diff --git a/backend-java/.gitignore b/backend-java/.gitignore new file mode 100644 index 0000000..a391884 --- /dev/null +++ b/backend-java/.gitignore @@ -0,0 +1,5 @@ +target/ +uploads/ +*.log +.idea/ +*.iml diff --git a/backend-java/pom.xml b/backend-java/pom.xml new file mode 100644 index 0000000..f26012d --- /dev/null +++ b/backend-java/pom.xml @@ -0,0 +1,198 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.competition + competition-management-system + 1.0.0 + competition-management-system + 少儿绘本创作活动管理平台 - Java 后端 + + + 17 + 3.5.7 + 1.2.23 + 0.12.6 + 4.5.0 + 1.5.5.Final + 5.8.32 + 2.0.53 + 5.6.227 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-aop + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + + com.alibaba + druid-spring-boot-3-starter + ${druid.version} + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + ${knife4j.version} + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + + cn.hutool + hutool-all + ${hutool.version} + + + + + com.alibaba.fastjson2 + fastjson2 + ${fastjson2.version} + + + + + com.qcloud + cos_api + ${cos.version} + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + + + + + diff --git a/backend-java/src/main/java/com/competition/CompetitionApplication.java b/backend-java/src/main/java/com/competition/CompetitionApplication.java new file mode 100644 index 0000000..3a68f24 --- /dev/null +++ b/backend-java/src/main/java/com/competition/CompetitionApplication.java @@ -0,0 +1,22 @@ +package com.competition; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@MapperScan({ + "com.competition.modules.sys.mapper", + "com.competition.modules.biz.contest.mapper", + "com.competition.modules.biz.review.mapper", + "com.competition.modules.biz.homework.mapper", + "com.competition.modules.biz.judge.mapper", + "com.competition.modules.user.mapper", + "com.competition.modules.ugc.mapper" +}) +public class CompetitionApplication { + + public static void main(String[] args) { + SpringApplication.run(CompetitionApplication.class, args); + } +} diff --git a/backend-java/src/main/java/com/competition/common/config/CorsConfig.java b/backend-java/src/main/java/com/competition/common/config/CorsConfig.java new file mode 100644 index 0000000..b207f95 --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/config/CorsConfig.java @@ -0,0 +1,28 @@ +package com.competition.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +/** + * 跨域配置 + */ +@Configuration +public class CorsConfig { + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.addExposedHeader("X-Trace-Id"); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } +} diff --git a/backend-java/src/main/java/com/competition/common/config/Knife4jConfig.java b/backend-java/src/main/java/com/competition/common/config/Knife4jConfig.java new file mode 100644 index 0000000..2cbd578 --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/config/Knife4jConfig.java @@ -0,0 +1,29 @@ +package com.competition.common.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Knife4j / OpenAPI 配置 + */ +@Configuration +public class Knife4jConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("少儿绘本创作活动管理平台 API") + .description("Competition Management System - Java Backend") + .version("1.0.0")) + .addSecurityItem(new SecurityRequirement().addList("Bearer")) + .schemaRequirement("Bearer", new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + } +} diff --git a/backend-java/src/main/java/com/competition/common/config/MyBatisPlusConfig.java b/backend-java/src/main/java/com/competition/common/config/MyBatisPlusConfig.java new file mode 100644 index 0000000..497e298 --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/config/MyBatisPlusConfig.java @@ -0,0 +1,22 @@ +package com.competition.common.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MyBatis-Plus 配置 + */ +@Configuration +public class MyBatisPlusConfig { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + // 分页插件 + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } +} diff --git a/backend-java/src/main/java/com/competition/common/config/WebMvcConfig.java b/backend-java/src/main/java/com/competition/common/config/WebMvcConfig.java new file mode 100644 index 0000000..334dd67 --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/config/WebMvcConfig.java @@ -0,0 +1,22 @@ +package com.competition.common.config; + +import com.competition.common.interceptor.TraceIdInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * WebMvc 配置 + */ +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final TraceIdInterceptor traceIdInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(traceIdInterceptor).addPathPatterns("/**"); + } +} diff --git a/backend-java/src/main/java/com/competition/common/entity/BaseEntity.java b/backend-java/src/main/java/com/competition/common/entity/BaseEntity.java new file mode 100644 index 0000000..404b7f1 --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/entity/BaseEntity.java @@ -0,0 +1,55 @@ +package com.competition.common.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 基础实体类,所有实体继承此类 + * 当前阶段使用现有数据库列名,新审计字段暂不映射(待 Flyway 迁移后启用) + */ +@Data +public abstract class BaseEntity implements Serializable { + + /** 主键 ID(自增) */ + @TableId(type = IdType.AUTO) + private Long id; + + // ====== 新审计字段(列尚未存在,暂不映射到数据库) ====== + + /** 创建人账号(待 Flyway V2 后启用) */ + @TableField(exist = false) + private String createBy; + + /** 更新人账号(待 Flyway V2 后启用) */ + @TableField(exist = false) + private String updateBy; + + /** 逻辑删除标识(待 Flyway V2 后启用) */ + @TableField(exist = false) + private Integer deleted; + + // ====== 现有审计字段(与当前数据库一致) ====== + + /** 创建人 ID */ + @TableField(value = "creator", fill = FieldFill.INSERT) + private Integer creator; + + /** 修改人 ID */ + @TableField(value = "modifier", fill = FieldFill.INSERT_UPDATE) + private Integer modifier; + + /** 创建时间 */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** 修改时间(数据库列名为 modify_time) */ + @TableField(value = "modify_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime modifyTime; + + /** 有效状态:1-有效,2-失效 */ + @TableField(value = "valid_state", fill = FieldFill.INSERT) + private Integer validState; +} diff --git a/backend-java/src/main/java/com/competition/common/enums/ErrorCode.java b/backend-java/src/main/java/com/competition/common/enums/ErrorCode.java new file mode 100644 index 0000000..59df20e --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/enums/ErrorCode.java @@ -0,0 +1,23 @@ +package com.competition.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 错误码枚举 + */ +@Getter +@AllArgsConstructor +public enum ErrorCode { + + SUCCESS(200, "success"), + BAD_REQUEST(400, "请求参数错误"), + UNAUTHORIZED(401, "未登录或 Token 已过期"), + FORBIDDEN(403, "没有访问权限"), + NOT_FOUND(404, "资源不存在"), + CONFLICT(409, "数据冲突"), + INTERNAL_ERROR(500, "系统内部错误"); + + private final Integer code; + private final String message; +} diff --git a/backend-java/src/main/java/com/competition/common/exception/BusinessException.java b/backend-java/src/main/java/com/competition/common/exception/BusinessException.java new file mode 100644 index 0000000..266bbfe --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/exception/BusinessException.java @@ -0,0 +1,36 @@ +package com.competition.common.exception; + +import com.competition.common.enums.ErrorCode; +import lombok.Getter; + +/** + * 业务异常 + */ +@Getter +public class BusinessException extends RuntimeException { + + private final Integer code; + + public BusinessException(Integer code, String message) { + super(message); + this.code = code; + } + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.code = errorCode.getCode(); + } + + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.code = errorCode.getCode(); + } + + public static BusinessException of(ErrorCode errorCode) { + return new BusinessException(errorCode); + } + + public static BusinessException of(ErrorCode errorCode, String message) { + return new BusinessException(errorCode, message); + } +} diff --git a/backend-java/src/main/java/com/competition/common/exception/GlobalExceptionHandler.java b/backend-java/src/main/java/com/competition/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..465890f --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,83 @@ +package com.competition.common.exception; + +import com.competition.common.enums.ErrorCode; +import com.competition.common.result.Result; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +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; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.util.stream.Collectors; + +/** + * 全局异常处理器 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** 业务异常 */ + @ExceptionHandler(BusinessException.class) + public Result handleBusinessException(BusinessException e, HttpServletRequest request) { + log.warn("业务异常,路径:{},消息:{}", request.getRequestURI(), e.getMessage()); + return Result.error(e.getCode(), e.getMessage(), request.getRequestURI()); + } + + /** 参数校验异常(@Valid) */ + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + log.warn("参数校验失败,路径:{},消息:{}", request.getRequestURI(), message); + return Result.error(400, message, request.getRequestURI()); + } + + /** 参数绑定异常 */ + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleBindException(BindException e, HttpServletRequest request) { + String message = e.getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + log.warn("参数绑定失败,路径:{},消息:{}", request.getRequestURI(), message); + return Result.error(400, message, request.getRequestURI()); + } + + /** 认证异常 */ + @ExceptionHandler(AuthenticationException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public Result handleAuthenticationException(AuthenticationException e, HttpServletRequest request) { + return Result.error(401, ErrorCode.UNAUTHORIZED.getMessage(), request.getRequestURI()); + } + + /** 授权异常 */ + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public Result handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request) { + return Result.error(403, ErrorCode.FORBIDDEN.getMessage(), request.getRequestURI()); + } + + /** 资源不存在 */ + @ExceptionHandler(NoResourceFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public Result handleNotFoundException(NoResourceFoundException e, HttpServletRequest request) { + return Result.error(404, ErrorCode.NOT_FOUND.getMessage(), request.getRequestURI()); + } + + /** 兜底:未知异常 */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleException(Exception e, HttpServletRequest request) { + log.error("系统异常,路径:{},消息:{}", request.getRequestURI(), e.getMessage(), e); + return Result.error(500, ErrorCode.INTERNAL_ERROR.getMessage(), request.getRequestURI()); + } +} diff --git a/backend-java/src/main/java/com/competition/common/handler/AuditMetaObjectHandler.java b/backend-java/src/main/java/com/competition/common/handler/AuditMetaObjectHandler.java new file mode 100644 index 0000000..a03adfe --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/handler/AuditMetaObjectHandler.java @@ -0,0 +1,45 @@ +package com.competition.common.handler; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.competition.common.util.SecurityUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * MyBatis-Plus 审计字段自动填充 + * 同时填充新字段(create_by/update_by/deleted)和旧字段(creator/modifier/valid_state) + */ +@Slf4j +@Component +public class AuditMetaObjectHandler implements MetaObjectHandler { + + @Override + public void insertFill(MetaObject metaObject) { + LocalDateTime now = LocalDateTime.now(); + Long userId = SecurityUtil.getCurrentUserIdOrNull(); + + this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now); + this.strictInsertFill(metaObject, "modifyTime", LocalDateTime.class, now); + this.strictInsertFill(metaObject, "validState", Integer.class, 1); + + if (userId != null) { + this.strictInsertFill(metaObject, "creator", Integer.class, userId.intValue()); + this.strictInsertFill(metaObject, "modifier", Integer.class, userId.intValue()); + } + } + + @Override + public void updateFill(MetaObject metaObject) { + LocalDateTime now = LocalDateTime.now(); + Long userId = SecurityUtil.getCurrentUserIdOrNull(); + + this.strictUpdateFill(metaObject, "modifyTime", LocalDateTime.class, now); + + if (userId != null) { + this.strictUpdateFill(metaObject, "modifier", Integer.class, userId.intValue()); + } + } +} diff --git a/backend-java/src/main/java/com/competition/common/interceptor/TraceIdInterceptor.java b/backend-java/src/main/java/com/competition/common/interceptor/TraceIdInterceptor.java new file mode 100644 index 0000000..ed177d5 --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/interceptor/TraceIdInterceptor.java @@ -0,0 +1,33 @@ +package com.competition.common.interceptor; + +import cn.hutool.core.util.IdUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * TraceId 链路追踪拦截器 + */ +@Component +public class TraceIdInterceptor implements HandlerInterceptor { + + private static final String TRACE_ID = "traceId"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String traceId = request.getHeader("X-Trace-Id"); + if (traceId == null || traceId.isBlank()) { + traceId = IdUtil.fastSimpleUUID(); + } + MDC.put(TRACE_ID, traceId); + response.setHeader("X-Trace-Id", traceId); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + MDC.remove(TRACE_ID); + } +} diff --git a/backend-java/src/main/java/com/competition/common/result/PageResult.java b/backend-java/src/main/java/com/competition/common/result/PageResult.java new file mode 100644 index 0000000..2818815 --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/result/PageResult.java @@ -0,0 +1,60 @@ +package com.competition.common.result; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 分页结果,格式与前端完全兼容:{ list, total, page, pageSize } + */ +@Data +public class PageResult implements Serializable { + + /** 数据列表 */ + private List list; + + /** 总记录数 */ + private Long total; + + /** 当前页码 */ + private Long page; + + /** 每页大小 */ + private Long pageSize; + + public PageResult() { + } + + public PageResult(List list, Long total, Long page, Long pageSize) { + this.list = list; + this.total = total; + this.page = page; + this.pageSize = pageSize; + } + + /** + * 从 MyBatis-Plus 的 IPage 转换 + */ + public static PageResult from(IPage page) { + return new PageResult<>( + page.getRecords(), + page.getTotal(), + page.getCurrent(), + page.getSize() + ); + } + + /** + * 从 MyBatis-Plus 的 IPage 转换,支持 VO 列表替换 + */ + public static PageResult from(IPage page, List voList) { + return new PageResult<>( + voList, + page.getTotal(), + page.getCurrent(), + page.getSize() + ); + } +} diff --git a/backend-java/src/main/java/com/competition/common/result/Result.java b/backend-java/src/main/java/com/competition/common/result/Result.java new file mode 100644 index 0000000..aaa47d9 --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/result/Result.java @@ -0,0 +1,62 @@ +package com.competition.common.result; + +import com.competition.common.enums.ErrorCode; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 统一响应结果 + * 格式与 NestJS 前端完全兼容:{ code, message, data, timestamp, path } + */ +@Data +public class Result implements Serializable { + + private Integer code; + private String message; + private T data; + private String timestamp; + private String path; + + private Result() { + } + + public static Result success(T data) { + Result result = new Result<>(); + result.setCode(200); + result.setMessage("success"); + result.setData(data); + return result; + } + + public static Result success() { + return success(null); + } + + public static Result success(String message, T data) { + Result result = new Result<>(); + result.setCode(200); + result.setMessage(message); + result.setData(data); + return result; + } + + public static Result error(Integer code, String message) { + Result result = new Result<>(); + result.setCode(code); + result.setMessage(message); + result.setTimestamp(LocalDateTime.now().toString()); + return result; + } + + public static Result error(ErrorCode errorCode) { + return error(errorCode.getCode(), errorCode.getMessage()); + } + + public static Result error(Integer code, String message, String path) { + Result result = error(code, message); + result.setPath(path); + return result; + } +} diff --git a/backend-java/src/main/java/com/competition/common/util/SecurityUtil.java b/backend-java/src/main/java/com/competition/common/util/SecurityUtil.java new file mode 100644 index 0000000..3be7085 --- /dev/null +++ b/backend-java/src/main/java/com/competition/common/util/SecurityUtil.java @@ -0,0 +1,68 @@ +package com.competition.common.util; + +import com.competition.security.model.LoginUser; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * 安全工具类 - 获取当前登录用户信息 + */ +public final class SecurityUtil { + + private SecurityUtil() { + } + + /** + * 获取当前登录用户 + */ + public static LoginUser getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof LoginUser loginUser) { + return loginUser; + } + return null; + } + + /** + * 获取当前用户 ID(未登录返回 null) + */ + public static Long getCurrentUserIdOrNull() { + LoginUser user = getCurrentUser(); + return user != null ? user.getUserId() : null; + } + + /** + * 获取当前用户 ID(未登录抛异常) + */ + public static Long getCurrentUserId() { + LoginUser user = getCurrentUser(); + if (user == null) { + throw new RuntimeException("用户未登录"); + } + return user.getUserId(); + } + + /** + * 获取当前用户名(未登录返回 "system") + */ + public static String getCurrentUsername() { + LoginUser user = getCurrentUser(); + return user != null ? user.getUsername() : "system"; + } + + /** + * 获取当前租户 ID(未登录返回 null) + */ + public static Long getCurrentTenantId() { + LoginUser user = getCurrentUser(); + return user != null ? user.getTenantId() : null; + } + + /** + * 当前用户是否为超级管理员 + */ + public static boolean isSuperAdmin() { + LoginUser user = getCurrentUser(); + return user != null && user.isSuperAdmin(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestAttachmentController.java b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestAttachmentController.java new file mode 100644 index 0000000..80e235a --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestAttachmentController.java @@ -0,0 +1,65 @@ +package com.competition.modules.biz.contest.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.competition.common.result.Result; +import com.competition.modules.biz.contest.entity.BizContestAttachment; +import com.competition.modules.biz.contest.service.IContestAttachmentService; +import com.competition.security.annotation.RequirePermission; +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 = "赛事附件") +@RestController +@RequestMapping("/contests/attachments") +@RequiredArgsConstructor +public class ContestAttachmentController { + + private final IContestAttachmentService attachmentService; + + @PostMapping + @RequirePermission("contest:update") + @Operation(summary = "上传附件") + public Result create(@RequestBody BizContestAttachment attachment) { + attachmentService.save(attachment); + return Result.success(attachment); + } + + @GetMapping("/contest/{contestId}") + @RequirePermission("contest:read") + @Operation(summary = "查询赛事下的附件列表") + public Result> findByContest(@PathVariable Long contestId) { + List list = attachmentService.list( + new LambdaQueryWrapper() + .eq(BizContestAttachment::getContestId, contestId) + .orderByDesc(BizContestAttachment::getCreateTime)); + return Result.success(list); + } + + @GetMapping("/{id}") + @RequirePermission("contest:read") + @Operation(summary = "查询附件详情") + public Result findDetail(@PathVariable Long id) { + return Result.success(attachmentService.getById(id)); + } + + @PatchMapping("/{id}") + @RequirePermission("contest:update") + @Operation(summary = "更新附件") + public Result update(@PathVariable Long id, @RequestBody BizContestAttachment attachment) { + attachment.setId(id); + attachmentService.updateById(attachment); + return Result.success(); + } + + @DeleteMapping("/{id}") + @RequirePermission("contest:update") + @Operation(summary = "删除附件") + public Result remove(@PathVariable Long id) { + attachmentService.removeById(id); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestController.java b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestController.java new file mode 100644 index 0000000..b5effd2 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestController.java @@ -0,0 +1,115 @@ +package com.competition.modules.biz.contest.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.contest.dto.CreateContestDto; +import com.competition.modules.biz.contest.dto.QueryContestDto; +import com.competition.modules.biz.contest.entity.BizContest; +import com.competition.modules.biz.contest.service.IContestService; +import com.competition.modules.sys.service.ISysTenantService; +import com.competition.security.annotation.RequirePermission; +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.Map; + +@Tag(name = "赛事管理") +@RestController +@RequestMapping("/contests") +@RequiredArgsConstructor +public class ContestController { + + private final IContestService contestService; + private final ISysTenantService tenantService; + + @PostMapping + @RequirePermission("contest:create") + @Operation(summary = "创建赛事") + public Result create(@Valid @RequestBody CreateContestDto dto) { + return Result.success(contestService.createContest(dto, SecurityUtil.getCurrentUserId())); + } + + @GetMapping("/stats") + @RequirePermission("contest:read") + @Operation(summary = "获取赛事统计") + public Result> getStats() { + Long tenantId = SecurityUtil.getCurrentTenantId(); + boolean isSuperTenant = tenantService.isSuperTenant(tenantId); + return Result.success(contestService.getStats(tenantId, isSuperTenant)); + } + + @GetMapping("/dashboard") + @RequirePermission("contest:read") + @Operation(summary = "获取赛事看板") + public Result> getDashboard() { + return Result.success(contestService.getDashboard(SecurityUtil.getCurrentTenantId())); + } + + @GetMapping + @RequirePermission("contest:read") + @Operation(summary = "查询赛事列表") + public Result>> findAll(QueryContestDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + boolean isSuperTenant = tenantService.isSuperTenant(tenantId); + return Result.success(contestService.findAll(dto, tenantId, isSuperTenant)); + } + + @GetMapping("/my-contests") + @RequirePermission({"contest:read", "contest:activity:read"}) + @Operation(summary = "获取我的赛事") + public Result>> getMyContests(QueryContestDto dto) { + Long userId = SecurityUtil.getCurrentUserId(); + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(contestService.getMyContests(dto, userId, tenantId)); + } + + @GetMapping("/{id}") + @RequirePermission("contest:read") + @Operation(summary = "查询赛事详情") + public Result> findDetail(@PathVariable Long id) { + return Result.success(contestService.findDetail(id)); + } + + @PatchMapping("/{id}") + @RequirePermission("contest:update") + @Operation(summary = "更新赛事") + public Result update(@PathVariable Long id, @RequestBody CreateContestDto dto) { + return Result.success(contestService.updateContest(id, dto)); + } + + @PatchMapping("/{id}/publish") + @RequirePermission("contest:publish") + @Operation(summary = "发布/撤回赛事") + public Result publish(@PathVariable Long id, @RequestBody Map body) { + contestService.publishContest(id, body.get("contestState")); + return Result.success(); + } + + @PatchMapping("/{id}/finish") + @RequirePermission("contest:update") + @Operation(summary = "结束赛事") + public Result finish(@PathVariable Long id) { + contestService.finishContest(id); + return Result.success(); + } + + @PatchMapping("/{id}/reopen") + @RequirePermission("contest:update") + @Operation(summary = "重新开放赛事") + public Result reopen(@PathVariable Long id) { + contestService.reopenContest(id); + return Result.success(); + } + + @DeleteMapping("/{id}") + @RequirePermission("contest:delete") + @Operation(summary = "删除赛事") + public Result remove(@PathVariable Long id) { + contestService.removeContest(id); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestNoticeController.java b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestNoticeController.java new file mode 100644 index 0000000..e11727b --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestNoticeController.java @@ -0,0 +1,102 @@ +package com.competition.modules.biz.contest.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.modules.biz.contest.dto.CreateNoticeDto; +import com.competition.modules.biz.contest.entity.BizContestNotice; +import com.competition.modules.biz.contest.service.IContestNoticeService; +import com.competition.security.annotation.RequirePermission; +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.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Tag(name = "赛事公告") +@RestController +@RequestMapping("/contests/notices") +@RequiredArgsConstructor +public class ContestNoticeController { + + private final IContestNoticeService noticeService; + + @PostMapping + @RequirePermission("notice:create") + @Operation(summary = "创建公告") + public Result create(@Valid @RequestBody CreateNoticeDto dto) { + BizContestNotice notice = new BizContestNotice(); + notice.setContestId(dto.getContestId()); + notice.setTitle(dto.getTitle()); + notice.setContent(dto.getContent()); + notice.setNoticeType(dto.getNoticeType()); + notice.setPriority(dto.getPriority()); + if (StringUtils.hasText(dto.getPublishTime())) { + notice.setPublishTime(LocalDateTime.parse(dto.getPublishTime())); + } + noticeService.save(notice); + return Result.success(notice); + } + + @GetMapping("/contest/{contestId}") + @RequirePermission("notice:read") + @Operation(summary = "查询赛事下的公告列表") + public Result> findByContest(@PathVariable Long contestId) { + List list = noticeService.list( + new LambdaQueryWrapper() + .eq(BizContestNotice::getContestId, contestId) + .orderByDesc(BizContestNotice::getCreateTime)); + return Result.success(list); + } + + @GetMapping + @RequirePermission("notice:read") + @Operation(summary = "分页查询公告列表") + public Result> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize, + @RequestParam(required = false) String title) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .like(StringUtils.hasText(title), BizContestNotice::getTitle, title) + .orderByDesc(BizContestNotice::getCreateTime); + Page result = noticeService.page(new Page<>(page, pageSize), wrapper); + return Result.success(PageResult.from(result)); + } + + @GetMapping("/{id}") + @RequirePermission("notice:read") + @Operation(summary = "查询公告详情") + public Result findDetail(@PathVariable Long id) { + return Result.success(noticeService.getById(id)); + } + + @PatchMapping("/{id}") + @RequirePermission("notice:update") + @Operation(summary = "更新公告") + public Result update(@PathVariable Long id, @RequestBody CreateNoticeDto dto) { + BizContestNotice notice = new BizContestNotice(); + notice.setId(id); + notice.setTitle(dto.getTitle()); + notice.setContent(dto.getContent()); + notice.setNoticeType(dto.getNoticeType()); + notice.setPriority(dto.getPriority()); + if (StringUtils.hasText(dto.getPublishTime())) { + notice.setPublishTime(LocalDateTime.parse(dto.getPublishTime())); + } + noticeService.updateById(notice); + return Result.success(); + } + + @DeleteMapping("/{id}") + @RequirePermission("notice:delete") + @Operation(summary = "删除公告") + public Result remove(@PathVariable Long id) { + noticeService.removeById(id); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestRegistrationController.java b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestRegistrationController.java new file mode 100644 index 0000000..11990d6 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestRegistrationController.java @@ -0,0 +1,125 @@ +package com.competition.modules.biz.contest.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.contest.dto.CreateRegistrationDto; +import com.competition.modules.biz.contest.dto.QueryRegistrationDto; +import com.competition.modules.biz.contest.service.IContestRegistrationService; +import com.competition.security.annotation.RequirePermission; +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; +import java.util.Map; + +@Tag(name = "报名管理") +@RestController +@RequestMapping("/contests/registrations") +@RequiredArgsConstructor +public class ContestRegistrationController { + + private final IContestRegistrationService registrationService; + + @PostMapping + @RequirePermission("contest:register") + @Operation(summary = "创建报名") + public Result> create(@Valid @RequestBody CreateRegistrationDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(registrationService.createRegistration(dto, tenantId, userId)); + } + + @GetMapping("/stats") + @RequirePermission("contest:read") + @Operation(summary = "获取报名统计") + public Result> getStats(@RequestParam(required = false) Long contestId) { + return Result.success(registrationService.getStats(contestId, SecurityUtil.getCurrentTenantId())); + } + + @GetMapping + @RequirePermission("contest:read") + @Operation(summary = "查询报名列表") + public Result>> findAll(QueryRegistrationDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + boolean isSuperTenant = SecurityUtil.isSuperAdmin(); + return Result.success(registrationService.findAll(dto, tenantId, isSuperTenant)); + } + + @GetMapping("/my/{contestId}") + @RequirePermission("contest:read") + @Operation(summary = "获取我的报名信息") + public Result> getMyRegistration(@PathVariable Long contestId) { + Long userId = SecurityUtil.getCurrentUserId(); + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(registrationService.getMyRegistration(contestId, userId, tenantId)); + } + + @GetMapping("/{id}") + @RequirePermission("contest:read") + @Operation(summary = "查询报名详情") + public Result> findDetail(@PathVariable Long id) { + return Result.success(registrationService.findDetail(id, SecurityUtil.getCurrentTenantId())); + } + + @PatchMapping("/{id}/review") + @RequirePermission("contest:update") + @Operation(summary = "审核报名") + public Result review(@PathVariable Long id, @RequestBody Map body) { + Long operatorId = SecurityUtil.getCurrentUserId(); + Long tenantId = SecurityUtil.getCurrentTenantId(); + registrationService.reviewRegistration(id, body.get("registrationState"), body.get("reason"), operatorId, tenantId); + return Result.success(); + } + + @PatchMapping("/{id}/revoke") + @RequirePermission("contest:update") + @Operation(summary = "撤回审核") + public Result revoke(@PathVariable Long id) { + registrationService.revokeReview(id, SecurityUtil.getCurrentTenantId()); + return Result.success(); + } + + @PostMapping("/batch-review") + @RequirePermission("contest:update") + @Operation(summary = "批量审核报名") + @SuppressWarnings("unchecked") + public Result batchReview(@RequestBody Map body) { + List ids = (List) body.get("ids"); + String registrationState = (String) body.get("registrationState"); + String reason = (String) body.get("reason"); + Long operatorId = SecurityUtil.getCurrentUserId(); + Long tenantId = SecurityUtil.getCurrentTenantId(); + registrationService.batchReview(ids, registrationState, reason, operatorId, tenantId); + return Result.success(); + } + + @PostMapping("/{id}/teachers") + @RequirePermission("contest:update") + @Operation(summary = "添加指导老师") + public Result addTeacher(@PathVariable Long id, @RequestBody Map body) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + Long creatorId = SecurityUtil.getCurrentUserId(); + registrationService.addTeacher(id, body.get("teacherUserId"), tenantId, creatorId); + return Result.success(); + } + + @DeleteMapping("/{id}/teachers/{teacherUserId}") + @RequirePermission("contest:update") + @Operation(summary = "移除指导老师") + public Result removeTeacher(@PathVariable Long id, @PathVariable Long teacherUserId) { + registrationService.removeTeacher(id, teacherUserId, SecurityUtil.getCurrentTenantId()); + return Result.success(); + } + + @DeleteMapping("/{id}") + @RequirePermission("contest:update") + @Operation(summary = "删除报名") + public Result remove(@PathVariable Long id) { + registrationService.removeRegistration(id, SecurityUtil.getCurrentTenantId()); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestTeamController.java b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestTeamController.java new file mode 100644 index 0000000..226f291 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestTeamController.java @@ -0,0 +1,81 @@ +package com.competition.modules.biz.contest.controller; + +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.contest.dto.CreateTeamDto; +import com.competition.modules.biz.contest.entity.BizContestTeam; +import com.competition.modules.biz.contest.service.IContestTeamService; +import com.competition.security.annotation.RequirePermission; +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; +import java.util.Map; + +@Tag(name = "团队管理") +@RestController +@RequestMapping("/contests/teams") +@RequiredArgsConstructor +public class ContestTeamController { + + private final IContestTeamService teamService; + + @PostMapping + @RequirePermission("team:create") + @Operation(summary = "创建团队") + public Result> create(@Valid @RequestBody CreateTeamDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + Long creatorId = SecurityUtil.getCurrentUserId(); + return Result.success(teamService.createTeam(dto, tenantId, creatorId)); + } + + @GetMapping("/contest/{contestId}") + @RequirePermission("team:read") + @Operation(summary = "查询赛事下的团队列表") + public Result>> findByContest(@PathVariable Long contestId) { + return Result.success(teamService.findByContest(contestId, SecurityUtil.getCurrentTenantId())); + } + + @GetMapping("/{id}") + @RequirePermission("team:read") + @Operation(summary = "查询团队详情") + public Result> findDetail(@PathVariable Long id) { + return Result.success(teamService.findDetail(id)); + } + + @PatchMapping("/{id}") + @RequirePermission("team:update") + @Operation(summary = "更新团队") + public Result update(@PathVariable Long id, @RequestBody CreateTeamDto dto) { + return Result.success(teamService.updateTeam(id, dto)); + } + + @PostMapping("/{id}/members") + @RequirePermission("team:update") + @Operation(summary = "添加团队成员") + public Result addMember(@PathVariable Long id, @RequestBody Map body) { + Long userId = ((Number) body.get("userId")).longValue(); + String role = (String) body.get("role"); + teamService.addMember(id, userId, role, SecurityUtil.getCurrentTenantId()); + return Result.success(); + } + + @DeleteMapping("/{id}/members/{userId}") + @RequirePermission("team:update") + @Operation(summary = "移除团队成员") + public Result removeMember(@PathVariable Long id, @PathVariable Long userId) { + teamService.removeMember(id, userId); + return Result.success(); + } + + @DeleteMapping("/{id}") + @RequirePermission("team:delete") + @Operation(summary = "删除团队") + public Result remove(@PathVariable Long id) { + teamService.removeTeam(id); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestWorkController.java b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestWorkController.java new file mode 100644 index 0000000..9166846 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/controller/ContestWorkController.java @@ -0,0 +1,87 @@ +package com.competition.modules.biz.contest.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.contest.dto.QueryWorkDto; +import com.competition.modules.biz.contest.dto.SubmitWorkDto; +import com.competition.modules.biz.contest.service.IContestWorkService; +import com.competition.security.annotation.RequirePermission; +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; +import java.util.Map; + +@Tag(name = "作品管理") +@RestController +@RequestMapping("/contests/works") +@RequiredArgsConstructor +public class ContestWorkController { + + private final IContestWorkService workService; + + @PostMapping("/submit") + @RequirePermission("work:submit") + @Operation(summary = "提交作品") + public Result> submit(@Valid @RequestBody SubmitWorkDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + Long submitterId = SecurityUtil.getCurrentUserId(); + return Result.success(workService.submitWork(dto, tenantId, submitterId)); + } + + @GetMapping("/stats") + @RequirePermission("work:read") + @Operation(summary = "获取作品统计") + public Result> getStats(@RequestParam(required = false) Long contestId) { + return Result.success(workService.getStats(contestId, SecurityUtil.getCurrentTenantId())); + } + + @GetMapping + @RequirePermission("work:read") + @Operation(summary = "查询作品列表") + public Result>> findAll(QueryWorkDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + boolean isSuperTenant = SecurityUtil.isSuperAdmin(); + return Result.success(workService.findAll(dto, tenantId, isSuperTenant)); + } + + @GetMapping("/guided") + @RequirePermission("activity:read") + @Operation(summary = "查询辅导作品") + public Result>> getGuidedWorks( + @RequestParam(required = false) Long contestId, + @RequestParam(required = false) String workNo, + @RequestParam(required = false) String playerName, + @RequestParam(required = false) String accountNo, + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(workService.getGuidedWorks(contestId, workNo, playerName, accountNo, page, pageSize, userId)); + } + + @GetMapping("/{id}") + @RequirePermission("work:read") + @Operation(summary = "查询作品详情") + public Result> findDetail(@PathVariable Long id) { + return Result.success(workService.findDetail(id)); + } + + @GetMapping("/registration/{registrationId}/versions") + @RequirePermission("work:read") + @Operation(summary = "查询作品版本历史") + public Result>> getWorkVersions(@PathVariable Long registrationId) { + return Result.success(workService.getWorkVersions(registrationId)); + } + + @DeleteMapping("/{id}") + @RequirePermission("work:update") + @Operation(summary = "删除作品") + public Result remove(@PathVariable Long id) { + workService.removeWork(id); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateContestDto.java b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateContestDto.java new file mode 100644 index 0000000..5e9a504 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateContestDto.java @@ -0,0 +1,130 @@ +package com.competition.modules.biz.contest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "创建赛事DTO") +public class CreateContestDto { + + @NotBlank(message = "赛事名称不能为空") + @Schema(description = "赛事名称") + private String contestName; + + @NotBlank(message = "赛事类型不能为空") + @Schema(description = "赛事类型") + private String contestType; + + @Schema(description = "可见性") + private String visibility; + + @Schema(description = "目标城市列表") + private List targetCities; + + @Schema(description = "最小年龄") + private Integer ageMin; + + @Schema(description = "最大年龄") + private Integer ageMax; + + @NotBlank(message = "开始时间不能为空") + @Schema(description = "开始时间") + private String startTime; + + @NotBlank(message = "结束时间不能为空") + @Schema(description = "结束时间") + private String endTime; + + @Schema(description = "地址") + private String address; + + @Schema(description = "赛事内容") + private String content; + + @Schema(description = "赛事关联租户ID列表") + private List contestTenants; + + @Schema(description = "封面图URL") + private String coverUrl; + + @Schema(description = "海报URL") + private String posterUrl; + + @Schema(description = "联系人姓名") + private String contactName; + + @Schema(description = "联系人电话") + private String contactPhone; + + @Schema(description = "联系人二维码") + private String contactQrcode; + + @Schema(description = "主办方") + private Object organizers; + + @Schema(description = "协办方") + private Object coOrganizers; + + @Schema(description = "赞助方") + private Object sponsors; + + @NotBlank(message = "报名开始时间不能为空") + @Schema(description = "报名开始时间") + private String registerStartTime; + + @NotBlank(message = "报名结束时间不能为空") + @Schema(description = "报名结束时间") + private String registerEndTime; + + @Schema(description = "报名状态") + private String registerState; + + @Schema(description = "是否需要审核") + private Boolean requireAudit; + + @Schema(description = "允许的年级列表") + private List allowedGrades; + + @Schema(description = "允许的班级列表") + private List allowedClasses; + + @Schema(description = "团队最小人数") + private Integer teamMinMembers; + + @Schema(description = "团队最大人数") + private Integer teamMaxMembers; + + @Schema(description = "提交规则") + private String submitRule; + + @NotBlank(message = "提交开始时间不能为空") + @Schema(description = "提交开始时间") + private String submitStartTime; + + @NotBlank(message = "提交结束时间不能为空") + @Schema(description = "提交结束时间") + private String submitEndTime; + + @Schema(description = "作品类型") + private String workType; + + @Schema(description = "作品要求") + private String workRequirement; + + @Schema(description = "评审规则ID") + private Long reviewRuleId; + + @NotBlank(message = "评审开始时间不能为空") + @Schema(description = "评审开始时间") + private String reviewStartTime; + + @NotBlank(message = "评审结束时间不能为空") + @Schema(description = "评审结束时间") + private String reviewEndTime; + + @Schema(description = "结果发布时间") + private String resultPublishTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateNoticeDto.java b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateNoticeDto.java new file mode 100644 index 0000000..e638232 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateNoticeDto.java @@ -0,0 +1,32 @@ +package com.competition.modules.biz.contest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +@Schema(description = "创建公告DTO") +public class CreateNoticeDto { + + @NotNull(message = "赛事ID不能为空") + @Schema(description = "赛事ID") + private Long contestId; + + @NotBlank(message = "公告标题不能为空") + @Schema(description = "公告标题") + private String title; + + @NotBlank(message = "公告内容不能为空") + @Schema(description = "公告内容") + private String content; + + @Schema(description = "公告类型") + private String noticeType; + + @Schema(description = "优先级") + private Integer priority; + + @Schema(description = "发布时间") + private String publishTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateRegistrationDto.java b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateRegistrationDto.java new file mode 100644 index 0000000..ba25d23 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateRegistrationDto.java @@ -0,0 +1,26 @@ +package com.competition.modules.biz.contest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +@Schema(description = "创建报名DTO") +public class CreateRegistrationDto { + + @NotNull(message = "赛事ID不能为空") + @Schema(description = "赛事ID") + private Long contestId; + + @NotBlank(message = "报名类型不能为空") + @Schema(description = "报名类型") + private String registrationType; + + @Schema(description = "团队ID") + private Long teamId; + + @NotNull(message = "用户ID不能为空") + @Schema(description = "用户ID") + private Long userId; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateTeamDto.java b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateTeamDto.java new file mode 100644 index 0000000..492f3c9 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/CreateTeamDto.java @@ -0,0 +1,34 @@ +package com.competition.modules.biz.contest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "创建团队DTO") +public class CreateTeamDto { + + @NotNull(message = "赛事ID不能为空") + @Schema(description = "赛事ID") + private Long contestId; + + @NotBlank(message = "团队名称不能为空") + @Schema(description = "团队名称") + private String teamName; + + @NotNull(message = "队长ID不能为空") + @Schema(description = "队长ID") + private Long leaderId; + + @Schema(description = "成员ID列表") + private List memberIds; + + @Schema(description = "指导老师ID列表") + private List teacherIds; + + @Schema(description = "最大成员数") + private Integer maxMembers; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/dto/QueryContestDto.java b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/QueryContestDto.java new file mode 100644 index 0000000..0115f15 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/QueryContestDto.java @@ -0,0 +1,39 @@ +package com.competition.modules.biz.contest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "查询赛事DTO") +public class QueryContestDto { + + @Schema(description = "页码", defaultValue = "1") + private Long page = 1L; + + @Schema(description = "每页条数", defaultValue = "10") + private Long pageSize = 10L; + + @Schema(description = "赛事名称") + private String contestName; + + @Schema(description = "赛事状态") + private String contestState; + + @Schema(description = "状态") + private String status; + + @Schema(description = "赛事类型") + private String contestType; + + @Schema(description = "可见性") + private String visibility; + + @Schema(description = "赛事阶段") + private String stage; + + @Schema(description = "创建者租户ID") + private Long creatorTenantId; + + @Schema(description = "角色") + private String role; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/dto/QueryRegistrationDto.java b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/QueryRegistrationDto.java new file mode 100644 index 0000000..982d2a3 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/QueryRegistrationDto.java @@ -0,0 +1,33 @@ +package com.competition.modules.biz.contest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "查询报名DTO") +public class QueryRegistrationDto { + + @Schema(description = "页码", defaultValue = "1") + private Long page = 1L; + + @Schema(description = "每页条数", defaultValue = "10") + private Long pageSize = 10L; + + @Schema(description = "赛事ID") + private Long contestId; + + @Schema(description = "报名状态") + private String registrationState; + + @Schema(description = "报名类型") + private String registrationType; + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "参赛者类型") + private String participantType; + + @Schema(description = "关键词") + private String keyword; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/dto/QueryWorkDto.java b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/QueryWorkDto.java new file mode 100644 index 0000000..7b75ecb --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/QueryWorkDto.java @@ -0,0 +1,51 @@ +package com.competition.modules.biz.contest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "查询作品DTO") +public class QueryWorkDto { + + @Schema(description = "页码", defaultValue = "1") + private Long page = 1L; + + @Schema(description = "每页条数", defaultValue = "10") + private Long pageSize = 10L; + + @Schema(description = "赛事ID") + private Long contestId; + + @Schema(description = "报名ID") + private Long registrationId; + + @Schema(description = "状态") + private String status; + + @Schema(description = "作品标题") + private String title; + + @Schema(description = "作品编号") + private String workNo; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "关键词") + private String keyword; + + @Schema(description = "姓名") + private String name; + + @Schema(description = "分配状态") + private String assignStatus; + + @Schema(description = "租户ID") + private Long tenantId; + + @Schema(description = "提交开始时间") + private String submitStartTime; + + @Schema(description = "提交结束时间") + private String submitEndTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/dto/SubmitWorkDto.java b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/SubmitWorkDto.java new file mode 100644 index 0000000..ff30e53 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/dto/SubmitWorkDto.java @@ -0,0 +1,56 @@ +package com.competition.modules.biz.contest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "提交作品DTO") +public class SubmitWorkDto { + + @NotNull(message = "报名ID不能为空") + @Schema(description = "报名ID") + private Long registrationId; + + @NotBlank(message = "作品标题不能为空") + @Schema(description = "作品标题") + private String title; + + @Schema(description = "作品描述") + private String description; + + @Schema(description = "文件信息") + private Object files; + + @Schema(description = "预览图URL") + private String previewUrl; + + @Schema(description = "预览图URL列表") + private List previewUrls; + + @Schema(description = "AI模型元数据") + private Object aiModelMeta; + + @Schema(description = "附件列表") + private List attachments; + + @Data + @Schema(description = "附件项") + public static class AttachmentItem { + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "文件URL") + private String fileUrl; + + @Schema(description = "文件类型") + private String fileType; + + @Schema(description = "文件大小") + private String size; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContest.java b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContest.java new file mode 100644 index 0000000..e4e181c --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContest.java @@ -0,0 +1,152 @@ +package com.competition.modules.biz.contest.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 赛事实体(35+ 字段,7 个 JSON 列) + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "t_contest", autoResultMap = true) +public class BizContest extends BaseEntity { + + /** 赛事名称(唯一) */ + @TableField("contest_name") + private String contestName; + + /** 赛事类型:individual/team */ + @TableField("contest_type") + private String contestType; + + /** 赛事发布状态:unpublished/published */ + @TableField("contest_state") + private String contestState; + + /** 赛事进度状态:ongoing/finished */ + private String status; + + /** 开始时间 */ + @TableField("start_time") + private LocalDateTime startTime; + + /** 结束时间 */ + @TableField("end_time") + private LocalDateTime endTime; + + /** 线下地址 */ + private String address; + + /** 赛事详情(富文本) */ + private String content; + + /** 可见范围:public/designated/internal */ + private String visibility; + + // ====== 授权租户(JSON) ====== + /** 授权租户 ID 数组 */ + @TableField(value = "contest_tenants", typeHandler = JacksonTypeHandler.class) + private List contestTenants; + + // ====== 封面和联系方式 ====== + @TableField("cover_url") + private String coverUrl; + + @TableField("poster_url") + private String posterUrl; + + @TableField("contact_name") + private String contactName; + + @TableField("contact_phone") + private String contactPhone; + + @TableField("contact_qrcode") + private String contactQrcode; + + // ====== 主办/协办/赞助(JSON) ====== + @TableField(value = "organizers", typeHandler = JacksonTypeHandler.class) + private Object organizers; + + @TableField(value = "co_organizers", typeHandler = JacksonTypeHandler.class) + private Object coOrganizers; + + @TableField(value = "sponsors", typeHandler = JacksonTypeHandler.class) + private Object sponsors; + + // ====== 报名配置 ====== + @TableField("register_start_time") + private LocalDateTime registerStartTime; + + @TableField("register_end_time") + private LocalDateTime registerEndTime; + + @TableField("register_state") + private String registerState; + + @TableField("require_audit") + private Boolean requireAudit; + + @TableField(value = "allowed_grades", typeHandler = JacksonTypeHandler.class) + private List allowedGrades; + + @TableField(value = "allowed_classes", typeHandler = JacksonTypeHandler.class) + private List allowedClasses; + + @TableField("team_min_members") + private Integer teamMinMembers; + + @TableField("team_max_members") + private Integer teamMaxMembers; + + // ====== 目标筛选 ====== + @TableField(value = "target_cities", typeHandler = JacksonTypeHandler.class) + private List targetCities; + + @TableField("age_min") + private Integer ageMin; + + @TableField("age_max") + private Integer ageMax; + + // ====== 提交配置 ====== + @TableField("submit_rule") + private String submitRule; + + @TableField("submit_start_time") + private LocalDateTime submitStartTime; + + @TableField("submit_end_time") + private LocalDateTime submitEndTime; + + @TableField("work_type") + private String workType; + + @TableField("work_requirement") + private String workRequirement; + + // ====== 评审配置 ====== + @TableField("review_rule_id") + private Long reviewRuleId; + + @TableField("review_start_time") + private LocalDateTime reviewStartTime; + + @TableField("review_end_time") + private LocalDateTime reviewEndTime; + + // ====== 成果发布 ====== + @TableField("result_state") + private String resultState; + + @TableField("result_publish_time") + private LocalDateTime resultPublishTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestAttachment.java b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestAttachment.java new file mode 100644 index 0000000..7f136a8 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestAttachment.java @@ -0,0 +1,29 @@ +package com.competition.modules.biz.contest.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("t_contest_attachment") +public class BizContestAttachment extends BaseEntity { + + @TableField("contest_id") + private Long contestId; + + @TableField("file_name") + private String fileName; + + @TableField("file_url") + private String fileUrl; + + private String format; + + @TableField("file_type") + private String fileType; + + private String size; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestNotice.java b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestNotice.java new file mode 100644 index 0000000..eb6912f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestNotice.java @@ -0,0 +1,31 @@ +package com.competition.modules.biz.contest.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("t_contest_notice") +public class BizContestNotice extends BaseEntity { + + @TableField("contest_id") + private Long contestId; + + private String title; + + private String content; + + /** system/manual/urgent */ + @TableField("notice_type") + private String noticeType; + + private Integer priority; + + @TableField("publish_time") + private LocalDateTime publishTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestRegistration.java b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestRegistration.java new file mode 100644 index 0000000..688b7b3 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestRegistration.java @@ -0,0 +1,71 @@ +package com.competition.modules.biz.contest.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("t_contest_registration") +public class BizContestRegistration extends BaseEntity { + + @TableField("contest_id") + private Long contestId; + + @TableField("tenant_id") + private Long tenantId; + + /** 报名类型:individual/team */ + @TableField("registration_type") + private String registrationType; + + @TableField("team_id") + private Long teamId; + + /** 团队名称快照 */ + @TableField("team_name") + private String teamName; + + @TableField("user_id") + private Long userId; + + /** 账号快照 */ + @TableField("account_no") + private String accountNo; + + @TableField("account_name") + private String accountName; + + /** 角色快照:leader/member/mentor */ + private String role; + + /** 报名状态:pending/passed/rejected/withdrawn */ + @TableField("registration_state") + private String registrationState; + + /** 参与者类型:self/child */ + @TableField("participant_type") + private String participantType; + + @TableField("child_id") + private Long childId; + + /** 实际提交人 ID */ + private Integer registrant; + + @TableField("registration_time") + private LocalDateTime registrationTime; + + /** 审核原因 */ + private String reason; + + /** 审核操作人 */ + private Integer operator; + + @TableField("operation_date") + private LocalDateTime operationDate; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestRegistrationTeacher.java b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestRegistrationTeacher.java new file mode 100644 index 0000000..1d91b15 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestRegistrationTeacher.java @@ -0,0 +1,36 @@ +package com.competition.modules.biz.contest.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("t_contest_registration_teacher") +public class BizContestRegistrationTeacher implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("registration_id") + private Long registrationId; + + @TableField("tenant_id") + private Long tenantId; + + @TableField("user_id") + private Long userId; + + @TableField("is_default") + private Boolean isDefault; + + private Integer creator; + private Integer modifier; + + @TableField("create_time") + private LocalDateTime createTime; + + @TableField("modify_time") + private LocalDateTime modifyTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestTeam.java b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestTeam.java new file mode 100644 index 0000000..79afaff --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestTeam.java @@ -0,0 +1,28 @@ +package com.competition.modules.biz.contest.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("t_contest_team") +public class BizContestTeam extends BaseEntity { + + @TableField("tenant_id") + private Long tenantId; + + @TableField("contest_id") + private Long contestId; + + @TableField("team_name") + private String teamName; + + @TableField("leader_user_id") + private Long leaderUserId; + + @TableField("max_members") + private Integer maxMembers; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestTeamMember.java b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestTeamMember.java new file mode 100644 index 0000000..ac1ff3b --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestTeamMember.java @@ -0,0 +1,36 @@ +package com.competition.modules.biz.contest.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("t_contest_team_member") +public class BizContestTeamMember implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("tenant_id") + private Long tenantId; + + @TableField("team_id") + private Long teamId; + + @TableField("user_id") + private Long userId; + + /** 角色:member/leader/mentor */ + private String role; + + private Integer creator; + private Integer modifier; + + @TableField("create_time") + private LocalDateTime createTime; + + @TableField("modify_time") + private LocalDateTime modifyTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestWork.java b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestWork.java new file mode 100644 index 0000000..c9164eb --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestWork.java @@ -0,0 +1,88 @@ +package com.competition.modules.biz.contest.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "t_contest_work", autoResultMap = true) +public class BizContestWork extends BaseEntity { + + @TableField("tenant_id") + private Long tenantId; + + @TableField("contest_id") + private Long contestId; + + @TableField("registration_id") + private Long registrationId; + + /** 作品编号(唯一展示编号) */ + @TableField("work_no") + private String workNo; + + private String title; + + private String description; + + @TableField(value = "files", typeHandler = JacksonTypeHandler.class) + private Object files; + + private Integer version; + + @TableField("is_latest") + private Boolean isLatest; + + /** submitted/locked/reviewing/rejected/accepted */ + private String status; + + @TableField("submit_time") + private LocalDateTime submitTime; + + @TableField("submitter_user_id") + private Long submitterUserId; + + @TableField("submitter_account_no") + private String submitterAccountNo; + + /** teacher/student/team_leader */ + @TableField("submit_source") + private String submitSource; + + @TableField("preview_url") + private String previewUrl; + + @TableField(value = "preview_urls", typeHandler = JacksonTypeHandler.class) + private List previewUrls; + + @TableField(value = "ai_model_meta", typeHandler = JacksonTypeHandler.class) + private Object aiModelMeta; + + @TableField("user_work_id") + private Long userWorkId; + + // ====== 赛果字段 ====== + @TableField("final_score") + private BigDecimal finalScore; + + @TableField("`rank`") + private Integer rank; + + /** first/second/third/excellent/none */ + @TableField("award_level") + private String awardLevel; + + @TableField("award_name") + private String awardName; + + @TableField("certificate_url") + private String certificateUrl; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestWorkAttachment.java b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestWorkAttachment.java new file mode 100644 index 0000000..a2e9c01 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/entity/BizContestWorkAttachment.java @@ -0,0 +1,46 @@ +package com.competition.modules.biz.contest.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("t_contest_work_attachment") +public class BizContestWorkAttachment implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("tenant_id") + private Long tenantId; + + @TableField("contest_id") + private Long contestId; + + @TableField("work_id") + private Long workId; + + @TableField("file_name") + private String fileName; + + @TableField("file_url") + private String fileUrl; + + private String format; + + @TableField("file_type") + private String fileType; + + private String size; + + private Integer creator; + private Integer modifier; + + @TableField("create_time") + private LocalDateTime createTime; + + @TableField("modify_time") + private LocalDateTime modifyTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestAttachmentMapper.java b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestAttachmentMapper.java new file mode 100644 index 0000000..b33cfc9 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestAttachmentMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.contest.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.contest.entity.BizContestAttachment; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestAttachmentMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestMapper.java b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestMapper.java new file mode 100644 index 0000000..07aad8e --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.contest.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.contest.entity.BizContest; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestNoticeMapper.java b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestNoticeMapper.java new file mode 100644 index 0000000..2d5d6a0 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestNoticeMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.contest.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.contest.entity.BizContestNotice; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestNoticeMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestRegistrationMapper.java b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestRegistrationMapper.java new file mode 100644 index 0000000..39fdb27 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestRegistrationMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.contest.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.contest.entity.BizContestRegistration; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestRegistrationMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestRegistrationTeacherMapper.java b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestRegistrationTeacherMapper.java new file mode 100644 index 0000000..707fc64 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestRegistrationTeacherMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.contest.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.contest.entity.BizContestRegistrationTeacher; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestRegistrationTeacherMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestTeamMapper.java b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestTeamMapper.java new file mode 100644 index 0000000..dbab6d3 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestTeamMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.contest.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.contest.entity.BizContestTeam; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestTeamMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestTeamMemberMapper.java b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestTeamMemberMapper.java new file mode 100644 index 0000000..7ab9c46 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestTeamMemberMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.contest.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.contest.entity.BizContestTeamMember; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestTeamMemberMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestWorkAttachmentMapper.java b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestWorkAttachmentMapper.java new file mode 100644 index 0000000..513f6c0 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestWorkAttachmentMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.contest.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.contest.entity.BizContestWorkAttachment; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestWorkAttachmentMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestWorkMapper.java b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestWorkMapper.java new file mode 100644 index 0000000..11b8a75 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/mapper/ContestWorkMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.contest.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.contest.entity.BizContestWork; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestWorkMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestAttachmentService.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestAttachmentService.java new file mode 100644 index 0000000..29e2ae0 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestAttachmentService.java @@ -0,0 +1,7 @@ +package com.competition.modules.biz.contest.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.modules.biz.contest.entity.BizContestAttachment; + +public interface IContestAttachmentService extends IService { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestNoticeService.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestNoticeService.java new file mode 100644 index 0000000..32246dc --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestNoticeService.java @@ -0,0 +1,7 @@ +package com.competition.modules.biz.contest.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.modules.biz.contest.entity.BizContestNotice; + +public interface IContestNoticeService extends IService { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestRegistrationService.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestRegistrationService.java new file mode 100644 index 0000000..f8b8a3e --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestRegistrationService.java @@ -0,0 +1,35 @@ +package com.competition.modules.biz.contest.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.contest.dto.CreateRegistrationDto; +import com.competition.modules.biz.contest.dto.QueryRegistrationDto; +import com.competition.modules.biz.contest.entity.BizContestRegistration; + +import java.util.List; +import java.util.Map; + +public interface IContestRegistrationService extends IService { + + Map createRegistration(CreateRegistrationDto dto, Long tenantId, Long creatorId); + + PageResult> findAll(QueryRegistrationDto dto, Long tenantId, boolean isSuperTenant); + + Map getStats(Long contestId, Long tenantId); + + Map findDetail(Long id, Long tenantId); + + Map getMyRegistration(Long contestId, Long userId, Long tenantId); + + void reviewRegistration(Long id, String state, String reason, Long operatorId, Long tenantId); + + void revokeReview(Long id, Long tenantId); + + void batchReview(List ids, String state, String reason, Long operatorId, Long tenantId); + + void addTeacher(Long registrationId, Long teacherUserId, Long tenantId, Long creatorId); + + void removeTeacher(Long registrationId, Long teacherUserId, Long tenantId); + + void removeRegistration(Long id, Long tenantId); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestService.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestService.java new file mode 100644 index 0000000..8542932 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestService.java @@ -0,0 +1,34 @@ +package com.competition.modules.biz.contest.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.contest.dto.CreateContestDto; +import com.competition.modules.biz.contest.dto.QueryContestDto; +import com.competition.modules.biz.contest.entity.BizContest; + +import java.util.Map; + +public interface IContestService extends IService { + + BizContest createContest(CreateContestDto dto, Long creatorId); + + PageResult> findAll(QueryContestDto dto, Long tenantId, boolean isSuperTenant); + + PageResult> getMyContests(QueryContestDto dto, Long userId, Long tenantId); + + Map findDetail(Long id); + + BizContest updateContest(Long id, CreateContestDto dto); + + void publishContest(Long id, String contestState); + + void finishContest(Long id); + + void reopenContest(Long id); + + void removeContest(Long id); + + Map getStats(Long tenantId, boolean isSuperTenant); + + Map getDashboard(Long tenantId); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestTeamService.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestTeamService.java new file mode 100644 index 0000000..928c711 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestTeamService.java @@ -0,0 +1,25 @@ +package com.competition.modules.biz.contest.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.modules.biz.contest.dto.CreateTeamDto; +import com.competition.modules.biz.contest.entity.BizContestTeam; + +import java.util.List; +import java.util.Map; + +public interface IContestTeamService extends IService { + + Map createTeam(CreateTeamDto dto, Long tenantId, Long creatorId); + + List> findByContest(Long contestId, Long tenantId); + + Map findDetail(Long id); + + BizContestTeam updateTeam(Long id, CreateTeamDto dto); + + void addMember(Long teamId, Long userId, String role, Long tenantId); + + void removeMember(Long teamId, Long userId); + + void removeTeam(Long id); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestWorkService.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestWorkService.java new file mode 100644 index 0000000..1a8e73f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/IContestWorkService.java @@ -0,0 +1,27 @@ +package com.competition.modules.biz.contest.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.contest.dto.QueryWorkDto; +import com.competition.modules.biz.contest.dto.SubmitWorkDto; +import com.competition.modules.biz.contest.entity.BizContestWork; + +import java.util.List; +import java.util.Map; + +public interface IContestWorkService extends IService { + + Map submitWork(SubmitWorkDto dto, Long tenantId, Long submitterId); + + PageResult> findAll(QueryWorkDto dto, Long tenantId, boolean isSuperTenant); + + Map getStats(Long contestId, Long tenantId); + + Map findDetail(Long id); + + List> getWorkVersions(Long registrationId); + + PageResult> getGuidedWorks(Long contestId, String workNo, String playerName, String accountNo, Long page, Long pageSize, Long userId); + + void removeWork(Long id); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestAttachmentServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestAttachmentServiceImpl.java new file mode 100644 index 0000000..2618fde --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestAttachmentServiceImpl.java @@ -0,0 +1,11 @@ +package com.competition.modules.biz.contest.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.modules.biz.contest.entity.BizContestAttachment; +import com.competition.modules.biz.contest.mapper.ContestAttachmentMapper; +import com.competition.modules.biz.contest.service.IContestAttachmentService; +import org.springframework.stereotype.Service; + +@Service +public class ContestAttachmentServiceImpl extends ServiceImpl implements IContestAttachmentService { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestNoticeServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestNoticeServiceImpl.java new file mode 100644 index 0000000..967e14b --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestNoticeServiceImpl.java @@ -0,0 +1,11 @@ +package com.competition.modules.biz.contest.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.modules.biz.contest.entity.BizContestNotice; +import com.competition.modules.biz.contest.mapper.ContestNoticeMapper; +import com.competition.modules.biz.contest.service.IContestNoticeService; +import org.springframework.stereotype.Service; + +@Service +public class ContestNoticeServiceImpl extends ServiceImpl implements IContestNoticeService { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestRegistrationServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestRegistrationServiceImpl.java new file mode 100644 index 0000000..72f0545 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestRegistrationServiceImpl.java @@ -0,0 +1,332 @@ +package com.competition.modules.biz.contest.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.contest.dto.CreateRegistrationDto; +import com.competition.modules.biz.contest.dto.QueryRegistrationDto; +import com.competition.modules.biz.contest.entity.BizContest; +import com.competition.modules.biz.contest.entity.BizContestRegistration; +import com.competition.modules.biz.contest.entity.BizContestRegistrationTeacher; +import com.competition.modules.biz.contest.mapper.ContestMapper; +import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper; +import com.competition.modules.biz.contest.mapper.ContestRegistrationTeacherMapper; +import com.competition.modules.biz.contest.service.IContestRegistrationService; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.mapper.SysUserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ContestRegistrationServiceImpl extends ServiceImpl + implements IContestRegistrationService { + + private final ContestRegistrationMapper contestRegistrationMapper; + private final ContestRegistrationTeacherMapper contestRegistrationTeacherMapper; + private final ContestMapper contestMapper; + private final SysUserMapper sysUserMapper; + + @Override + public Map createRegistration(CreateRegistrationDto dto, Long tenantId, Long creatorId) { + log.info("开始创建报名,赛事ID:{},用户ID:{}", dto.getContestId(), dto.getUserId()); + + // 验证赛事存在且已发布 + BizContest contest = contestMapper.selectById(dto.getContestId()); + if (contest == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在"); + } + if (!"published".equals(contest.getContestState())) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "赛事未发布,无法报名"); + } + + // 获取用户信息 + SysUser user = sysUserMapper.selectById(dto.getUserId()); + String accountNo = user != null ? user.getUsername() : null; + String accountName = user != null ? user.getNickname() : null; + + BizContestRegistration registration = new BizContestRegistration(); + registration.setContestId(dto.getContestId()); + registration.setTenantId(tenantId); + registration.setRegistrationType(dto.getRegistrationType()); + registration.setTeamId(dto.getTeamId()); + registration.setUserId(dto.getUserId()); + registration.setAccountNo(accountNo); + registration.setAccountName(accountName); + registration.setRegistrationState("pending"); + registration.setParticipantType("self"); + registration.setRegistrationTime(LocalDateTime.now()); + registration.setRegistrant(creatorId != null ? creatorId.intValue() : null); + registration.setCreator(creatorId != null ? creatorId.intValue() : null); + + save(registration); + log.info("报名创建成功,ID:{}", registration.getId()); + + // 如果创建者是教师角色,自动添加为默认指导教师 + // 简化实现:此处由调用方根据角色判断后调用 addTeacher + // TODO: 检查创建者角色,自动关联教师 + + Map result = registrationToMap(registration); + return result; + } + + @Override + public PageResult> findAll(QueryRegistrationDto dto, Long tenantId, boolean isSuperTenant) { + log.info("查询报名列表,赛事ID:{},页码:{}", dto.getContestId(), dto.getPage()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (dto.getContestId() != null) { + wrapper.eq(BizContestRegistration::getContestId, dto.getContestId()); + } + if (StringUtils.hasText(dto.getRegistrationState())) { + wrapper.eq(BizContestRegistration::getRegistrationState, dto.getRegistrationState()); + } + if (StringUtils.hasText(dto.getRegistrationType())) { + wrapper.eq(BizContestRegistration::getRegistrationType, dto.getRegistrationType()); + } + if (StringUtils.hasText(dto.getParticipantType())) { + wrapper.eq(BizContestRegistration::getParticipantType, dto.getParticipantType()); + } + if (dto.getUserId() != null) { + wrapper.eq(BizContestRegistration::getUserId, dto.getUserId()); + } + if (StringUtils.hasText(dto.getKeyword())) { + wrapper.and(w -> w.like(BizContestRegistration::getAccountNo, dto.getKeyword()) + .or().like(BizContestRegistration::getAccountName, dto.getKeyword())); + } + + if (!isSuperTenant && tenantId != null) { + wrapper.eq(BizContestRegistration::getTenantId, tenantId); + } + + wrapper.orderByDesc(BizContestRegistration::getRegistrationTime); + + Page page = new Page<>(dto.getPage(), dto.getPageSize()); + Page result = contestRegistrationMapper.selectPage(page, wrapper); + + List> voList = result.getRecords().stream() + .map(this::registrationToMap) + .collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + @Override + public Map getStats(Long contestId, Long tenantId) { + log.info("获取报名统计,赛事ID:{}", contestId); + + LambdaQueryWrapper baseWrapper = new LambdaQueryWrapper<>(); + if (contestId != null) { + baseWrapper.eq(BizContestRegistration::getContestId, contestId); + } + + long total = count(baseWrapper); + + LambdaQueryWrapper pendingWrapper = new LambdaQueryWrapper<>(); + if (contestId != null) { + pendingWrapper.eq(BizContestRegistration::getContestId, contestId); + } + pendingWrapper.eq(BizContestRegistration::getRegistrationState, "pending"); + long pending = count(pendingWrapper); + + LambdaQueryWrapper passedWrapper = new LambdaQueryWrapper<>(); + if (contestId != null) { + passedWrapper.eq(BizContestRegistration::getContestId, contestId); + } + passedWrapper.eq(BizContestRegistration::getRegistrationState, "passed"); + long passed = count(passedWrapper); + + LambdaQueryWrapper rejectedWrapper = new LambdaQueryWrapper<>(); + if (contestId != null) { + rejectedWrapper.eq(BizContestRegistration::getContestId, contestId); + } + rejectedWrapper.eq(BizContestRegistration::getRegistrationState, "rejected"); + long rejected = count(rejectedWrapper); + + Map stats = new LinkedHashMap<>(); + stats.put("total", total); + stats.put("pending", pending); + stats.put("passed", passed); + stats.put("rejected", rejected); + return stats; + } + + @Override + public Map findDetail(Long id, Long tenantId) { + log.info("查询报名详情,ID:{}", id); + + BizContestRegistration registration = getById(id); + if (registration == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "报名记录不存在"); + } + + Map result = registrationToMap(registration); + + // 查询用户详情 + if (registration.getUserId() != null) { + SysUser user = sysUserMapper.selectById(registration.getUserId()); + if (user != null) { + Map userInfo = new LinkedHashMap<>(); + userInfo.put("id", user.getId()); + userInfo.put("username", user.getUsername()); + userInfo.put("nickname", user.getNickname()); + userInfo.put("phone", user.getPhone()); + userInfo.put("email", user.getEmail()); + userInfo.put("avatar", user.getAvatar()); + result.put("userInfo", userInfo); + } + } + + // 查询指导教师列表 + LambdaQueryWrapper teacherWrapper = new LambdaQueryWrapper<>(); + teacherWrapper.eq(BizContestRegistrationTeacher::getRegistrationId, id); + List teachers = contestRegistrationTeacherMapper.selectList(teacherWrapper); + result.put("teachers", teachers); + + return result; + } + + @Override + public Map getMyRegistration(Long contestId, Long userId, Long tenantId) { + log.info("查询我的报名,赛事ID:{},用户ID:{}", contestId, userId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestRegistration::getContestId, contestId); + wrapper.eq(BizContestRegistration::getUserId, userId); + wrapper.last("LIMIT 1"); + + BizContestRegistration registration = getOne(wrapper, false); + if (registration == null) { + return null; + } + return registrationToMap(registration); + } + + @Override + public void reviewRegistration(Long id, String state, String reason, Long operatorId, Long tenantId) { + log.info("审核报名,ID:{},状态:{}", id, state); + + BizContestRegistration registration = getById(id); + if (registration == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "报名记录不存在"); + } + + registration.setRegistrationState(state); + registration.setReason(reason); + registration.setOperator(operatorId != null ? operatorId.intValue() : null); + registration.setOperationDate(LocalDateTime.now()); + updateById(registration); + + log.info("报名审核完成,ID:{},结果:{}", id, state); + } + + @Override + public void revokeReview(Long id, Long tenantId) { + log.info("撤回审核,ID:{}", id); + + BizContestRegistration registration = getById(id); + if (registration == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "报名记录不存在"); + } + + registration.setRegistrationState("pending"); + registration.setReason(null); + registration.setOperator(null); + registration.setOperationDate(null); + updateById(registration); + + log.info("审核已撤回,ID:{}", id); + } + + @Override + public void batchReview(List ids, String state, String reason, Long operatorId, Long tenantId) { + log.info("批量审核报名,数量:{},状态:{}", ids.size(), state); + + for (Long id : ids) { + reviewRegistration(id, state, reason, operatorId, tenantId); + } + + log.info("批量审核完成,共处理 {} 条", ids.size()); + } + + @Override + public void addTeacher(Long registrationId, Long teacherUserId, Long tenantId, Long creatorId) { + log.info("添加指导教师,报名ID:{},教师用户ID:{}", registrationId, teacherUserId); + + // 检查重复 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestRegistrationTeacher::getRegistrationId, registrationId); + wrapper.eq(BizContestRegistrationTeacher::getUserId, teacherUserId); + Long existCount = contestRegistrationTeacherMapper.selectCount(wrapper); + if (existCount > 0) { + throw BusinessException.of(ErrorCode.CONFLICT, "该教师已关联此报名"); + } + + BizContestRegistrationTeacher teacher = new BizContestRegistrationTeacher(); + teacher.setRegistrationId(registrationId); + teacher.setTenantId(tenantId); + teacher.setUserId(teacherUserId); + teacher.setIsDefault(false); + teacher.setCreator(creatorId != null ? creatorId.intValue() : null); + teacher.setCreateTime(LocalDateTime.now()); + contestRegistrationTeacherMapper.insert(teacher); + + log.info("指导教师添加成功,报名ID:{},教师用户ID:{}", registrationId, teacherUserId); + } + + @Override + public void removeTeacher(Long registrationId, Long teacherUserId, Long tenantId) { + log.info("移除指导教师,报名ID:{},教师用户ID:{}", registrationId, teacherUserId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestRegistrationTeacher::getRegistrationId, registrationId); + wrapper.eq(BizContestRegistrationTeacher::getUserId, teacherUserId); + contestRegistrationTeacherMapper.delete(wrapper); + + log.info("指导教师移除成功,报名ID:{},教师用户ID:{}", registrationId, teacherUserId); + } + + @Override + public void removeRegistration(Long id, Long tenantId) { + log.info("删除报名,ID:{}", id); + removeById(id); + log.info("报名删除成功,ID:{}", id); + } + + // ====== 私有辅助方法 ====== + + private Map registrationToMap(BizContestRegistration entity) { + Map map = new LinkedHashMap<>(); + map.put("id", entity.getId()); + map.put("contestId", entity.getContestId()); + map.put("tenantId", entity.getTenantId()); + map.put("registrationType", entity.getRegistrationType()); + map.put("teamId", entity.getTeamId()); + map.put("teamName", entity.getTeamName()); + map.put("userId", entity.getUserId()); + map.put("accountNo", entity.getAccountNo()); + map.put("accountName", entity.getAccountName()); + map.put("role", entity.getRole()); + map.put("registrationState", entity.getRegistrationState()); + map.put("participantType", entity.getParticipantType()); + map.put("childId", entity.getChildId()); + map.put("registrant", entity.getRegistrant()); + map.put("registrationTime", entity.getRegistrationTime()); + map.put("reason", entity.getReason()); + map.put("operator", entity.getOperator()); + map.put("operationDate", entity.getOperationDate()); + map.put("createTime", entity.getCreateTime()); + return map; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestServiceImpl.java new file mode 100644 index 0000000..cd15307 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestServiceImpl.java @@ -0,0 +1,463 @@ +package com.competition.modules.biz.contest.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.contest.dto.CreateContestDto; +import com.competition.modules.biz.contest.dto.QueryContestDto; +import com.competition.modules.biz.contest.entity.BizContest; +import com.competition.modules.biz.contest.entity.BizContestAttachment; +import com.competition.modules.biz.contest.entity.BizContestRegistration; +import com.competition.modules.biz.contest.entity.BizContestWork; +import com.competition.modules.biz.contest.mapper.ContestAttachmentMapper; +import com.competition.modules.biz.contest.mapper.ContestMapper; +import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper; +import com.competition.modules.biz.contest.mapper.ContestWorkMapper; +import com.competition.modules.biz.contest.service.IContestService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ContestServiceImpl extends ServiceImpl implements IContestService { + + private final ContestMapper contestMapper; + private final ContestAttachmentMapper contestAttachmentMapper; + private final ContestRegistrationMapper contestRegistrationMapper; + private final ContestWorkMapper contestWorkMapper; + + private static final DateTimeFormatter DT_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + @Override + public BizContest createContest(CreateContestDto dto, Long creatorId) { + log.info("开始创建赛事,名称:{}", dto.getContestName()); + + BizContest entity = new BizContest(); + mapDtoToEntity(dto, entity); + + // 默认状态 + entity.setContestState("unpublished"); + entity.setStatus("ongoing"); + entity.setResultState("unpublished"); + if (!StringUtils.hasText(entity.getSubmitRule())) { + entity.setSubmitRule("once"); + } + entity.setCreator(creatorId != null ? creatorId.intValue() : null); + + save(entity); + log.info("赛事创建成功,ID:{}", entity.getId()); + return entity; + } + + @Override + public PageResult> findAll(QueryContestDto dto, Long tenantId, boolean isSuperTenant) { + log.info("查询赛事列表,页码:{},每页:{}", dto.getPage(), dto.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContest::getValidState, 1); + + if (StringUtils.hasText(dto.getContestName())) { + wrapper.like(BizContest::getContestName, dto.getContestName()); + } + if (StringUtils.hasText(dto.getContestState())) { + wrapper.eq(BizContest::getContestState, dto.getContestState()); + } + if (StringUtils.hasText(dto.getStatus())) { + wrapper.eq(BizContest::getStatus, dto.getStatus()); + } + if (StringUtils.hasText(dto.getVisibility())) { + wrapper.eq(BizContest::getVisibility, dto.getVisibility()); + } + if (StringUtils.hasText(dto.getContestType())) { + wrapper.eq(BizContest::getContestType, dto.getContestType()); + } + + // 阶段筛选 + if (StringUtils.hasText(dto.getStage())) { + LocalDateTime now = LocalDateTime.now(); + switch (dto.getStage()) { + case "registering": + wrapper.le(BizContest::getRegisterStartTime, now) + .ge(BizContest::getRegisterEndTime, now); + break; + case "submitting": + wrapper.le(BizContest::getSubmitStartTime, now) + .ge(BizContest::getSubmitEndTime, now); + break; + case "reviewing": + wrapper.le(BizContest::getReviewStartTime, now) + .ge(BizContest::getReviewEndTime, now); + break; + default: + break; + } + } + + // 非超级租户按授权租户过滤 + if (!isSuperTenant && tenantId != null) { + wrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId); + } + + wrapper.orderByDesc(BizContest::getCreateTime); + + Page page = new Page<>(dto.getPage(), dto.getPageSize()); + Page result = contestMapper.selectPage(page, wrapper); + + List> voList = result.getRecords().stream() + .map(this::entityToMap) + .collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + @Override + public PageResult> getMyContests(QueryContestDto dto, Long userId, Long tenantId) { + log.info("查询我的赛事,用户ID:{},租户ID:{}", userId, tenantId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContest::getValidState, 1); + wrapper.eq(BizContest::getContestState, "published"); + + if (StringUtils.hasText(dto.getContestName())) { + wrapper.like(BizContest::getContestName, dto.getContestName()); + } + + // 按租户过滤 + if (tenantId != null) { + wrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId); + } + + wrapper.orderByDesc(BizContest::getCreateTime); + + Page page = new Page<>(dto.getPage(), dto.getPageSize()); + Page result = contestMapper.selectPage(page, wrapper); + + List> voList = result.getRecords().stream() + .map(this::entityToMap) + .collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + @Override + public Map findDetail(Long id) { + log.info("查询赛事详情,ID:{}", id); + + BizContest contest = getById(id); + if (contest == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在"); + } + + Map result = entityToMap(contest); + + // 查询附件列表 + LambdaQueryWrapper attWrapper = new LambdaQueryWrapper<>(); + attWrapper.eq(BizContestAttachment::getContestId, id); + List attachments = contestAttachmentMapper.selectList(attWrapper); + result.put("attachments", attachments); + + return result; + } + + @Override + public BizContest updateContest(Long id, CreateContestDto dto) { + log.info("更新赛事,ID:{}", id); + + BizContest entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在"); + } + + mapDtoToEntity(dto, entity); + updateById(entity); + + log.info("赛事更新成功,ID:{}", id); + return entity; + } + + @Override + public void publishContest(Long id, String contestState) { + log.info("发布/撤回赛事,ID:{},状态:{}", id, contestState); + + BizContest entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在"); + } + + entity.setContestState(contestState); + updateById(entity); + log.info("赛事状态更新成功,ID:{},新状态:{}", id, contestState); + } + + @Override + public void finishContest(Long id) { + log.info("结束赛事,ID:{}", id); + + BizContest entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在"); + } + + entity.setStatus("finished"); + updateById(entity); + log.info("赛事已结束,ID:{}", id); + } + + @Override + public void reopenContest(Long id) { + log.info("重新开启赛事,ID:{}", id); + + BizContest entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在"); + } + + entity.setStatus("ongoing"); + updateById(entity); + log.info("赛事已重新开启,ID:{}", id); + } + + @Override + public void removeContest(Long id) { + log.info("删除赛事,ID:{}", id); + removeById(id); + log.info("赛事删除成功,ID:{}", id); + } + + @Override + public Map getStats(Long tenantId, boolean isSuperTenant) { + log.info("获取赛事统计,租户ID:{}", tenantId); + + LambdaQueryWrapper baseWrapper = new LambdaQueryWrapper<>(); + baseWrapper.eq(BizContest::getValidState, 1); + if (!isSuperTenant && tenantId != null) { + baseWrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId); + } + + long total = count(baseWrapper); + + LambdaQueryWrapper publishedWrapper = new LambdaQueryWrapper<>(); + publishedWrapper.eq(BizContest::getValidState, 1); + publishedWrapper.eq(BizContest::getContestState, "published"); + if (!isSuperTenant && tenantId != null) { + publishedWrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId); + } + long published = count(publishedWrapper); + + LambdaQueryWrapper ongoingWrapper = new LambdaQueryWrapper<>(); + ongoingWrapper.eq(BizContest::getValidState, 1); + ongoingWrapper.eq(BizContest::getStatus, "ongoing"); + if (!isSuperTenant && tenantId != null) { + ongoingWrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId); + } + long ongoing = count(ongoingWrapper); + + LambdaQueryWrapper finishedWrapper = new LambdaQueryWrapper<>(); + finishedWrapper.eq(BizContest::getValidState, 1); + finishedWrapper.eq(BizContest::getStatus, "finished"); + if (!isSuperTenant && tenantId != null) { + finishedWrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId); + } + long finished = count(finishedWrapper); + + Map stats = new LinkedHashMap<>(); + stats.put("total", total); + stats.put("published", published); + stats.put("ongoing", ongoing); + stats.put("finished", finished); + return stats; + } + + @Override + public Map getDashboard(Long tenantId) { + log.info("获取仪表盘数据,租户ID:{}", tenantId); + + LambdaQueryWrapper contestWrapper = new LambdaQueryWrapper<>(); + contestWrapper.eq(BizContest::getValidState, 1); + if (tenantId != null) { + contestWrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId); + } + long totalContests = count(contestWrapper); + + long totalRegistrations = contestRegistrationMapper.selectCount(new LambdaQueryWrapper()); + + long totalWorks = contestWorkMapper.selectCount(new LambdaQueryWrapper()); + + Map dashboard = new LinkedHashMap<>(); + dashboard.put("totalContests", totalContests); + dashboard.put("totalRegistrations", totalRegistrations); + dashboard.put("totalWorks", totalWorks); + return dashboard; + } + + // ====== 私有辅助方法 ====== + + private void mapDtoToEntity(CreateContestDto dto, BizContest entity) { + if (StringUtils.hasText(dto.getContestName())) { + entity.setContestName(dto.getContestName()); + } + if (StringUtils.hasText(dto.getContestType())) { + entity.setContestType(dto.getContestType()); + } + if (StringUtils.hasText(dto.getVisibility())) { + entity.setVisibility(dto.getVisibility()); + } + if (dto.getTargetCities() != null) { + entity.setTargetCities(dto.getTargetCities()); + } + if (dto.getAgeMin() != null) { + entity.setAgeMin(dto.getAgeMin()); + } + if (dto.getAgeMax() != null) { + entity.setAgeMax(dto.getAgeMax()); + } + if (StringUtils.hasText(dto.getStartTime())) { + entity.setStartTime(LocalDateTime.parse(dto.getStartTime(), DT_FORMATTER)); + } + if (StringUtils.hasText(dto.getEndTime())) { + entity.setEndTime(LocalDateTime.parse(dto.getEndTime(), DT_FORMATTER)); + } + if (StringUtils.hasText(dto.getAddress())) { + entity.setAddress(dto.getAddress()); + } + if (dto.getContent() != null) { + entity.setContent(dto.getContent()); + } + if (dto.getContestTenants() != null) { + entity.setContestTenants(dto.getContestTenants()); + } + if (StringUtils.hasText(dto.getCoverUrl())) { + entity.setCoverUrl(dto.getCoverUrl()); + } + if (StringUtils.hasText(dto.getPosterUrl())) { + entity.setPosterUrl(dto.getPosterUrl()); + } + if (StringUtils.hasText(dto.getContactName())) { + entity.setContactName(dto.getContactName()); + } + if (StringUtils.hasText(dto.getContactPhone())) { + entity.setContactPhone(dto.getContactPhone()); + } + if (StringUtils.hasText(dto.getContactQrcode())) { + entity.setContactQrcode(dto.getContactQrcode()); + } + if (dto.getOrganizers() != null) { + entity.setOrganizers(dto.getOrganizers()); + } + if (dto.getCoOrganizers() != null) { + entity.setCoOrganizers(dto.getCoOrganizers()); + } + if (dto.getSponsors() != null) { + entity.setSponsors(dto.getSponsors()); + } + if (StringUtils.hasText(dto.getRegisterStartTime())) { + entity.setRegisterStartTime(LocalDateTime.parse(dto.getRegisterStartTime(), DT_FORMATTER)); + } + if (StringUtils.hasText(dto.getRegisterEndTime())) { + entity.setRegisterEndTime(LocalDateTime.parse(dto.getRegisterEndTime(), DT_FORMATTER)); + } + if (StringUtils.hasText(dto.getRegisterState())) { + entity.setRegisterState(dto.getRegisterState()); + } + if (dto.getRequireAudit() != null) { + entity.setRequireAudit(dto.getRequireAudit()); + } + if (dto.getAllowedGrades() != null) { + entity.setAllowedGrades(dto.getAllowedGrades()); + } + if (dto.getAllowedClasses() != null) { + entity.setAllowedClasses(dto.getAllowedClasses()); + } + if (dto.getTeamMinMembers() != null) { + entity.setTeamMinMembers(dto.getTeamMinMembers()); + } + if (dto.getTeamMaxMembers() != null) { + entity.setTeamMaxMembers(dto.getTeamMaxMembers()); + } + if (StringUtils.hasText(dto.getSubmitRule())) { + entity.setSubmitRule(dto.getSubmitRule()); + } + if (StringUtils.hasText(dto.getSubmitStartTime())) { + entity.setSubmitStartTime(LocalDateTime.parse(dto.getSubmitStartTime(), DT_FORMATTER)); + } + if (StringUtils.hasText(dto.getSubmitEndTime())) { + entity.setSubmitEndTime(LocalDateTime.parse(dto.getSubmitEndTime(), DT_FORMATTER)); + } + if (StringUtils.hasText(dto.getWorkType())) { + entity.setWorkType(dto.getWorkType()); + } + if (dto.getWorkRequirement() != null) { + entity.setWorkRequirement(dto.getWorkRequirement()); + } + if (dto.getReviewRuleId() != null) { + entity.setReviewRuleId(dto.getReviewRuleId()); + } + if (StringUtils.hasText(dto.getReviewStartTime())) { + entity.setReviewStartTime(LocalDateTime.parse(dto.getReviewStartTime(), DT_FORMATTER)); + } + if (StringUtils.hasText(dto.getReviewEndTime())) { + entity.setReviewEndTime(LocalDateTime.parse(dto.getReviewEndTime(), DT_FORMATTER)); + } + if (StringUtils.hasText(dto.getResultPublishTime())) { + entity.setResultPublishTime(LocalDateTime.parse(dto.getResultPublishTime(), DT_FORMATTER)); + } + } + + private Map entityToMap(BizContest entity) { + Map map = new LinkedHashMap<>(); + map.put("id", entity.getId()); + map.put("contestName", entity.getContestName()); + map.put("contestType", entity.getContestType()); + map.put("contestState", entity.getContestState()); + map.put("status", entity.getStatus()); + map.put("visibility", entity.getVisibility()); + map.put("startTime", entity.getStartTime()); + map.put("endTime", entity.getEndTime()); + map.put("address", entity.getAddress()); + map.put("content", entity.getContent()); + map.put("contestTenants", entity.getContestTenants()); + map.put("coverUrl", entity.getCoverUrl()); + map.put("posterUrl", entity.getPosterUrl()); + map.put("contactName", entity.getContactName()); + map.put("contactPhone", entity.getContactPhone()); + map.put("contactQrcode", entity.getContactQrcode()); + map.put("organizers", entity.getOrganizers()); + map.put("coOrganizers", entity.getCoOrganizers()); + map.put("sponsors", entity.getSponsors()); + map.put("registerStartTime", entity.getRegisterStartTime()); + map.put("registerEndTime", entity.getRegisterEndTime()); + map.put("registerState", entity.getRegisterState()); + map.put("requireAudit", entity.getRequireAudit()); + map.put("allowedGrades", entity.getAllowedGrades()); + map.put("allowedClasses", entity.getAllowedClasses()); + map.put("teamMinMembers", entity.getTeamMinMembers()); + map.put("teamMaxMembers", entity.getTeamMaxMembers()); + map.put("targetCities", entity.getTargetCities()); + map.put("ageMin", entity.getAgeMin()); + map.put("ageMax", entity.getAgeMax()); + map.put("submitRule", entity.getSubmitRule()); + map.put("submitStartTime", entity.getSubmitStartTime()); + map.put("submitEndTime", entity.getSubmitEndTime()); + map.put("workType", entity.getWorkType()); + map.put("workRequirement", entity.getWorkRequirement()); + map.put("reviewRuleId", entity.getReviewRuleId()); + map.put("reviewStartTime", entity.getReviewStartTime()); + map.put("reviewEndTime", entity.getReviewEndTime()); + map.put("resultState", entity.getResultState()); + map.put("resultPublishTime", entity.getResultPublishTime()); + map.put("createTime", entity.getCreateTime()); + map.put("modifyTime", entity.getModifyTime()); + return map; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestTeamServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestTeamServiceImpl.java new file mode 100644 index 0000000..e463763 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestTeamServiceImpl.java @@ -0,0 +1,215 @@ +package com.competition.modules.biz.contest.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.modules.biz.contest.dto.CreateTeamDto; +import com.competition.modules.biz.contest.entity.BizContestTeam; +import com.competition.modules.biz.contest.entity.BizContestTeamMember; +import com.competition.modules.biz.contest.mapper.ContestTeamMapper; +import com.competition.modules.biz.contest.mapper.ContestTeamMemberMapper; +import com.competition.modules.biz.contest.service.IContestTeamService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 ContestTeamServiceImpl extends ServiceImpl + implements IContestTeamService { + + private final ContestTeamMapper contestTeamMapper; + private final ContestTeamMemberMapper contestTeamMemberMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public Map createTeam(CreateTeamDto dto, Long tenantId, Long creatorId) { + log.info("开始创建团队,赛事ID:{},团队名称:{}", dto.getContestId(), dto.getTeamName()); + + BizContestTeam team = new BizContestTeam(); + team.setTenantId(tenantId); + team.setContestId(dto.getContestId()); + team.setTeamName(dto.getTeamName()); + team.setLeaderUserId(dto.getLeaderId()); + team.setMaxMembers(dto.getMaxMembers()); + team.setCreator(creatorId != null ? creatorId.intValue() : null); + + save(team); + log.info("团队创建成功,ID:{}", team.getId()); + + // 添加队长为成员 + addMemberInternal(team.getId(), dto.getLeaderId(), "leader", tenantId, creatorId); + + // 添加其他成员 + if (dto.getMemberIds() != null) { + for (Long memberId : dto.getMemberIds()) { + if (!memberId.equals(dto.getLeaderId())) { + addMemberInternal(team.getId(), memberId, "member", tenantId, creatorId); + } + } + } + + // 添加指导老师 + if (dto.getTeacherIds() != null) { + for (Long teacherId : dto.getTeacherIds()) { + addMemberInternal(team.getId(), teacherId, "mentor", tenantId, creatorId); + } + } + + log.info("团队成员添加完成,团队ID:{}", team.getId()); + return teamToMap(team, getMemberList(team.getId())); + } + + @Override + public List> findByContest(Long contestId, Long tenantId) { + log.info("查询赛事团队列表,赛事ID:{}", contestId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestTeam::getContestId, contestId); + if (tenantId != null) { + wrapper.eq(BizContestTeam::getTenantId, tenantId); + } + wrapper.orderByDesc(BizContestTeam::getCreateTime); + + List teams = list(wrapper); + + return teams.stream() + .map(team -> teamToMap(team, getMemberList(team.getId()))) + .collect(Collectors.toList()); + } + + @Override + public Map findDetail(Long id) { + log.info("查询团队详情,ID:{}", id); + + BizContestTeam team = getById(id); + if (team == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "团队不存在"); + } + + return teamToMap(team, getMemberList(id)); + } + + @Override + public BizContestTeam updateTeam(Long id, CreateTeamDto dto) { + log.info("更新团队,ID:{}", id); + + BizContestTeam team = getById(id); + if (team == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "团队不存在"); + } + + if (StringUtils.hasText(dto.getTeamName())) { + team.setTeamName(dto.getTeamName()); + } + if (dto.getLeaderId() != null) { + team.setLeaderUserId(dto.getLeaderId()); + } + if (dto.getMaxMembers() != null) { + team.setMaxMembers(dto.getMaxMembers()); + } + + updateById(team); + log.info("团队更新成功,ID:{}", id); + return team; + } + + @Override + public void addMember(Long teamId, Long userId, String role, Long tenantId) { + log.info("添加团队成员,团队ID:{},用户ID:{},角色:{}", teamId, userId, role); + + // 检查重复 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestTeamMember::getTeamId, teamId); + wrapper.eq(BizContestTeamMember::getUserId, userId); + Long existCount = contestTeamMemberMapper.selectCount(wrapper); + if (existCount > 0) { + throw BusinessException.of(ErrorCode.CONFLICT, "该用户已在团队中"); + } + + addMemberInternal(teamId, userId, role, tenantId, null); + log.info("团队成员添加成功,团队ID:{},用户ID:{}", teamId, userId); + } + + @Override + public void removeMember(Long teamId, Long userId) { + log.info("移除团队成员,团队ID:{},用户ID:{}", teamId, userId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestTeamMember::getTeamId, teamId); + wrapper.eq(BizContestTeamMember::getUserId, userId); + contestTeamMemberMapper.delete(wrapper); + + log.info("团队成员移除成功,团队ID:{},用户ID:{}", teamId, userId); + } + + @Override + public void removeTeam(Long id) { + log.info("删除团队,ID:{}", id); + + // 删除团队成员 + LambdaQueryWrapper memberWrapper = new LambdaQueryWrapper<>(); + memberWrapper.eq(BizContestTeamMember::getTeamId, id); + contestTeamMemberMapper.delete(memberWrapper); + + removeById(id); + log.info("团队删除成功,ID:{}", id); + } + + // ====== 私有辅助方法 ====== + + private void addMemberInternal(Long teamId, Long userId, String role, Long tenantId, Long creatorId) { + BizContestTeamMember member = new BizContestTeamMember(); + member.setTeamId(teamId); + member.setTenantId(tenantId); + member.setUserId(userId); + member.setRole(role); + member.setCreator(creatorId != null ? creatorId.intValue() : null); + member.setCreateTime(LocalDateTime.now()); + contestTeamMemberMapper.insert(member); + } + + private List getMemberList(Long teamId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestTeamMember::getTeamId, teamId); + wrapper.orderByAsc(BizContestTeamMember::getCreateTime); + return contestTeamMemberMapper.selectList(wrapper); + } + + private Map teamToMap(BizContestTeam entity, List members) { + Map map = new LinkedHashMap<>(); + map.put("id", entity.getId()); + map.put("tenantId", entity.getTenantId()); + map.put("contestId", entity.getContestId()); + map.put("teamName", entity.getTeamName()); + map.put("leaderUserId", entity.getLeaderUserId()); + map.put("maxMembers", entity.getMaxMembers()); + map.put("createTime", entity.getCreateTime()); + + if (members != null) { + List> memberList = members.stream() + .map(m -> { + Map mMap = new LinkedHashMap<>(); + mMap.put("id", m.getId()); + mMap.put("teamId", m.getTeamId()); + mMap.put("userId", m.getUserId()); + mMap.put("role", m.getRole()); + mMap.put("createTime", m.getCreateTime()); + return mMap; + }) + .collect(Collectors.toList()); + map.put("members", memberList); + map.put("memberCount", memberList.size()); + } + + return map; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestWorkServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestWorkServiceImpl.java new file mode 100644 index 0000000..50d9c9f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestWorkServiceImpl.java @@ -0,0 +1,388 @@ +package com.competition.modules.biz.contest.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.contest.dto.QueryWorkDto; +import com.competition.modules.biz.contest.dto.SubmitWorkDto; +import com.competition.modules.biz.contest.entity.BizContest; +import com.competition.modules.biz.contest.entity.BizContestRegistration; +import com.competition.modules.biz.contest.entity.BizContestWork; +import com.competition.modules.biz.contest.entity.BizContestWorkAttachment; +import com.competition.modules.biz.contest.mapper.ContestMapper; +import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper; +import com.competition.modules.biz.contest.mapper.ContestWorkAttachmentMapper; +import com.competition.modules.biz.contest.mapper.ContestWorkMapper; +import com.competition.modules.biz.contest.service.IContestWorkService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ContestWorkServiceImpl extends ServiceImpl + implements IContestWorkService { + + private final ContestWorkMapper contestWorkMapper; + private final ContestWorkAttachmentMapper contestWorkAttachmentMapper; + private final ContestRegistrationMapper contestRegistrationMapper; + private final ContestMapper contestMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public Map submitWork(SubmitWorkDto dto, Long tenantId, Long submitterId) { + log.info("开始提交作品,报名ID:{},提交者:{}", dto.getRegistrationId(), submitterId); + + // 验证报名存在且已通过 + BizContestRegistration registration = contestRegistrationMapper.selectById(dto.getRegistrationId()); + if (registration == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "报名记录不存在"); + } + if (!"passed".equals(registration.getRegistrationState())) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "报名未通过审核,无法提交作品"); + } + + Long contestId = registration.getContestId(); + + // 查询赛事提交规则 + BizContest contest = contestMapper.selectById(contestId); + String submitRule = contest != null ? contest.getSubmitRule() : "once"; + + // 计算版本号 + int version = 1; + if ("resubmit".equals(submitRule)) { + // 将旧版本标记为非最新 + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(BizContestWork::getRegistrationId, dto.getRegistrationId()) + .eq(BizContestWork::getIsLatest, true) + .set(BizContestWork::getIsLatest, false); + update(updateWrapper); + + // 查询当前最大版本号 + LambdaQueryWrapper versionWrapper = new LambdaQueryWrapper<>(); + versionWrapper.eq(BizContestWork::getRegistrationId, dto.getRegistrationId()); + versionWrapper.orderByDesc(BizContestWork::getVersion); + versionWrapper.last("LIMIT 1"); + BizContestWork latestWork = getOne(versionWrapper, false); + if (latestWork != null) { + version = latestWork.getVersion() + 1; + } + } else { + // once 规则:检查是否已有作品 + LambdaQueryWrapper existWrapper = new LambdaQueryWrapper<>(); + existWrapper.eq(BizContestWork::getRegistrationId, dto.getRegistrationId()); + long existCount = count(existWrapper); + if (existCount > 0) { + throw BusinessException.of(ErrorCode.CONFLICT, "该报名已提交作品,不允许重复提交"); + } + } + + // 生成作品编号 + String workNo = generateWorkNo(contestId); + + // 创建作品 + BizContestWork work = new BizContestWork(); + work.setTenantId(tenantId); + work.setContestId(contestId); + work.setRegistrationId(dto.getRegistrationId()); + work.setWorkNo(workNo); + work.setTitle(dto.getTitle()); + work.setDescription(dto.getDescription()); + work.setFiles(dto.getFiles()); + work.setVersion(version); + work.setIsLatest(true); + work.setStatus("submitted"); + work.setSubmitTime(LocalDateTime.now()); + work.setSubmitterUserId(submitterId); + work.setPreviewUrl(dto.getPreviewUrl()); + work.setPreviewUrls(dto.getPreviewUrls()); + work.setAiModelMeta(dto.getAiModelMeta()); + + save(work); + log.info("作品提交成功,ID:{},编号:{}", work.getId(), workNo); + + // 保存附件 + if (dto.getAttachments() != null && !dto.getAttachments().isEmpty()) { + for (SubmitWorkDto.AttachmentItem item : dto.getAttachments()) { + BizContestWorkAttachment attachment = new BizContestWorkAttachment(); + attachment.setTenantId(tenantId); + attachment.setContestId(contestId); + attachment.setWorkId(work.getId()); + attachment.setFileName(item.getFileName()); + attachment.setFileUrl(item.getFileUrl()); + attachment.setFileType(item.getFileType()); + attachment.setSize(item.getSize()); + attachment.setCreator(submitterId != null ? submitterId.intValue() : null); + attachment.setCreateTime(LocalDateTime.now()); + contestWorkAttachmentMapper.insert(attachment); + } + log.info("作品附件保存成功,数量:{}", dto.getAttachments().size()); + } + + return workToMap(work); + } + + @Override + public PageResult> findAll(QueryWorkDto dto, Long tenantId, boolean isSuperTenant) { + log.info("查询作品列表,赛事ID:{},页码:{}", dto.getContestId(), dto.getPage()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (dto.getContestId() != null) { + wrapper.eq(BizContestWork::getContestId, dto.getContestId()); + } + if (StringUtils.hasText(dto.getStatus())) { + wrapper.eq(BizContestWork::getStatus, dto.getStatus()); + } + if (StringUtils.hasText(dto.getTitle())) { + wrapper.like(BizContestWork::getTitle, dto.getTitle()); + } + if (StringUtils.hasText(dto.getWorkNo())) { + wrapper.eq(BizContestWork::getWorkNo, dto.getWorkNo()); + } + if (dto.getRegistrationId() != null) { + wrapper.eq(BizContestWork::getRegistrationId, dto.getRegistrationId()); + } + if (dto.getTenantId() != null) { + wrapper.eq(BizContestWork::getTenantId, dto.getTenantId()); + } else if (!isSuperTenant && tenantId != null) { + wrapper.eq(BizContestWork::getTenantId, tenantId); + } + if (StringUtils.hasText(dto.getKeyword())) { + wrapper.and(w -> w.like(BizContestWork::getTitle, dto.getKeyword()) + .or().like(BizContestWork::getWorkNo, dto.getKeyword())); + } + if (StringUtils.hasText(dto.getSubmitStartTime())) { + wrapper.ge(BizContestWork::getSubmitTime, + LocalDateTime.parse(dto.getSubmitStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } + if (StringUtils.hasText(dto.getSubmitEndTime())) { + wrapper.le(BizContestWork::getSubmitTime, + LocalDateTime.parse(dto.getSubmitEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } + + // 默认只查最新版本 + wrapper.eq(BizContestWork::getIsLatest, true); + wrapper.orderByDesc(BizContestWork::getSubmitTime); + + Page page = new Page<>(dto.getPage(), dto.getPageSize()); + Page result = contestWorkMapper.selectPage(page, wrapper); + + // 批量查询报名信息 + Set registrationIds = result.getRecords().stream() + .map(BizContestWork::getRegistrationId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Map registrationMap = new HashMap<>(); + if (!registrationIds.isEmpty()) { + List registrations = contestRegistrationMapper.selectBatchIds(registrationIds); + registrationMap = registrations.stream() + .collect(Collectors.toMap(BizContestRegistration::getId, r -> r)); + } + + Map finalRegistrationMap = registrationMap; + List> voList = result.getRecords().stream() + .map(work -> { + Map map = workToMap(work); + BizContestRegistration reg = finalRegistrationMap.get(work.getRegistrationId()); + if (reg != null) { + map.put("accountNo", reg.getAccountNo()); + map.put("accountName", reg.getAccountName()); + map.put("userId", reg.getUserId()); + } + return map; + }) + .collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + @Override + public Map getStats(Long contestId, Long tenantId) { + log.info("获取作品统计,赛事ID:{}", contestId); + + LambdaQueryWrapper baseWrapper = new LambdaQueryWrapper<>(); + if (contestId != null) { + baseWrapper.eq(BizContestWork::getContestId, contestId); + } + baseWrapper.eq(BizContestWork::getIsLatest, true); + long total = count(baseWrapper); + + LambdaQueryWrapper submittedWrapper = new LambdaQueryWrapper<>(); + if (contestId != null) { + submittedWrapper.eq(BizContestWork::getContestId, contestId); + } + submittedWrapper.eq(BizContestWork::getIsLatest, true); + submittedWrapper.eq(BizContestWork::getStatus, "submitted"); + long submitted = count(submittedWrapper); + + LambdaQueryWrapper reviewingWrapper = new LambdaQueryWrapper<>(); + if (contestId != null) { + reviewingWrapper.eq(BizContestWork::getContestId, contestId); + } + reviewingWrapper.eq(BizContestWork::getIsLatest, true); + reviewingWrapper.eq(BizContestWork::getStatus, "reviewing"); + long reviewing = count(reviewingWrapper); + + LambdaQueryWrapper acceptedWrapper = new LambdaQueryWrapper<>(); + if (contestId != null) { + acceptedWrapper.eq(BizContestWork::getContestId, contestId); + } + acceptedWrapper.eq(BizContestWork::getIsLatest, true); + acceptedWrapper.eq(BizContestWork::getStatus, "accepted"); + long accepted = count(acceptedWrapper); + + LambdaQueryWrapper rejectedWrapper = new LambdaQueryWrapper<>(); + if (contestId != null) { + rejectedWrapper.eq(BizContestWork::getContestId, contestId); + } + rejectedWrapper.eq(BizContestWork::getIsLatest, true); + rejectedWrapper.eq(BizContestWork::getStatus, "rejected"); + long rejected = count(rejectedWrapper); + + Map stats = new LinkedHashMap<>(); + stats.put("total", total); + stats.put("submitted", submitted); + stats.put("reviewing", reviewing); + stats.put("accepted", accepted); + stats.put("rejected", rejected); + return stats; + } + + @Override + public Map findDetail(Long id) { + log.info("查询作品详情,ID:{}", id); + + BizContestWork work = getById(id); + if (work == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作品不存在"); + } + + Map result = workToMap(work); + + // 查询报名信息 + if (work.getRegistrationId() != null) { + BizContestRegistration registration = contestRegistrationMapper.selectById(work.getRegistrationId()); + if (registration != null) { + Map regInfo = new LinkedHashMap<>(); + regInfo.put("id", registration.getId()); + regInfo.put("userId", registration.getUserId()); + regInfo.put("accountNo", registration.getAccountNo()); + regInfo.put("accountName", registration.getAccountName()); + regInfo.put("registrationType", registration.getRegistrationType()); + regInfo.put("participantType", registration.getParticipantType()); + result.put("registration", regInfo); + } + } + + // 查询附件 + LambdaQueryWrapper attWrapper = new LambdaQueryWrapper<>(); + attWrapper.eq(BizContestWorkAttachment::getWorkId, id); + List attachments = contestWorkAttachmentMapper.selectList(attWrapper); + result.put("attachments", attachments); + + return result; + } + + @Override + public List> getWorkVersions(Long registrationId) { + log.info("查询作品版本历史,报名ID:{}", registrationId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestWork::getRegistrationId, registrationId); + wrapper.orderByDesc(BizContestWork::getVersion); + + List works = list(wrapper); + return works.stream() + .map(this::workToMap) + .collect(Collectors.toList()); + } + + @Override + public PageResult> getGuidedWorks(Long contestId, String workNo, String playerName, + String accountNo, Long page, Long pageSize, Long userId) { + log.info("查询指导作品,赛事ID:{},教师用户ID:{}", contestId, userId); + + // 简化实现:查询当前教师指导的报名ID列表,再查对应作品 + // 完整实现需要关联 t_biz_contest_registration_teacher 表 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (contestId != null) { + wrapper.eq(BizContestWork::getContestId, contestId); + } + wrapper.eq(BizContestWork::getIsLatest, true); + + if (StringUtils.hasText(workNo)) { + wrapper.eq(BizContestWork::getWorkNo, workNo); + } + + wrapper.orderByDesc(BizContestWork::getSubmitTime); + + Page pageObj = new Page<>(page != null ? page : 1L, pageSize != null ? pageSize : 10L); + Page result = contestWorkMapper.selectPage(pageObj, wrapper); + + List> voList = result.getRecords().stream() + .map(this::workToMap) + .collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + @Override + public void removeWork(Long id) { + log.info("删除作品,ID:{}", id); + removeById(id); + log.info("作品删除成功,ID:{}", id); + } + + // ====== 私有辅助方法 ====== + + private String generateWorkNo(Long contestId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestWork::getContestId, contestId); + long count = count(wrapper); + return "W" + contestId + "-" + (count + 1); + } + + private Map workToMap(BizContestWork entity) { + Map map = new LinkedHashMap<>(); + map.put("id", entity.getId()); + map.put("tenantId", entity.getTenantId()); + map.put("contestId", entity.getContestId()); + map.put("registrationId", entity.getRegistrationId()); + map.put("workNo", entity.getWorkNo()); + map.put("title", entity.getTitle()); + map.put("description", entity.getDescription()); + map.put("files", entity.getFiles()); + map.put("version", entity.getVersion()); + map.put("isLatest", entity.getIsLatest()); + map.put("status", entity.getStatus()); + map.put("submitTime", entity.getSubmitTime()); + map.put("submitterUserId", entity.getSubmitterUserId()); + map.put("submitterAccountNo", entity.getSubmitterAccountNo()); + map.put("submitSource", entity.getSubmitSource()); + map.put("previewUrl", entity.getPreviewUrl()); + map.put("previewUrls", entity.getPreviewUrls()); + map.put("aiModelMeta", entity.getAiModelMeta()); + map.put("userWorkId", entity.getUserWorkId()); + map.put("finalScore", entity.getFinalScore()); + map.put("rank", entity.getRank()); + map.put("awardLevel", entity.getAwardLevel()); + map.put("awardName", entity.getAwardName()); + map.put("certificateUrl", entity.getCertificateUrl()); + map.put("createTime", entity.getCreateTime()); + return map; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkController.java b/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkController.java new file mode 100644 index 0000000..ccce283 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkController.java @@ -0,0 +1,96 @@ +package com.competition.modules.biz.homework.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.homework.dto.CreateHomeworkDto; +import com.competition.modules.biz.homework.entity.BizHomework; +import com.competition.modules.biz.homework.service.IHomeworkService; +import com.competition.security.annotation.RequirePermission; +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; +import java.util.Map; + +@Tag(name = "作业管理") +@RestController +@RequestMapping("/homework/homeworks") +@RequiredArgsConstructor +public class HomeworkController { + + private final IHomeworkService homeworkService; + + @PostMapping + @RequirePermission("homework:create") + @Operation(summary = "创建作业") + public Result create(@Valid @RequestBody CreateHomeworkDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(homeworkService.create(dto, tenantId)); + } + + @GetMapping + @RequirePermission("homework:read") + @Operation(summary = "查询作业列表") + public Result>> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize, + @RequestParam(required = false) String name, + @RequestParam(required = false) String status) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(homeworkService.findAll(page, pageSize, tenantId, name, status)); + } + + @GetMapping("/my") + @RequirePermission({"homework:read", "homework:student:read"}) + @Operation(summary = "查询我的作业列表") + public Result>> findMyHomeworks( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize, + @RequestParam(required = false) String name) { + Long userId = SecurityUtil.getCurrentUserId(); + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(homeworkService.findMyHomeworks(page, pageSize, userId, tenantId, name)); + } + + @GetMapping("/{id}") + @RequirePermission({"homework:read", "homework:student:read"}) + @Operation(summary = "查询作业详情") + public Result> findDetail(@PathVariable Long id) { + return Result.success(homeworkService.findDetail(id)); + } + + @PatchMapping("/{id}") + @RequirePermission("homework:update") + @Operation(summary = "更新作业") + public Result update(@PathVariable Long id, @RequestBody CreateHomeworkDto dto) { + return Result.success(homeworkService.update(id, dto)); + } + + @PostMapping("/{id}/publish") + @RequirePermission("homework:update") + @Operation(summary = "发布作业") + public Result publish(@PathVariable Long id, @RequestBody(required = false) List publishScope) { + homeworkService.publish(id, publishScope); + return Result.success(); + } + + @PostMapping("/{id}/unpublish") + @RequirePermission("homework:update") + @Operation(summary = "取消发布作业") + public Result unpublish(@PathVariable Long id) { + homeworkService.unpublish(id); + return Result.success(); + } + + @DeleteMapping("/{id}") + @RequirePermission("homework:delete") + @Operation(summary = "删除作业") + public Result remove(@PathVariable Long id) { + homeworkService.remove(id); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkReviewRuleController.java b/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkReviewRuleController.java new file mode 100644 index 0000000..cf95f1c --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkReviewRuleController.java @@ -0,0 +1,75 @@ +package com.competition.modules.biz.homework.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.homework.dto.CreateHomeworkReviewRuleDto; +import com.competition.modules.biz.homework.entity.BizHomeworkReviewRule; +import com.competition.modules.biz.homework.service.IHomeworkReviewRuleService; +import com.competition.security.annotation.RequirePermission; +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; +import java.util.Map; + +@Tag(name = "作业评审规则") +@RestController +@RequestMapping("/homework/review-rules") +@RequiredArgsConstructor +public class HomeworkReviewRuleController { + + private final IHomeworkReviewRuleService homeworkReviewRuleService; + + @GetMapping + @RequirePermission("homework:read") + @Operation(summary = "查询作业评审规则列表") + public Result>> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize, + @RequestParam(required = false) String name) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(homeworkReviewRuleService.findAll(page, pageSize, tenantId, name)); + } + + @GetMapping("/select") + @RequirePermission("homework:read") + @Operation(summary = "查询作业评审规则选项列表") + public Result>> findAllForSelect() { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(homeworkReviewRuleService.findAllForSelect(tenantId)); + } + + @PostMapping + @RequirePermission("homework:update") + @Operation(summary = "创建作业评审规则") + public Result create(@Valid @RequestBody CreateHomeworkReviewRuleDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(homeworkReviewRuleService.create(dto, tenantId)); + } + + @GetMapping("/{id}") + @RequirePermission("homework:read") + @Operation(summary = "查询作业评审规则详情") + public Result> findDetail(@PathVariable Long id) { + return Result.success(homeworkReviewRuleService.findDetail(id)); + } + + @PatchMapping("/{id}") + @RequirePermission("homework:update") + @Operation(summary = "更新作业评审规则") + public Result update(@PathVariable Long id, @RequestBody CreateHomeworkReviewRuleDto dto) { + return Result.success(homeworkReviewRuleService.update(id, dto)); + } + + @DeleteMapping("/{id}") + @RequirePermission("homework:update") + @Operation(summary = "删除作业评审规则") + public Result remove(@PathVariable Long id) { + homeworkReviewRuleService.remove(id); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkScoreController.java b/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkScoreController.java new file mode 100644 index 0000000..4126602 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkScoreController.java @@ -0,0 +1,73 @@ +package com.competition.modules.biz.homework.controller; + +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.homework.dto.CreateHomeworkScoreDto; +import com.competition.modules.biz.homework.entity.BizHomeworkScore; +import com.competition.modules.biz.homework.entity.BizHomeworkSubmission; +import com.competition.modules.biz.homework.service.IHomeworkScoreService; +import com.competition.modules.biz.homework.service.IHomeworkSubmissionService; +import com.competition.security.annotation.RequirePermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@Tag(name = "作业评分") +@RestController +@RequestMapping("/homework/scores") +@RequiredArgsConstructor +public class HomeworkScoreController { + + private final IHomeworkScoreService homeworkScoreService; + private final IHomeworkSubmissionService homeworkSubmissionService; + + @PostMapping + @RequirePermission("homework:update") + @Operation(summary = "创建评分") + public Result create(@Valid @RequestBody CreateHomeworkScoreDto dto) { + Long reviewerId = SecurityUtil.getCurrentUserId(); + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(homeworkScoreService.create(dto, reviewerId, tenantId)); + } + + @PostMapping("/{submissionId}/violation") + @RequirePermission("homework:update") + @Operation(summary = "标记违规") + public Result markViolation(@PathVariable Long submissionId) { + log.info("标记作业提交违规,提交ID:{}", submissionId); + + BizHomeworkSubmission submission = homeworkSubmissionService.getById(submissionId); + if (submission == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作业提交不存在"); + } + + submission.setStatus("violation"); + homeworkSubmissionService.updateById(submission); + log.info("作业提交已标记为违规,提交ID:{}", submissionId); + return Result.success(); + } + + @PostMapping("/{submissionId}/reset") + @RequirePermission("homework:update") + @Operation(summary = "重置评分状态") + public Result resetStatus(@PathVariable Long submissionId) { + log.info("重置作业提交评分状态,提交ID:{}", submissionId); + + BizHomeworkSubmission submission = homeworkSubmissionService.getById(submissionId); + if (submission == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作业提交不存在"); + } + + submission.setStatus("pending"); + submission.setTotalScore(null); + homeworkSubmissionService.updateById(submission); + log.info("作业提交评分状态已重置,提交ID:{}", submissionId); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkSubmissionController.java b/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkSubmissionController.java new file mode 100644 index 0000000..fd227b5 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/controller/HomeworkSubmissionController.java @@ -0,0 +1,75 @@ +package com.competition.modules.biz.homework.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.homework.dto.CreateSubmissionDto; +import com.competition.modules.biz.homework.entity.BizHomeworkSubmission; +import com.competition.modules.biz.homework.service.IHomeworkSubmissionService; +import com.competition.security.annotation.RequirePermission; +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; +import java.util.Map; + +@Tag(name = "作业提交") +@RestController +@RequestMapping("/homework/submissions") +@RequiredArgsConstructor +public class HomeworkSubmissionController { + + private final IHomeworkSubmissionService homeworkSubmissionService; + + @GetMapping + @RequirePermission("homework:read") + @Operation(summary = "查询作业提交列表") + public Result>> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize, + @RequestParam(required = false) Long homeworkId, + @RequestParam(required = false) String workNo, + @RequestParam(required = false) String workName, + @RequestParam(required = false) String studentAccount, + @RequestParam(required = false) String studentName, + @RequestParam(required = false) String status) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(homeworkSubmissionService.findAll(page, pageSize, tenantId, homeworkId, + workNo, workName, studentAccount, studentName, status)); + } + + @GetMapping("/class-tree") + @RequirePermission("homework:read") + @Operation(summary = "获取班级树") + public Result>> getClassTree() { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(homeworkSubmissionService.getClassTree(tenantId)); + } + + @GetMapping("/my/{homeworkId}") + @RequirePermission({"homework:read", "homework:student:read"}) + @Operation(summary = "查询我的作业提交") + public Result> findMySubmission(@PathVariable Long homeworkId) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(homeworkSubmissionService.findMySubmission(homeworkId, userId)); + } + + @PostMapping + @RequirePermission({"homework:read", "homework:student:read"}) + @Operation(summary = "提交作业") + public Result create(@Valid @RequestBody CreateSubmissionDto dto) { + Long studentId = SecurityUtil.getCurrentUserId(); + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(homeworkSubmissionService.create(dto, studentId, tenantId)); + } + + @GetMapping("/{id}") + @RequirePermission("homework:read") + @Operation(summary = "查询作业提交详情") + public Result> findDetail(@PathVariable Long id) { + return Result.success(homeworkSubmissionService.findDetail(id)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateHomeworkDto.java b/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateHomeworkDto.java new file mode 100644 index 0000000..ced8a54 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateHomeworkDto.java @@ -0,0 +1,36 @@ +package com.competition.modules.biz.homework.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "创建作业DTO") +public class CreateHomeworkDto { + + @NotBlank(message = "作业名称不能为空") + @Schema(description = "作业名称") + private String name; + + @Schema(description = "作业内容") + private String content; + + @NotBlank(message = "提交开始时间不能为空") + @Schema(description = "提交开始时间") + private String submitStartTime; + + @NotBlank(message = "提交截止时间不能为空") + @Schema(description = "提交截止时间") + private String submitEndTime; + + @Schema(description = "附件") + private Object attachments; + + @Schema(description = "发布范围") + private List publishScope; + + @Schema(description = "评审规则ID") + private Long reviewRuleId; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateHomeworkReviewRuleDto.java b/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateHomeworkReviewRuleDto.java new file mode 100644 index 0000000..e3c682e --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateHomeworkReviewRuleDto.java @@ -0,0 +1,22 @@ +package com.competition.modules.biz.homework.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +@Schema(description = "创建作业评审规则DTO") +public class CreateHomeworkReviewRuleDto { + + @NotBlank(message = "规则名称不能为空") + @Schema(description = "规则名称") + private String name; + + @Schema(description = "规则描述") + private String description; + + @NotNull(message = "评分标准不能为空") + @Schema(description = "评分标准(JSON数组)") + private Object criteria; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateHomeworkScoreDto.java b/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateHomeworkScoreDto.java new file mode 100644 index 0000000..49cbf64 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateHomeworkScoreDto.java @@ -0,0 +1,26 @@ +package com.competition.modules.biz.homework.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@Schema(description = "创建作业评分DTO") +public class CreateHomeworkScoreDto { + + @NotNull(message = "提交ID不能为空") + @Schema(description = "提交ID") + private Long submissionId; + + @Schema(description = "维度评分(JSON)") + private Object dimensionScores; + + @NotNull(message = "总分不能为空") + @Schema(description = "总分") + private BigDecimal totalScore; + + @Schema(description = "评语") + private String comments; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateSubmissionDto.java b/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateSubmissionDto.java new file mode 100644 index 0000000..118110c --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/dto/CreateSubmissionDto.java @@ -0,0 +1,25 @@ +package com.competition.modules.biz.homework.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +@Schema(description = "创建作业提交DTO") +public class CreateSubmissionDto { + + @NotNull(message = "作业ID不能为空") + @Schema(description = "作业ID") + private Long homeworkId; + + @NotBlank(message = "作品名称不能为空") + @Schema(description = "作品名称") + private String workName; + + @Schema(description = "作品描述") + private String workDescription; + + @Schema(description = "作品文件") + private Object files; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomework.java b/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomework.java new file mode 100644 index 0000000..24c7302 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomework.java @@ -0,0 +1,44 @@ +package com.competition.modules.biz.homework.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "t_homework", autoResultMap = true) +public class BizHomework extends BaseEntity { + + @TableField("tenant_id") + private Long tenantId; + + private String name; + + private String content; + + @TableField("submit_start_time") + private LocalDateTime submitStartTime; + + @TableField("submit_end_time") + private LocalDateTime submitEndTime; + + @TableField(value = "attachments", typeHandler = JacksonTypeHandler.class) + private Object attachments; + + @TableField(value = "publish_scope", typeHandler = JacksonTypeHandler.class) + private Object publishScope; + + @TableField("review_rule_id") + private Long reviewRuleId; + + /** unpublished / published */ + private String status; + + @TableField("publish_time") + private LocalDateTime publishTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomeworkReviewRule.java b/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomeworkReviewRule.java new file mode 100644 index 0000000..bcbecdf --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomeworkReviewRule.java @@ -0,0 +1,25 @@ +package com.competition.modules.biz.homework.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "t_homework_review_rule", autoResultMap = true) +public class BizHomeworkReviewRule extends BaseEntity { + + @TableField("tenant_id") + private Long tenantId; + + private String name; + + private String description; + + /** JSON array of {name, maxScore, description} */ + @TableField(value = "criteria", typeHandler = JacksonTypeHandler.class) + private Object criteria; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomeworkScore.java b/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomeworkScore.java new file mode 100644 index 0000000..242e1e5 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomeworkScore.java @@ -0,0 +1,37 @@ +package com.competition.modules.biz.homework.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "t_homework_score", autoResultMap = true) +public class BizHomeworkScore extends BaseEntity { + + @TableField("tenant_id") + private Long tenantId; + + @TableField("submission_id") + private Long submissionId; + + @TableField("reviewer_id") + private Long reviewerId; + + @TableField(value = "dimension_scores", typeHandler = JacksonTypeHandler.class) + private Object dimensionScores; + + @TableField("total_score") + private BigDecimal totalScore; + + private String comments; + + @TableField("score_time") + private LocalDateTime scoreTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomeworkSubmission.java b/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomeworkSubmission.java new file mode 100644 index 0000000..3dd6b63 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/entity/BizHomeworkSubmission.java @@ -0,0 +1,50 @@ +package com.competition.modules.biz.homework.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "t_homework_submission", autoResultMap = true) +public class BizHomeworkSubmission extends BaseEntity { + + @TableField("tenant_id") + private Long tenantId; + + @TableField("homework_id") + private Long homeworkId; + + @TableField("student_id") + private Long studentId; + + @TableField("work_no") + private String workNo; + + @TableField("work_name") + private String workName; + + @TableField("work_description") + private String workDescription; + + @TableField(value = "files", typeHandler = JacksonTypeHandler.class) + private Object files; + + @TableField(value = "attachments", typeHandler = JacksonTypeHandler.class) + private Object attachments; + + @TableField("submit_time") + private LocalDateTime submitTime; + + /** pending / reviewed */ + private String status; + + @TableField("total_score") + private BigDecimal totalScore; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkMapper.java b/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkMapper.java new file mode 100644 index 0000000..c33c46f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.homework.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.homework.entity.BizHomework; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HomeworkMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkReviewRuleMapper.java b/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkReviewRuleMapper.java new file mode 100644 index 0000000..6eeaa4d --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkReviewRuleMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.homework.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.homework.entity.BizHomeworkReviewRule; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HomeworkReviewRuleMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkScoreMapper.java b/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkScoreMapper.java new file mode 100644 index 0000000..fc33ed3 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkScoreMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.homework.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.homework.entity.BizHomeworkScore; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HomeworkScoreMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkSubmissionMapper.java b/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkSubmissionMapper.java new file mode 100644 index 0000000..b80824c --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/mapper/HomeworkSubmissionMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.homework.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.homework.entity.BizHomeworkSubmission; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HomeworkSubmissionMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkReviewRuleService.java b/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkReviewRuleService.java new file mode 100644 index 0000000..bd2620a --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkReviewRuleService.java @@ -0,0 +1,24 @@ +package com.competition.modules.biz.homework.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.homework.dto.CreateHomeworkReviewRuleDto; +import com.competition.modules.biz.homework.entity.BizHomeworkReviewRule; + +import java.util.List; +import java.util.Map; + +public interface IHomeworkReviewRuleService extends IService { + + BizHomeworkReviewRule create(CreateHomeworkReviewRuleDto dto, Long tenantId); + + PageResult> findAll(Long page, Long pageSize, Long tenantId, String name); + + List> findAllForSelect(Long tenantId); + + Map findDetail(Long id); + + BizHomeworkReviewRule update(Long id, CreateHomeworkReviewRuleDto dto); + + void remove(Long id); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkScoreService.java b/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkScoreService.java new file mode 100644 index 0000000..cef815c --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkScoreService.java @@ -0,0 +1,15 @@ +package com.competition.modules.biz.homework.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.modules.biz.homework.dto.CreateHomeworkScoreDto; +import com.competition.modules.biz.homework.entity.BizHomeworkScore; + +import java.util.List; +import java.util.Map; + +public interface IHomeworkScoreService extends IService { + + BizHomeworkScore create(CreateHomeworkScoreDto dto, Long reviewerId, Long tenantId); + + List> findBySubmission(Long submissionId); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkService.java b/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkService.java new file mode 100644 index 0000000..6fe4bd8 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkService.java @@ -0,0 +1,28 @@ +package com.competition.modules.biz.homework.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.homework.dto.CreateHomeworkDto; +import com.competition.modules.biz.homework.entity.BizHomework; + +import java.util.List; +import java.util.Map; + +public interface IHomeworkService extends IService { + + BizHomework create(CreateHomeworkDto dto, Long tenantId); + + PageResult> findAll(Long page, Long pageSize, Long tenantId, String name, String status); + + PageResult> findMyHomeworks(Long page, Long pageSize, Long userId, Long tenantId, String name); + + Map findDetail(Long id); + + BizHomework update(Long id, CreateHomeworkDto dto); + + void publish(Long id, List publishScope); + + void unpublish(Long id); + + void remove(Long id); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkSubmissionService.java b/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkSubmissionService.java new file mode 100644 index 0000000..ed80e88 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/service/IHomeworkSubmissionService.java @@ -0,0 +1,24 @@ +package com.competition.modules.biz.homework.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.homework.dto.CreateSubmissionDto; +import com.competition.modules.biz.homework.entity.BizHomeworkSubmission; + +import java.util.List; +import java.util.Map; + +public interface IHomeworkSubmissionService extends IService { + + BizHomeworkSubmission create(CreateSubmissionDto dto, Long studentId, Long tenantId); + + PageResult> findAll(Long page, Long pageSize, Long tenantId, Long homeworkId, + String workNo, String workName, String studentAccount, + String studentName, String status); + + Map findDetail(Long id); + + Map findMySubmission(Long homeworkId, Long userId); + + List> getClassTree(Long tenantId); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkReviewRuleServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkReviewRuleServiceImpl.java new file mode 100644 index 0000000..260f376 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkReviewRuleServiceImpl.java @@ -0,0 +1,139 @@ +package com.competition.modules.biz.homework.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.homework.dto.CreateHomeworkReviewRuleDto; +import com.competition.modules.biz.homework.entity.BizHomeworkReviewRule; +import com.competition.modules.biz.homework.mapper.HomeworkReviewRuleMapper; +import com.competition.modules.biz.homework.service.IHomeworkReviewRuleService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class HomeworkReviewRuleServiceImpl extends ServiceImpl implements IHomeworkReviewRuleService { + + private final HomeworkReviewRuleMapper homeworkReviewRuleMapper; + + @Override + public BizHomeworkReviewRule create(CreateHomeworkReviewRuleDto dto, Long tenantId) { + log.info("创建作业评审规则,名称:{},租户:{}", dto.getName(), tenantId); + + BizHomeworkReviewRule entity = new BizHomeworkReviewRule(); + entity.setTenantId(tenantId); + entity.setName(dto.getName()); + entity.setDescription(dto.getDescription()); + entity.setCriteria(dto.getCriteria()); + + save(entity); + log.info("作业评审规则创建成功,ID:{}", entity.getId()); + return entity; + } + + @Override + public PageResult> findAll(Long page, Long pageSize, Long tenantId, String name) { + log.info("查询作业评审规则列表,页码:{},每页:{}", page, pageSize); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizHomeworkReviewRule::getValidState, 1); + + if (tenantId != null) { + wrapper.eq(BizHomeworkReviewRule::getTenantId, tenantId); + } + if (StringUtils.hasText(name)) { + wrapper.like(BizHomeworkReviewRule::getName, name); + } + + wrapper.orderByDesc(BizHomeworkReviewRule::getCreateTime); + + Page pageObj = new Page<>(page, pageSize); + Page result = homeworkReviewRuleMapper.selectPage(pageObj, wrapper); + + List> voList = result.getRecords().stream() + .map(this::entityToMap) + .collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + @Override + public List> findAllForSelect(Long tenantId) { + log.info("查询作业评审规则下拉列表,租户:{}", tenantId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizHomeworkReviewRule::getValidState, 1); + if (tenantId != null) { + wrapper.eq(BizHomeworkReviewRule::getTenantId, tenantId); + } + wrapper.orderByDesc(BizHomeworkReviewRule::getCreateTime); + + List list = homeworkReviewRuleMapper.selectList(wrapper); + return list.stream().map(this::entityToMap).collect(Collectors.toList()); + } + + @Override + public Map findDetail(Long id) { + log.info("查询作业评审规则详情,ID:{}", id); + + BizHomeworkReviewRule entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作业评审规则不存在"); + } + return entityToMap(entity); + } + + @Override + public BizHomeworkReviewRule update(Long id, CreateHomeworkReviewRuleDto dto) { + log.info("更新作业评审规则,ID:{}", id); + + BizHomeworkReviewRule entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作业评审规则不存在"); + } + + if (StringUtils.hasText(dto.getName())) { + entity.setName(dto.getName()); + } + if (dto.getDescription() != null) { + entity.setDescription(dto.getDescription()); + } + if (dto.getCriteria() != null) { + entity.setCriteria(dto.getCriteria()); + } + + updateById(entity); + log.info("作业评审规则更新成功,ID:{}", id); + return entity; + } + + @Override + public void remove(Long id) { + log.info("删除作业评审规则,ID:{}", id); + removeById(id); + log.info("作业评审规则删除成功,ID:{}", id); + } + + // ====== 私有辅助方法 ====== + + private Map entityToMap(BizHomeworkReviewRule entity) { + Map map = new LinkedHashMap<>(); + map.put("id", entity.getId()); + map.put("tenantId", entity.getTenantId()); + map.put("name", entity.getName()); + map.put("description", entity.getDescription()); + map.put("criteria", entity.getCriteria()); + map.put("createTime", entity.getCreateTime()); + map.put("modifyTime", entity.getModifyTime()); + return map; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkScoreServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkScoreServiceImpl.java new file mode 100644 index 0000000..6fd6a5f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkScoreServiceImpl.java @@ -0,0 +1,93 @@ +package com.competition.modules.biz.homework.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.modules.biz.homework.dto.CreateHomeworkScoreDto; +import com.competition.modules.biz.homework.entity.BizHomeworkScore; +import com.competition.modules.biz.homework.entity.BizHomeworkSubmission; +import com.competition.modules.biz.homework.mapper.HomeworkScoreMapper; +import com.competition.modules.biz.homework.service.IHomeworkScoreService; +import com.competition.modules.biz.homework.service.IHomeworkSubmissionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class HomeworkScoreServiceImpl extends ServiceImpl implements IHomeworkScoreService { + + private final HomeworkScoreMapper homeworkScoreMapper; + @Lazy + private final IHomeworkSubmissionService homeworkSubmissionService; + + @Override + @Transactional + public BizHomeworkScore create(CreateHomeworkScoreDto dto, Long reviewerId, Long tenantId) { + log.info("创建作业评分,提交ID:{},评审人:{}", dto.getSubmissionId(), reviewerId); + + // 验证提交存在 + BizHomeworkSubmission submission = homeworkSubmissionService.getById(dto.getSubmissionId()); + if (submission == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作业提交不存在"); + } + + BizHomeworkScore entity = new BizHomeworkScore(); + entity.setTenantId(tenantId); + entity.setSubmissionId(dto.getSubmissionId()); + entity.setReviewerId(reviewerId); + entity.setDimensionScores(dto.getDimensionScores()); + entity.setTotalScore(dto.getTotalScore()); + entity.setComments(dto.getComments()); + entity.setScoreTime(LocalDateTime.now()); + + save(entity); + log.info("作业评分创建成功,ID:{}", entity.getId()); + + // 更新提交的总分和状态 + submission.setTotalScore(dto.getTotalScore()); + submission.setStatus("reviewed"); + homeworkSubmissionService.updateById(submission); + log.info("作业提交状态已更新为reviewed,提交ID:{}", submission.getId()); + + return entity; + } + + @Override + public List> findBySubmission(Long submissionId) { + log.info("查询作业提交的评分记录,提交ID:{}", submissionId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizHomeworkScore::getSubmissionId, submissionId); + wrapper.eq(BizHomeworkScore::getValidState, 1); + wrapper.orderByDesc(BizHomeworkScore::getScoreTime); + + List list = homeworkScoreMapper.selectList(wrapper); + return list.stream().map(this::entityToMap).collect(Collectors.toList()); + } + + // ====== 私有辅助方法 ====== + + private Map entityToMap(BizHomeworkScore entity) { + Map map = new LinkedHashMap<>(); + map.put("id", entity.getId()); + map.put("tenantId", entity.getTenantId()); + map.put("submissionId", entity.getSubmissionId()); + map.put("reviewerId", entity.getReviewerId()); + map.put("dimensionScores", entity.getDimensionScores()); + map.put("totalScore", entity.getTotalScore()); + map.put("comments", entity.getComments()); + map.put("scoreTime", entity.getScoreTime()); + map.put("createTime", entity.getCreateTime()); + map.put("modifyTime", entity.getModifyTime()); + return map; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkServiceImpl.java new file mode 100644 index 0000000..8e44a07 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkServiceImpl.java @@ -0,0 +1,216 @@ +package com.competition.modules.biz.homework.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.homework.dto.CreateHomeworkDto; +import com.competition.modules.biz.homework.entity.BizHomework; +import com.competition.modules.biz.homework.mapper.HomeworkMapper; +import com.competition.modules.biz.homework.service.IHomeworkService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class HomeworkServiceImpl extends ServiceImpl implements IHomeworkService { + + private final HomeworkMapper homeworkMapper; + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public BizHomework create(CreateHomeworkDto dto, Long tenantId) { + log.info("创建作业,名称:{},租户:{}", dto.getName(), tenantId); + + BizHomework entity = new BizHomework(); + entity.setTenantId(tenantId); + entity.setName(dto.getName()); + entity.setContent(dto.getContent()); + entity.setSubmitStartTime(LocalDateTime.parse(dto.getSubmitStartTime(), DATE_TIME_FORMATTER)); + entity.setSubmitEndTime(LocalDateTime.parse(dto.getSubmitEndTime(), DATE_TIME_FORMATTER)); + entity.setAttachments(dto.getAttachments()); + entity.setPublishScope(dto.getPublishScope()); + entity.setReviewRuleId(dto.getReviewRuleId()); + entity.setStatus("unpublished"); + + save(entity); + log.info("作业创建成功,ID:{}", entity.getId()); + return entity; + } + + @Override + public PageResult> findAll(Long page, Long pageSize, Long tenantId, String name, String status) { + log.info("查询作业列表,页码:{},每页:{}", page, pageSize); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizHomework::getValidState, 1); + + if (tenantId != null) { + wrapper.eq(BizHomework::getTenantId, tenantId); + } + if (StringUtils.hasText(name)) { + wrapper.like(BizHomework::getName, name); + } + if (StringUtils.hasText(status)) { + wrapper.eq(BizHomework::getStatus, status); + } + + wrapper.orderByDesc(BizHomework::getCreateTime); + + Page pageObj = new Page<>(page, pageSize); + Page result = homeworkMapper.selectPage(pageObj, wrapper); + + List> voList = result.getRecords().stream() + .map(this::entityToMap) + .collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + @Override + public PageResult> findMyHomeworks(Long page, Long pageSize, Long userId, Long tenantId, String name) { + log.info("查询我的作业列表,用户:{},租户:{}", userId, tenantId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizHomework::getValidState, 1); + wrapper.eq(BizHomework::getStatus, "published"); + + if (tenantId != null) { + wrapper.eq(BizHomework::getTenantId, tenantId); + } + if (StringUtils.hasText(name)) { + wrapper.like(BizHomework::getName, name); + } + + wrapper.orderByDesc(BizHomework::getPublishTime); + + Page pageObj = new Page<>(page, pageSize); + Page result = homeworkMapper.selectPage(pageObj, wrapper); + + List> voList = result.getRecords().stream() + .map(this::entityToMap) + .collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + @Override + public Map findDetail(Long id) { + log.info("查询作业详情,ID:{}", id); + + BizHomework entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作业不存在"); + } + return entityToMap(entity); + } + + @Override + public BizHomework update(Long id, CreateHomeworkDto dto) { + log.info("更新作业,ID:{}", id); + + BizHomework entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作业不存在"); + } + + if (StringUtils.hasText(dto.getName())) { + entity.setName(dto.getName()); + } + if (dto.getContent() != null) { + entity.setContent(dto.getContent()); + } + if (StringUtils.hasText(dto.getSubmitStartTime())) { + entity.setSubmitStartTime(LocalDateTime.parse(dto.getSubmitStartTime(), DATE_TIME_FORMATTER)); + } + if (StringUtils.hasText(dto.getSubmitEndTime())) { + entity.setSubmitEndTime(LocalDateTime.parse(dto.getSubmitEndTime(), DATE_TIME_FORMATTER)); + } + if (dto.getAttachments() != null) { + entity.setAttachments(dto.getAttachments()); + } + if (dto.getPublishScope() != null) { + entity.setPublishScope(dto.getPublishScope()); + } + if (dto.getReviewRuleId() != null) { + entity.setReviewRuleId(dto.getReviewRuleId()); + } + + updateById(entity); + log.info("作业更新成功,ID:{}", id); + return entity; + } + + @Override + public void publish(Long id, List publishScope) { + log.info("发布作业,ID:{}", id); + + BizHomework entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作业不存在"); + } + + entity.setStatus("published"); + entity.setPublishTime(LocalDateTime.now()); + if (publishScope != null) { + entity.setPublishScope(publishScope); + } + + updateById(entity); + log.info("作业发布成功,ID:{}", id); + } + + @Override + public void unpublish(Long id) { + log.info("取消发布作业,ID:{}", id); + + BizHomework entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作业不存在"); + } + + entity.setStatus("unpublished"); + entity.setPublishTime(null); + + updateById(entity); + log.info("作业取消发布成功,ID:{}", id); + } + + @Override + public void remove(Long id) { + log.info("删除作业,ID:{}", id); + removeById(id); + log.info("作业删除成功,ID:{}", id); + } + + // ====== 私有辅助方法 ====== + + private Map entityToMap(BizHomework entity) { + Map map = new LinkedHashMap<>(); + map.put("id", entity.getId()); + map.put("tenantId", entity.getTenantId()); + map.put("name", entity.getName()); + map.put("content", entity.getContent()); + map.put("submitStartTime", entity.getSubmitStartTime()); + map.put("submitEndTime", entity.getSubmitEndTime()); + map.put("attachments", entity.getAttachments()); + map.put("publishScope", entity.getPublishScope()); + map.put("reviewRuleId", entity.getReviewRuleId()); + map.put("status", entity.getStatus()); + map.put("publishTime", entity.getPublishTime()); + map.put("createTime", entity.getCreateTime()); + map.put("modifyTime", entity.getModifyTime()); + return map; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkSubmissionServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkSubmissionServiceImpl.java new file mode 100644 index 0000000..5a860b5 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/homework/service/impl/HomeworkSubmissionServiceImpl.java @@ -0,0 +1,162 @@ +package com.competition.modules.biz.homework.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.homework.dto.CreateSubmissionDto; +import com.competition.modules.biz.homework.entity.BizHomework; +import com.competition.modules.biz.homework.entity.BizHomeworkSubmission; +import com.competition.modules.biz.homework.mapper.HomeworkSubmissionMapper; +import com.competition.modules.biz.homework.service.IHomeworkService; +import com.competition.modules.biz.homework.service.IHomeworkSubmissionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class HomeworkSubmissionServiceImpl extends ServiceImpl implements IHomeworkSubmissionService { + + private final HomeworkSubmissionMapper homeworkSubmissionMapper; + @Lazy + private final IHomeworkService homeworkService; + + @Override + public BizHomeworkSubmission create(CreateSubmissionDto dto, Long studentId, Long tenantId) { + log.info("提交作业,作业ID:{},学生ID:{}", dto.getHomeworkId(), studentId); + + // 验证作业已发布 + BizHomework homework = homeworkService.getById(dto.getHomeworkId()); + if (homework == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作业不存在"); + } + if (!"published".equals(homework.getStatus())) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "作业未发布,无法提交"); + } + + // 检查是否已提交过 + LambdaQueryWrapper checkWrapper = new LambdaQueryWrapper<>(); + checkWrapper.eq(BizHomeworkSubmission::getHomeworkId, dto.getHomeworkId()); + checkWrapper.eq(BizHomeworkSubmission::getStudentId, studentId); + checkWrapper.eq(BizHomeworkSubmission::getValidState, 1); + long existCount = count(checkWrapper); + if (existCount > 0) { + throw BusinessException.of(ErrorCode.CONFLICT, "您已提交过该作业"); + } + + BizHomeworkSubmission entity = new BizHomeworkSubmission(); + entity.setTenantId(tenantId); + entity.setHomeworkId(dto.getHomeworkId()); + entity.setStudentId(studentId); + entity.setWorkName(dto.getWorkName()); + entity.setWorkDescription(dto.getWorkDescription()); + entity.setFiles(dto.getFiles()); + entity.setSubmitTime(LocalDateTime.now()); + entity.setStatus("pending"); + + save(entity); + log.info("作业提交成功,ID:{}", entity.getId()); + return entity; + } + + @Override + public PageResult> findAll(Long page, Long pageSize, Long tenantId, Long homeworkId, + String workNo, String workName, String studentAccount, + String studentName, String status) { + log.info("查询作业提交列表,页码:{},每页:{}", page, pageSize); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizHomeworkSubmission::getValidState, 1); + + if (tenantId != null) { + wrapper.eq(BizHomeworkSubmission::getTenantId, tenantId); + } + if (homeworkId != null) { + wrapper.eq(BizHomeworkSubmission::getHomeworkId, homeworkId); + } + if (StringUtils.hasText(workNo)) { + wrapper.like(BizHomeworkSubmission::getWorkNo, workNo); + } + if (StringUtils.hasText(workName)) { + wrapper.like(BizHomeworkSubmission::getWorkName, workName); + } + if (StringUtils.hasText(status)) { + wrapper.eq(BizHomeworkSubmission::getStatus, status); + } + + wrapper.orderByDesc(BizHomeworkSubmission::getSubmitTime); + + Page pageObj = new Page<>(page, pageSize); + Page result = homeworkSubmissionMapper.selectPage(pageObj, wrapper); + + List> voList = result.getRecords().stream() + .map(this::entityToMap) + .collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + @Override + public Map findDetail(Long id) { + log.info("查询作业提交详情,ID:{}", id); + + BizHomeworkSubmission entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作业提交不存在"); + } + return entityToMap(entity); + } + + @Override + public Map findMySubmission(Long homeworkId, Long userId) { + log.info("查询我的作业提交,作业ID:{},用户ID:{}", homeworkId, userId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizHomeworkSubmission::getHomeworkId, homeworkId); + wrapper.eq(BizHomeworkSubmission::getStudentId, userId); + wrapper.eq(BizHomeworkSubmission::getValidState, 1); + + BizHomeworkSubmission entity = getOne(wrapper); + if (entity == null) { + return null; + } + return entityToMap(entity); + } + + @Override + public List> getClassTree(Long tenantId) { + log.info("获取班级树,租户:{}(学校模块已剥离,返回空列表)", tenantId); + return Collections.emptyList(); + } + + // ====== 私有辅助方法 ====== + + private Map entityToMap(BizHomeworkSubmission entity) { + Map map = new LinkedHashMap<>(); + map.put("id", entity.getId()); + map.put("tenantId", entity.getTenantId()); + map.put("homeworkId", entity.getHomeworkId()); + map.put("studentId", entity.getStudentId()); + map.put("workNo", entity.getWorkNo()); + map.put("workName", entity.getWorkName()); + map.put("workDescription", entity.getWorkDescription()); + map.put("files", entity.getFiles()); + map.put("attachments", entity.getAttachments()); + map.put("submitTime", entity.getSubmitTime()); + map.put("status", entity.getStatus()); + map.put("totalScore", entity.getTotalScore()); + map.put("createTime", entity.getCreateTime()); + map.put("modifyTime", entity.getModifyTime()); + return map; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/judge/controller/JudgesManagementController.java b/backend-java/src/main/java/com/competition/modules/biz/judge/controller/JudgesManagementController.java new file mode 100644 index 0000000..87338ff --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/judge/controller/JudgesManagementController.java @@ -0,0 +1,90 @@ +package com.competition.modules.biz.judge.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.judge.service.IJudgesManagementService; +import com.competition.security.annotation.RequirePermission; +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; +import java.util.Map; + +@Tag(name = "评委库管理") +@RestController +@RequestMapping("/judges-management") +@RequiredArgsConstructor +public class JudgesManagementController { + + private final IJudgesManagementService judgesManagementService; + + @PostMapping + @RequirePermission("judge:create") + @Operation(summary = "创建评委账号") + public Result> create(@RequestBody Map body) { + return Result.success(judgesManagementService.createJudge(body)); + } + + @GetMapping + @RequirePermission("judge:read") + @Operation(summary = "查询评委列表") + public Result>> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status) { + return Result.success(judgesManagementService.findAll(page, pageSize, keyword, status)); + } + + @GetMapping("/{id}") + @RequirePermission("judge:read") + @Operation(summary = "查询评委详情") + public Result> findDetail(@PathVariable Long id) { + return Result.success(judgesManagementService.findDetail(id)); + } + + @PatchMapping("/{id}") + @RequirePermission("judge:update") + @Operation(summary = "更新评委信息") + public Result> update(@PathVariable Long id, @RequestBody Map body) { + return Result.success(judgesManagementService.updateJudge(id, body)); + } + + @PatchMapping("/{id}/freeze") + @RequirePermission("judge:update") + @Operation(summary = "冻结评委账号") + public Result freeze(@PathVariable Long id) { + judgesManagementService.freeze(id); + return Result.success(); + } + + @PatchMapping("/{id}/unfreeze") + @RequirePermission("judge:update") + @Operation(summary = "解冻评委账号") + public Result unfreeze(@PathVariable Long id) { + judgesManagementService.unfreeze(id); + return Result.success(); + } + + @DeleteMapping("/{id}") + @RequirePermission("judge:delete") + @Operation(summary = "删除评委") + public Result remove(@PathVariable Long id) { + judgesManagementService.remove(id); + return Result.success(); + } + + @PostMapping("/batch-delete") + @RequirePermission("judge:delete") + @Operation(summary = "批量删除评委") + @SuppressWarnings("unchecked") + public Result batchDelete(@RequestBody Map body) { + List ids = ((List) body.get("ids")) + .stream().map(Number::longValue).toList(); + judgesManagementService.batchDelete(ids); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/judge/service/IJudgesManagementService.java b/backend-java/src/main/java/com/competition/modules/biz/judge/service/IJudgesManagementService.java new file mode 100644 index 0000000..5313a87 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/judge/service/IJudgesManagementService.java @@ -0,0 +1,49 @@ +package com.competition.modules.biz.judge.service; + +import com.competition.common.result.PageResult; + +import java.util.List; +import java.util.Map; + +public interface IJudgesManagementService { + + /** + * 创建评委账号 + */ + Map createJudge(Map params); + + /** + * 分页查询评委列表 + */ + PageResult> findAll(Long page, Long pageSize, String keyword, String status); + + /** + * 查询评委详情 + */ + Map findDetail(Long id); + + /** + * 更新评委信息 + */ + Map updateJudge(Long id, Map params); + + /** + * 冻结评委 + */ + void freeze(Long id); + + /** + * 解冻评委 + */ + void unfreeze(Long id); + + /** + * 删除评委 + */ + void remove(Long id); + + /** + * 批量删除评委 + */ + void batchDelete(List ids); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/judge/service/impl/JudgesManagementServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/judge/service/impl/JudgesManagementServiceImpl.java new file mode 100644 index 0000000..3bb4ade --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/judge/service/impl/JudgesManagementServiceImpl.java @@ -0,0 +1,281 @@ +package com.competition.modules.biz.judge.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.judge.service.IJudgesManagementService; +import com.competition.modules.sys.entity.SysRole; +import com.competition.modules.sys.entity.SysTenant; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.entity.SysUserRole; +import com.competition.modules.sys.mapper.SysRoleMapper; +import com.competition.modules.sys.mapper.SysTenantMapper; +import com.competition.modules.sys.mapper.SysUserMapper; +import com.competition.modules.sys.mapper.SysUserRoleMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class JudgesManagementServiceImpl implements IJudgesManagementService { + + private final SysUserMapper sysUserMapper; + private final SysUserRoleMapper sysUserRoleMapper; + private final SysRoleMapper sysRoleMapper; + private final SysTenantMapper sysTenantMapper; + private final PasswordEncoder passwordEncoder; + + /** + * 获取评委专属租户 ID + */ + private Long getJudgeTenantId() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(SysTenant::getCode, "judge"); + SysTenant tenant = sysTenantMapper.selectOne(wrapper); + if (tenant == null) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "评委租户不存在,请先创建 code='judge' 的租户"); + } + return tenant.getId(); + } + + /** + * 获取评委角色 ID + */ + private Long getJudgeRoleId(Long tenantId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(SysRole::getCode, "judge"); + wrapper.eq(SysRole::getTenantId, tenantId); + SysRole role = sysRoleMapper.selectOne(wrapper); + if (role == null) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "评委角色不存在,请先在评委租户下创建 code='judge' 的角色"); + } + return role.getId(); + } + + /** + * 将 SysUser 转为前端需要的 Map + */ + private Map toMap(SysUser user) { + Map map = new LinkedHashMap<>(); + map.put("id", user.getId()); + map.put("username", user.getUsername()); + map.put("nickname", user.getNickname()); + map.put("email", user.getEmail()); + map.put("phone", user.getPhone()); + map.put("organization", user.getOrganization()); + map.put("avatar", user.getAvatar()); + map.put("status", user.getStatus()); + map.put("userSource", user.getUserSource()); + map.put("createTime", user.getCreateTime()); + map.put("modifyTime", user.getModifyTime()); + return map; + } + + @Override + @Transactional + public Map createJudge(Map params) { + String username = (String) params.get("username"); + String password = (String) params.get("password"); + String nickname = (String) params.get("nickname"); + String email = (String) params.get("email"); + String phone = (String) params.get("phone"); + String organization = (String) params.get("organization"); + + if (username == null || username.isBlank()) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "用户名不能为空"); + } + if (password == null || password.isBlank()) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "密码不能为空"); + } + + Long judgeTenantId = getJudgeTenantId(); + + // 检查用户名在评委租户内唯一 + LambdaQueryWrapper dupWrapper = new LambdaQueryWrapper<>(); + dupWrapper.eq(SysUser::getTenantId, judgeTenantId); + dupWrapper.eq(SysUser::getUsername, username); + if (sysUserMapper.selectCount(dupWrapper) > 0) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "该用户名已存在"); + } + + // 创建用户 + SysUser user = new SysUser(); + user.setTenantId(judgeTenantId); + user.setUsername(username); + user.setPassword(passwordEncoder.encode(password)); + user.setNickname(nickname); + user.setEmail(email); + user.setPhone(phone); + user.setOrganization(organization); + user.setUserSource("admin_created"); + user.setUserType("adult"); + user.setStatus("enabled"); + sysUserMapper.insert(user); + + // 分配评委角色 + Long judgeRoleId = getJudgeRoleId(judgeTenantId); + SysUserRole userRole = new SysUserRole(); + userRole.setUserId(user.getId()); + userRole.setRoleId(judgeRoleId); + sysUserRoleMapper.insert(userRole); + + log.info("评委账号创建成功,ID:{},用户名:{}", user.getId(), username); + return toMap(user); + } + + @Override + public PageResult> findAll(Long page, Long pageSize, String keyword, String status) { + Long judgeTenantId = getJudgeTenantId(); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(SysUser::getTenantId, judgeTenantId); + + if (keyword != null && !keyword.isBlank()) { + wrapper.and(w -> w + .like(SysUser::getUsername, keyword) + .or().like(SysUser::getNickname, keyword) + .or().like(SysUser::getOrganization, keyword)); + } + + if (status != null && !status.isBlank()) { + wrapper.eq(SysUser::getStatus, status); + } + + wrapper.orderByDesc(SysUser::getCreateTime); + + Page pageParam = new Page<>(page, pageSize); + Page result = sysUserMapper.selectPage(pageParam, wrapper); + + List> list = result.getRecords().stream() + .map(this::toMap) + .collect(Collectors.toList()); + + return new PageResult<>(list, result.getTotal(), result.getCurrent(), result.getSize()); + } + + @Override + public Map findDetail(Long id) { + SysUser user = sysUserMapper.selectById(id); + if (user == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在"); + } + + Long judgeTenantId = getJudgeTenantId(); + if (!judgeTenantId.equals(user.getTenantId())) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在"); + } + + return toMap(user); + } + + @Override + @Transactional + public Map updateJudge(Long id, Map params) { + SysUser user = sysUserMapper.selectById(id); + if (user == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在"); + } + + Long judgeTenantId = getJudgeTenantId(); + if (!judgeTenantId.equals(user.getTenantId())) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在"); + } + + if (params.containsKey("nickname")) { + user.setNickname((String) params.get("nickname")); + } + if (params.containsKey("email")) { + user.setEmail((String) params.get("email")); + } + if (params.containsKey("phone")) { + user.setPhone((String) params.get("phone")); + } + if (params.containsKey("organization")) { + user.setOrganization((String) params.get("organization")); + } + if (params.containsKey("password")) { + String newPassword = (String) params.get("password"); + if (newPassword != null && !newPassword.isBlank()) { + user.setPassword(passwordEncoder.encode(newPassword)); + } + } + + sysUserMapper.updateById(user); + log.info("评委信息更新成功,ID:{}", id); + return toMap(user); + } + + @Override + public void freeze(Long id) { + updateJudgeStatus(id, "disabled"); + log.info("评委账号已冻结,ID:{}", id); + } + + @Override + public void unfreeze(Long id) { + updateJudgeStatus(id, "enabled"); + log.info("评委账号已解冻,ID:{}", id); + } + + private void updateJudgeStatus(Long id, String status) { + SysUser user = sysUserMapper.selectById(id); + if (user == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在"); + } + + Long judgeTenantId = getJudgeTenantId(); + if (!judgeTenantId.equals(user.getTenantId())) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在"); + } + + user.setStatus(status); + sysUserMapper.updateById(user); + } + + @Override + public void remove(Long id) { + SysUser user = sysUserMapper.selectById(id); + if (user == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在"); + } + + Long judgeTenantId = getJudgeTenantId(); + if (!judgeTenantId.equals(user.getTenantId())) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在"); + } + + sysUserMapper.deleteById(id); + log.info("评委已删除,ID:{}", id); + } + + @Override + @Transactional + public void batchDelete(List ids) { + if (ids == null || ids.isEmpty()) { + return; + } + + Long judgeTenantId = getJudgeTenantId(); + + // 校验所有 ID 都属于评委租户 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(SysUser::getId, ids); + wrapper.eq(SysUser::getTenantId, judgeTenantId); + Long count = sysUserMapper.selectCount(wrapper); + if (count != ids.size()) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "部分评委不存在或不属于评委库"); + } + + sysUserMapper.deleteBatchIds(ids); + log.info("批量删除评委成功,数量:{}", ids.size()); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/controller/AnalyticsController.java b/backend-java/src/main/java/com/competition/modules/biz/review/controller/AnalyticsController.java new file mode 100644 index 0000000..4281e90 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/controller/AnalyticsController.java @@ -0,0 +1,40 @@ +package com.competition.modules.biz.review.controller; + +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.review.service.AnalyticsService; +import com.competition.security.annotation.RequirePermission; +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.Map; + +@Tag(name = "数据分析") +@RestController +@RequestMapping("/analytics") +@RequiredArgsConstructor +public class AnalyticsController { + + private final AnalyticsService analyticsService; + + @GetMapping("/overview") + @RequirePermission("contest:read") + @Operation(summary = "数据概览") + public Result> getOverview( + @RequestParam(required = false) String timeRange, + @RequestParam(required = false) Long contestId) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(analyticsService.getOverview(tenantId, contestId)); + } + + @GetMapping("/review") + @RequirePermission("contest:read") + @Operation(summary = "评审分析") + public Result> getReviewAnalysis( + @RequestParam(required = false) Long contestId) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(analyticsService.getReviewAnalysis(tenantId, contestId)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestJudgeController.java b/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestJudgeController.java new file mode 100644 index 0000000..3c2a72b --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestJudgeController.java @@ -0,0 +1,67 @@ +package com.competition.modules.biz.review.controller; + +import com.competition.common.result.Result; +import com.competition.modules.biz.review.entity.BizContestJudge; +import com.competition.modules.biz.review.service.IContestJudgeService; +import com.competition.security.annotation.RequirePermission; +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.math.BigDecimal; +import java.util.List; +import java.util.Map; + +@Tag(name = "赛事评委") +@RestController +@RequestMapping("/contests/judges") +@RequiredArgsConstructor +public class ContestJudgeController { + + private final IContestJudgeService contestJudgeService; + + @PostMapping + @RequirePermission("contest:update") + @Operation(summary = "添加赛事评委") + public Result createJudge(@RequestBody Map body) { + Long contestId = Long.valueOf(body.get("contestId").toString()); + Long judgeId = Long.valueOf(body.get("judgeId").toString()); + String specialty = (String) body.get("specialty"); + BigDecimal weight = body.get("weight") != null ? new BigDecimal(body.get("weight").toString()) : null; + String description = (String) body.get("description"); + return Result.success(contestJudgeService.createJudge(contestId, judgeId, specialty, weight, description)); + } + + @GetMapping("/contest/{contestId}") + @RequirePermission("contest:read") + @Operation(summary = "查询赛事评委列表") + public Result>> findByContest(@PathVariable Long contestId) { + return Result.success(contestJudgeService.findByContest(contestId)); + } + + @GetMapping("/{id}") + @RequirePermission("contest:read") + @Operation(summary = "查询评委详情") + public Result> findDetail(@PathVariable Long id) { + return Result.success(contestJudgeService.findDetail(id)); + } + + @PatchMapping("/{id}") + @RequirePermission("contest:update") + @Operation(summary = "更新评委信息") + public Result updateJudge(@PathVariable Long id, @RequestBody Map body) { + String specialty = (String) body.get("specialty"); + BigDecimal weight = body.get("weight") != null ? new BigDecimal(body.get("weight").toString()) : null; + String description = (String) body.get("description"); + return Result.success(contestJudgeService.updateJudge(id, specialty, weight, description)); + } + + @DeleteMapping("/{id}") + @RequirePermission("contest:update") + @Operation(summary = "移除评委") + public Result removeJudge(@PathVariable Long id) { + contestJudgeService.removeJudge(id); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestResultController.java b/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestResultController.java new file mode 100644 index 0000000..37ef333 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestResultController.java @@ -0,0 +1,100 @@ +package com.competition.modules.biz.review.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.modules.biz.review.dto.AutoSetAwardsDto; +import com.competition.modules.biz.review.dto.BatchSetAwardsDto; +import com.competition.modules.biz.review.dto.SetAwardDto; +import com.competition.modules.biz.review.service.IContestResultService; +import com.competition.security.annotation.RequirePermission; +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.Map; + +@Tag(name = "成果发布") +@RestController +@RequestMapping("/contests/results") +@RequiredArgsConstructor +public class ContestResultController { + + private final IContestResultService contestResultService; + + @PostMapping("/{contestId}/calculate-scores") + @RequirePermission("contest:update") + @Operation(summary = "计算所有最终得分") + public Result> calculateAllFinalScores(@PathVariable Long contestId) { + return Result.success(contestResultService.calculateAllFinalScores(contestId)); + } + + @PostMapping("/{contestId}/calculate-rankings") + @RequirePermission("contest:update") + @Operation(summary = "计算排名") + public Result> calculateRankings(@PathVariable Long contestId) { + return Result.success(contestResultService.calculateRankings(contestId)); + } + + @PatchMapping("/work/{workId}/award") + @RequirePermission("contest:update") + @Operation(summary = "设置奖项") + public Result setAward(@PathVariable Long workId, @Valid @RequestBody SetAwardDto dto) { + contestResultService.setAward(workId, dto); + return Result.success(); + } + + @PostMapping("/{contestId}/batch-set-awards") + @RequirePermission("contest:update") + @Operation(summary = "批量设置奖项") + public Result> batchSetAwards( + @PathVariable Long contestId, + @Valid @RequestBody BatchSetAwardsDto dto) { + return Result.success(contestResultService.batchSetAwards(contestId, dto)); + } + + @PostMapping("/{contestId}/auto-set-awards") + @RequirePermission("contest:update") + @Operation(summary = "自动设置奖项") + public Result> autoSetAwards( + @PathVariable Long contestId, + @Valid @RequestBody AutoSetAwardsDto dto) { + return Result.success(contestResultService.autoSetAwards(contestId, dto)); + } + + @PostMapping("/{contestId}/publish") + @RequirePermission("contest:update") + @Operation(summary = "发布成果") + public Result publishResults(@PathVariable Long contestId) { + contestResultService.publishResults(contestId); + return Result.success(); + } + + @PostMapping("/{contestId}/unpublish") + @RequirePermission("contest:update") + @Operation(summary = "撤回成果发布") + public Result unpublishResults(@PathVariable Long contestId) { + contestResultService.unpublishResults(contestId); + return Result.success(); + } + + @GetMapping("/{contestId}") + @RequirePermission("contest:read") + @Operation(summary = "查询成果列表") + public Result>> getResults( + @PathVariable Long contestId, + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize, + @RequestParam(required = false) String workNo, + @RequestParam(required = false) String accountNo) { + return Result.success(contestResultService.getResults(contestId, page, pageSize, workNo, accountNo)); + } + + @GetMapping("/{contestId}/summary") + @RequirePermission("contest:read") + @Operation(summary = "查询成果统计摘要") + public Result> getResultsSummary(@PathVariable Long contestId) { + return Result.success(contestResultService.getResultsSummary(contestId)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestReviewController.java b/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestReviewController.java new file mode 100644 index 0000000..5cd93fc --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestReviewController.java @@ -0,0 +1,132 @@ +package com.competition.modules.biz.review.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.review.dto.AssignWorkDto; +import com.competition.modules.biz.review.dto.BatchAssignDto; +import com.competition.modules.biz.review.dto.CreateScoreDto; +import com.competition.modules.biz.review.service.IContestReviewService; +import com.competition.security.annotation.RequirePermission; +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; +import java.util.Map; + +@Tag(name = "评审管理") +@RestController +@RequestMapping("/contests/reviews") +@RequiredArgsConstructor +public class ContestReviewController { + + private final IContestReviewService contestReviewService; + + @PostMapping("/assign") + @RequirePermission("review:assign") + @Operation(summary = "分配作品给评委") + public Result> assignWork( + @RequestParam Long contestId, + @Valid @RequestBody AssignWorkDto dto) { + Long creatorId = SecurityUtil.getCurrentUserId(); + return Result.success(contestReviewService.assignWork(contestId, dto.getWorkId(), dto.getJudgeIds(), creatorId)); + } + + @PostMapping("/batch-assign") + @RequirePermission("review:assign") + @Operation(summary = "批量分配作品给评委") + public Result> batchAssignWorks( + @RequestParam Long contestId, + @Valid @RequestBody BatchAssignDto dto) { + Long creatorId = SecurityUtil.getCurrentUserId(); + return Result.success(contestReviewService.batchAssignWorks(contestId, dto.getWorkIds(), dto.getJudgeIds(), creatorId)); + } + + @PostMapping("/auto-assign") + @RequirePermission("review:assign") + @Operation(summary = "自动分配作品") + public Result> autoAssignWorks(@RequestParam Long contestId) { + Long creatorId = SecurityUtil.getCurrentUserId(); + return Result.success(contestReviewService.autoAssignWorks(contestId, creatorId)); + } + + @PostMapping("/score") + @RequirePermission("review:score") + @Operation(summary = "评分") + public Result> score(@Valid @RequestBody CreateScoreDto dto) { + Long judgeId = SecurityUtil.getCurrentUserId(); + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(contestReviewService.score(dto, judgeId, tenantId)); + } + + @PatchMapping("/score/{id}") + @RequirePermission("review:score") + @Operation(summary = "修改评分") + public Result> updateScore( + @PathVariable Long id, + @Valid @RequestBody CreateScoreDto dto) { + Long judgeId = SecurityUtil.getCurrentUserId(); + return Result.success(contestReviewService.updateScore(id, dto, judgeId)); + } + + @GetMapping("/assigned") + @RequirePermission("review:read") + @Operation(summary = "查询已分配作品") + public Result>> getAssignedWorks(@RequestParam Long contestId) { + Long judgeId = SecurityUtil.getCurrentUserId(); + return Result.success(contestReviewService.getAssignedWorks(judgeId, contestId)); + } + + @GetMapping("/judge/contests") + @RequirePermission("review:score") + @Operation(summary = "获取评委参与的赛事列表") + public Result>> getJudgeContests() { + Long judgeId = SecurityUtil.getCurrentUserId(); + return Result.success(contestReviewService.getJudgeContests(judgeId)); + } + + @GetMapping("/judge/contests/{contestId}/works") + @RequirePermission("review:score") + @Operation(summary = "获取评委赛事作品列表") + public Result>> getJudgeContestWorks( + @PathVariable Long contestId, + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize, + @RequestParam(required = false) String workNo, + @RequestParam(required = false) String accountNo, + @RequestParam(required = false) String reviewStatus) { + Long judgeId = SecurityUtil.getCurrentUserId(); + return Result.success(contestReviewService.getJudgeContestWorks(judgeId, contestId, page, pageSize, workNo, accountNo, reviewStatus)); + } + + @GetMapping("/progress/{contestId}") + @RequirePermission("review:read") + @Operation(summary = "获取评审进度") + public Result> getReviewProgress(@PathVariable Long contestId) { + return Result.success(contestReviewService.getReviewProgress(contestId)); + } + + @GetMapping("/work-status/{contestId}") + @RequirePermission("review:read") + @Operation(summary = "获取作品评审状态统计") + public Result> getWorkStatusStats(@PathVariable Long contestId) { + return Result.success(contestReviewService.getWorkStatusStats(contestId)); + } + + @GetMapping("/work/{workId}/scores") + @RequirePermission("review:read") + @Operation(summary = "查询作品评分记录") + public Result>> getWorkScores(@PathVariable Long workId) { + return Result.success(contestReviewService.getWorkScores(workId)); + } + + @GetMapping("/work/{workId}/final-score") + @RequirePermission("review:read") + @Operation(summary = "计算作品最终得分") + public Result> calculateFinalScore(@PathVariable Long workId) { + return Result.success(contestReviewService.calculateFinalScore(workId)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestReviewRuleController.java b/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestReviewRuleController.java new file mode 100644 index 0000000..56987c6 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/controller/ContestReviewRuleController.java @@ -0,0 +1,75 @@ +package com.competition.modules.biz.review.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.review.dto.CreateReviewRuleDto; +import com.competition.modules.biz.review.entity.BizContestReviewRule; +import com.competition.modules.biz.review.service.IContestReviewRuleService; +import com.competition.security.annotation.RequirePermission; +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; +import java.util.Map; + +@Tag(name = "评审规则") +@RestController +@RequestMapping("/contests/review-rules") +@RequiredArgsConstructor +public class ContestReviewRuleController { + + private final IContestReviewRuleService contestReviewRuleService; + + @GetMapping + @RequirePermission("contest:read") + @Operation(summary = "查询评审规则列表") + public Result>> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize, + @RequestParam(required = false) String ruleName) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(contestReviewRuleService.findAll(page, pageSize, ruleName, tenantId)); + } + + @GetMapping("/select") + @RequirePermission("contest:read") + @Operation(summary = "查询评审规则选项列表") + public Result>> findAllForSelect() { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(contestReviewRuleService.findAllForSelect(tenantId)); + } + + @PostMapping + @RequirePermission("contest:update") + @Operation(summary = "创建评审规则") + public Result createRule(@Valid @RequestBody CreateReviewRuleDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(contestReviewRuleService.createRule(dto, tenantId)); + } + + @GetMapping("/{id}") + @RequirePermission("contest:read") + @Operation(summary = "查询评审规则详情") + public Result> findDetail(@PathVariable Long id) { + return Result.success(contestReviewRuleService.findDetail(id)); + } + + @PatchMapping("/{id}") + @RequirePermission("contest:update") + @Operation(summary = "更新评审规则") + public Result updateRule(@PathVariable Long id, @RequestBody CreateReviewRuleDto dto) { + return Result.success(contestReviewRuleService.updateRule(id, dto)); + } + + @DeleteMapping("/{id}") + @RequirePermission("contest:update") + @Operation(summary = "删除评审规则") + public Result removeRule(@PathVariable Long id) { + contestReviewRuleService.removeRule(id); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/controller/PresetCommentController.java b/backend-java/src/main/java/com/competition/modules/biz/review/controller/PresetCommentController.java new file mode 100644 index 0000000..63a550d --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/controller/PresetCommentController.java @@ -0,0 +1,95 @@ +package com.competition.modules.biz.review.controller; + +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.review.dto.CreatePresetCommentDto; +import com.competition.modules.biz.review.entity.BizPresetComment; +import com.competition.modules.biz.review.service.IPresetCommentService; +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; +import java.util.Map; + +@Tag(name = "预设评语") +@RestController +@RequestMapping("/contests/preset-comments") +@RequiredArgsConstructor +public class PresetCommentController { + + private final IPresetCommentService presetCommentService; + + @PostMapping + @Operation(summary = "创建预设评语") + public Result createComment(@Valid @RequestBody CreatePresetCommentDto dto) { + Long judgeId = SecurityUtil.getCurrentUserId(); + return Result.success(presetCommentService.createComment(dto, judgeId)); + } + + @GetMapping + @Operation(summary = "查询预设评语列表") + public Result>> findAll(@RequestParam(required = false) Long contestId) { + Long judgeId = SecurityUtil.getCurrentUserId(); + return Result.success(presetCommentService.findAll(contestId, judgeId)); + } + + @GetMapping("/judge/contests") + @Operation(summary = "获取评委参与的赛事列表") + public Result>> getJudgeContests() { + Long judgeId = SecurityUtil.getCurrentUserId(); + return Result.success(presetCommentService.getJudgeContests(judgeId)); + } + + @GetMapping("/{id}") + @Operation(summary = "查询预设评语详情") + public Result> findDetail(@PathVariable Long id) { + Long judgeId = SecurityUtil.getCurrentUserId(); + return Result.success(presetCommentService.findDetail(id, judgeId)); + } + + @PatchMapping("/{id}") + @Operation(summary = "更新预设评语") + public Result updateComment(@PathVariable Long id, @RequestBody CreatePresetCommentDto dto) { + Long judgeId = SecurityUtil.getCurrentUserId(); + return Result.success(presetCommentService.updateComment(id, dto, judgeId)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除预设评语") + public Result removeComment(@PathVariable Long id) { + Long judgeId = SecurityUtil.getCurrentUserId(); + presetCommentService.removeComment(id, judgeId); + return Result.success(); + } + + @SuppressWarnings("unchecked") + @PostMapping("/batch-delete") + @Operation(summary = "批量删除预设评语") + public Result batchDelete(@RequestBody Map body) { + List ids = (List) body.get("ids"); + Long judgeId = SecurityUtil.getCurrentUserId(); + presetCommentService.batchDelete(ids, judgeId); + return Result.success(); + } + + @PostMapping("/sync") + @Operation(summary = "同步评语到其他赛事") + public Result> syncComments(@RequestBody Map body) { + Long sourceContestId = Long.valueOf(body.get("sourceContestId").toString()); + @SuppressWarnings("unchecked") + List targetContestIds = (List) body.get("targetContestIds"); + Long judgeId = SecurityUtil.getCurrentUserId(); + return Result.success(presetCommentService.syncComments(sourceContestId, targetContestIds, judgeId)); + } + + @PostMapping("/{id}/use") + @Operation(summary = "增加评语使用次数") + public Result incrementUseCount(@PathVariable Long id) { + Long judgeId = SecurityUtil.getCurrentUserId(); + presetCommentService.incrementUseCount(id, judgeId); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/dto/AssignWorkDto.java b/backend-java/src/main/java/com/competition/modules/biz/review/dto/AssignWorkDto.java new file mode 100644 index 0000000..227d4f7 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/dto/AssignWorkDto.java @@ -0,0 +1,20 @@ +package com.competition.modules.biz.review.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "分配作品给评委DTO") +public class AssignWorkDto { + + @NotNull(message = "作品ID不能为空") + @Schema(description = "作品ID") + private Long workId; + + @NotNull(message = "评委ID列表不能为空") + @Schema(description = "评委ID列表") + private List judgeIds; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/dto/AutoSetAwardsDto.java b/backend-java/src/main/java/com/competition/modules/biz/review/dto/AutoSetAwardsDto.java new file mode 100644 index 0000000..6854a1f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/dto/AutoSetAwardsDto.java @@ -0,0 +1,25 @@ +package com.competition.modules.biz.review.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "自动设置奖项DTO") +public class AutoSetAwardsDto { + + @Schema(description = "奖项层级列表") + private List awards; + + @Data + @Schema(description = "奖项层级") + public static class AwardTier { + + @Schema(description = "奖项名称") + private String name; + + @Schema(description = "数量") + private Integer count; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/dto/BatchAssignDto.java b/backend-java/src/main/java/com/competition/modules/biz/review/dto/BatchAssignDto.java new file mode 100644 index 0000000..bf32c50 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/dto/BatchAssignDto.java @@ -0,0 +1,20 @@ +package com.competition.modules.biz.review.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "批量分配作品给评委DTO") +public class BatchAssignDto { + + @NotNull(message = "作品ID列表不能为空") + @Schema(description = "作品ID列表") + private List workIds; + + @NotNull(message = "评委ID列表不能为空") + @Schema(description = "评委ID列表") + private List judgeIds; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/dto/BatchSetAwardsDto.java b/backend-java/src/main/java/com/competition/modules/biz/review/dto/BatchSetAwardsDto.java new file mode 100644 index 0000000..70babac --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/dto/BatchSetAwardsDto.java @@ -0,0 +1,28 @@ +package com.competition.modules.biz.review.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "批量设置奖项DTO") +public class BatchSetAwardsDto { + + @Schema(description = "奖项列表") + private List awards; + + @Data + @Schema(description = "奖项条目") + public static class AwardItem { + + @Schema(description = "作品ID") + private Long workId; + + @Schema(description = "奖项等级") + private String awardLevel; + + @Schema(description = "奖项名称") + private String awardName; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/dto/CreatePresetCommentDto.java b/backend-java/src/main/java/com/competition/modules/biz/review/dto/CreatePresetCommentDto.java new file mode 100644 index 0000000..a81b323 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/dto/CreatePresetCommentDto.java @@ -0,0 +1,27 @@ +package com.competition.modules.biz.review.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@Schema(description = "创建预设评语DTO") +public class CreatePresetCommentDto { + + @NotNull(message = "赛事ID不能为空") + @Schema(description = "赛事ID") + private Long contestId; + + @NotBlank(message = "评语内容不能为空") + @Schema(description = "评语内容") + private String content; + + @Schema(description = "关联分数") + private BigDecimal score; + + @Schema(description = "排序") + private Integer sortOrder; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/dto/CreateReviewRuleDto.java b/backend-java/src/main/java/com/competition/modules/biz/review/dto/CreateReviewRuleDto.java new file mode 100644 index 0000000..00a79b9 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/dto/CreateReviewRuleDto.java @@ -0,0 +1,29 @@ +package com.competition.modules.biz.review.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +@Schema(description = "创建评审规则DTO") +public class CreateReviewRuleDto { + + @NotBlank(message = "规则名称不能为空") + @Schema(description = "规则名称") + private String ruleName; + + @Schema(description = "规则描述") + private String ruleDescription; + + @NotNull(message = "评委人数不能为空") + @Schema(description = "评委人数") + private Integer judgeCount; + + @NotNull(message = "评分维度不能为空") + @Schema(description = "评分维度(JSON数组)") + private Object dimensions; + + @Schema(description = "计算规则:average/remove_max_min/remove_min/max/weighted") + private String calculationRule; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/dto/CreateScoreDto.java b/backend-java/src/main/java/com/competition/modules/biz/review/dto/CreateScoreDto.java new file mode 100644 index 0000000..b837eef --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/dto/CreateScoreDto.java @@ -0,0 +1,30 @@ +package com.competition.modules.biz.review.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@Schema(description = "创建评分DTO") +public class CreateScoreDto { + + @NotNull(message = "作品ID不能为空") + @Schema(description = "作品ID") + private Long workId; + + @NotNull(message = "分配记录ID不能为空") + @Schema(description = "分配记录ID") + private Long assignmentId; + + @Schema(description = "维度评分(JSON对象)") + private Object dimensionScores; + + @NotNull(message = "总分不能为空") + @Schema(description = "总分") + private BigDecimal totalScore; + + @Schema(description = "评语") + private String comments; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/dto/SetAwardDto.java b/backend-java/src/main/java/com/competition/modules/biz/review/dto/SetAwardDto.java new file mode 100644 index 0000000..3c09d08 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/dto/SetAwardDto.java @@ -0,0 +1,20 @@ +package com.competition.modules.biz.review.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "设置奖项DTO") +public class SetAwardDto { + + @NotBlank(message = "奖项等级不能为空") + @Schema(description = "奖项等级") + private String awardLevel; + + @Schema(description = "奖项名称") + private String awardName; + + @Schema(description = "证书URL") + private String certificateUrl; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestJudge.java b/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestJudge.java new file mode 100644 index 0000000..b3c2bf3 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestJudge.java @@ -0,0 +1,29 @@ +package com.competition.modules.biz.review.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("t_contest_judge") +public class BizContestJudge extends BaseEntity { + + @TableField("contest_id") + private Long contestId; + + /** 用户 ID */ + @TableField("judge_id") + private Long judgeId; + + private String specialty; + + /** 评委权重 0-1, Decimal(3,2) */ + private BigDecimal weight; + + private String description; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestReviewRule.java b/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestReviewRule.java new file mode 100644 index 0000000..253500a --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestReviewRule.java @@ -0,0 +1,34 @@ +package com.competition.modules.biz.review.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "t_contest_review_rule", autoResultMap = true) +public class BizContestReviewRule extends BaseEntity { + + @TableField("tenant_id") + private Long tenantId; + + @TableField("rule_name") + private String ruleName; + + @TableField("rule_description") + private String ruleDescription; + + @TableField("judge_count") + private Integer judgeCount; + + /** JSON array of {name, percentage, description} */ + @TableField(value = "dimensions", typeHandler = JacksonTypeHandler.class) + private Object dimensions; + + /** average/remove_max_min/remove_min/max/weighted */ + @TableField("calculation_rule") + private String calculationRule; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestWorkJudgeAssignment.java b/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestWorkJudgeAssignment.java new file mode 100644 index 0000000..0b39be7 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestWorkJudgeAssignment.java @@ -0,0 +1,43 @@ +package com.competition.modules.biz.review.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("t_contest_work_judge_assignment") +public class BizContestWorkJudgeAssignment implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("contest_id") + private Long contestId; + + @TableField("work_id") + private Long workId; + + @TableField("judge_id") + private Long judgeId; + + @TableField("assignment_time") + private LocalDateTime assignmentTime; + + /** assigned/reviewing/completed */ + private String status; + + private Integer creator; + + private Integer modifier; + + @TableField("create_time") + private LocalDateTime createTime; + + @TableField("modify_time") + private LocalDateTime modifyTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestWorkScore.java b/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestWorkScore.java new file mode 100644 index 0000000..ca5551f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizContestWorkScore.java @@ -0,0 +1,46 @@ +package com.competition.modules.biz.review.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "t_contest_work_score", autoResultMap = true) +public class BizContestWorkScore extends BaseEntity { + + @TableField("tenant_id") + private Long tenantId; + + @TableField("contest_id") + private Long contestId; + + @TableField("work_id") + private Long workId; + + @TableField("assignment_id") + private Long assignmentId; + + @TableField("judge_id") + private Long judgeId; + + @TableField("judge_name") + private String judgeName; + + @TableField(value = "dimension_scores", typeHandler = JacksonTypeHandler.class) + private Object dimensionScores; + + @TableField("total_score") + private BigDecimal totalScore; + + private String comments; + + @TableField("score_time") + private LocalDateTime scoreTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizPresetComment.java b/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizPresetComment.java new file mode 100644 index 0000000..dd4d02e --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/entity/BizPresetComment.java @@ -0,0 +1,32 @@ +package com.competition.modules.biz.review.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("t_preset_comment") +public class BizPresetComment extends BaseEntity { + + @TableField("contest_id") + private Long contestId; + + @TableField("judge_id") + private Long judgeId; + + private String content; + + /** Decimal(10,2) */ + private BigDecimal score; + + @TableField("sort_order") + private Integer sortOrder; + + @TableField("use_count") + private Integer useCount; +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestJudgeMapper.java b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestJudgeMapper.java new file mode 100644 index 0000000..f0c18a5 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestJudgeMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.review.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.review.entity.BizContestJudge; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestJudgeMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestReviewRuleMapper.java b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestReviewRuleMapper.java new file mode 100644 index 0000000..ce8ad98 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestReviewRuleMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.review.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.review.entity.BizContestReviewRule; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestReviewRuleMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestWorkJudgeAssignmentMapper.java b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestWorkJudgeAssignmentMapper.java new file mode 100644 index 0000000..cbfe223 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestWorkJudgeAssignmentMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.review.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.review.entity.BizContestWorkJudgeAssignment; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestWorkJudgeAssignmentMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestWorkScoreMapper.java b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestWorkScoreMapper.java new file mode 100644 index 0000000..1e054d6 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/ContestWorkScoreMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.review.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.review.entity.BizContestWorkScore; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestWorkScoreMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/mapper/PresetCommentMapper.java b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/PresetCommentMapper.java new file mode 100644 index 0000000..023d988 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/PresetCommentMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.biz.review.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.biz.review.entity.BizPresetComment; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PresetCommentMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/AnalyticsService.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/AnalyticsService.java new file mode 100644 index 0000000..60055e4 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/AnalyticsService.java @@ -0,0 +1,171 @@ +package com.competition.modules.biz.review.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.competition.modules.biz.contest.entity.BizContest; +import com.competition.modules.biz.contest.entity.BizContestRegistration; +import com.competition.modules.biz.contest.entity.BizContestWork; +import com.competition.modules.biz.contest.mapper.ContestMapper; +import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper; +import com.competition.modules.biz.contest.mapper.ContestWorkMapper; +import com.competition.modules.biz.review.entity.BizContestJudge; +import com.competition.modules.biz.review.entity.BizContestWorkJudgeAssignment; +import com.competition.modules.biz.review.entity.BizContestWorkScore; +import com.competition.modules.biz.review.mapper.ContestJudgeMapper; +import com.competition.modules.biz.review.mapper.ContestWorkJudgeAssignmentMapper; +import com.competition.modules.biz.review.mapper.ContestWorkScoreMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AnalyticsService { + + private final ContestMapper contestMapper; + private final ContestRegistrationMapper contestRegistrationMapper; + private final ContestWorkMapper contestWorkMapper; + private final ContestWorkScoreMapper contestWorkScoreMapper; + private final ContestWorkJudgeAssignmentMapper contestWorkJudgeAssignmentMapper; + private final ContestJudgeMapper contestJudgeMapper; + + /** + * 数据概览 + */ + public Map getOverview(Long tenantId, Long contestId) { + Map result = new HashMap<>(); + + // --- summary --- + long totalContests = contestMapper.selectCount( + new LambdaQueryWrapper() + .eq(tenantId != null, BizContest::getContestState, "published")); + + long totalRegistrations = contestRegistrationMapper.selectCount( + new LambdaQueryWrapper() + .eq(contestId != null, BizContestRegistration::getContestId, contestId)); + + long passedRegistrations = contestRegistrationMapper.selectCount( + new LambdaQueryWrapper() + .eq(contestId != null, BizContestRegistration::getContestId, contestId) + .eq(BizContestRegistration::getRegistrationState, "passed")); + + long totalWorks = contestWorkMapper.selectCount( + new LambdaQueryWrapper() + .eq(contestId != null, BizContestWork::getContestId, contestId) + .eq(BizContestWork::getIsLatest, true)); + + long reviewedWorks = contestWorkScoreMapper.selectCount( + new LambdaQueryWrapper() + .eq(contestId != null, BizContestWorkScore::getContestId, contestId)); + + long awardedWorks = contestWorkMapper.selectCount( + new LambdaQueryWrapper() + .eq(contestId != null, BizContestWork::getContestId, contestId) + .eq(BizContestWork::getIsLatest, true) + .isNotNull(BizContestWork::getAwardLevel) + .ne(BizContestWork::getAwardLevel, "none")); + + Map summary = new LinkedHashMap<>(); + summary.put("totalContests", totalContests); + summary.put("totalRegistrations", totalRegistrations); + summary.put("passedRegistrations", passedRegistrations); + summary.put("totalWorks", totalWorks); + summary.put("reviewedWorks", reviewedWorks); + summary.put("awardedWorks", awardedWorks); + result.put("summary", summary); + + // --- funnel --- + List> funnel = new ArrayList<>(); + funnel.add(Map.of("stage", "报名", "count", totalRegistrations)); + funnel.add(Map.of("stage", "审核通过", "count", passedRegistrations)); + funnel.add(Map.of("stage", "提交作品", "count", totalWorks)); + funnel.add(Map.of("stage", "已评审", "count", reviewedWorks)); + funnel.add(Map.of("stage", "获奖", "count", awardedWorks)); + result.put("funnel", funnel); + + return result; + } + + /** + * 评审分析 + */ + public Map getReviewAnalysis(Long tenantId, Long contestId) { + Map result = new HashMap<>(); + + // --- efficiency --- + long totalAssignments = contestWorkJudgeAssignmentMapper.selectCount( + new LambdaQueryWrapper() + .eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId)); + + long completedAssignments = contestWorkJudgeAssignmentMapper.selectCount( + new LambdaQueryWrapper() + .eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId) + .eq(BizContestWorkJudgeAssignment::getStatus, "completed")); + + long totalScores = contestWorkScoreMapper.selectCount( + new LambdaQueryWrapper() + .eq(contestId != null, BizContestWorkScore::getContestId, contestId)); + + Map efficiency = new LinkedHashMap<>(); + efficiency.put("totalAssignments", totalAssignments); + efficiency.put("completedAssignments", completedAssignments); + efficiency.put("completionRate", totalAssignments > 0 + ? Math.round(completedAssignments * 10000.0 / totalAssignments) / 100.0 + : 0); + efficiency.put("totalScores", totalScores); + result.put("efficiency", efficiency); + + // --- judgeWorkload --- + List judges = contestJudgeMapper.selectList( + new LambdaQueryWrapper() + .eq(contestId != null, BizContestJudge::getContestId, contestId)); + + List> judgeWorkload = judges.stream().map(judge -> { + long assigned = contestWorkJudgeAssignmentMapper.selectCount( + new LambdaQueryWrapper() + .eq(BizContestWorkJudgeAssignment::getJudgeId, judge.getJudgeId()) + .eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId)); + long completed = contestWorkJudgeAssignmentMapper.selectCount( + new LambdaQueryWrapper() + .eq(BizContestWorkJudgeAssignment::getJudgeId, judge.getJudgeId()) + .eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId) + .eq(BizContestWorkJudgeAssignment::getStatus, "completed")); + Map item = new LinkedHashMap<>(); + item.put("judgeId", judge.getJudgeId()); + item.put("specialty", judge.getSpecialty()); + item.put("assigned", assigned); + item.put("completed", completed); + item.put("completionRate", assigned > 0 + ? Math.round(completed * 10000.0 / assigned) / 100.0 + : 0); + return item; + }).collect(Collectors.toList()); + result.put("judgeWorkload", judgeWorkload); + + // --- awardDistribution --- + long totalWorks = contestWorkMapper.selectCount( + new LambdaQueryWrapper() + .eq(contestId != null, BizContestWork::getContestId, contestId) + .eq(BizContestWork::getIsLatest, true)); + + List awardLevels = List.of("first", "second", "third", "excellent"); + List> awardDistribution = awardLevels.stream().map(level -> { + long count = contestWorkMapper.selectCount( + new LambdaQueryWrapper() + .eq(contestId != null, BizContestWork::getContestId, contestId) + .eq(BizContestWork::getIsLatest, true) + .eq(BizContestWork::getAwardLevel, level)); + Map item = new LinkedHashMap<>(); + item.put("level", level); + item.put("count", count); + item.put("ratio", totalWorks > 0 + ? Math.round(count * 10000.0 / totalWorks) / 100.0 + : 0); + return item; + }).collect(Collectors.toList()); + result.put("awardDistribution", awardDistribution); + + return result; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestJudgeService.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestJudgeService.java new file mode 100644 index 0000000..c0cbf37 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestJudgeService.java @@ -0,0 +1,21 @@ +package com.competition.modules.biz.review.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.modules.biz.review.entity.BizContestJudge; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +public interface IContestJudgeService extends IService { + + BizContestJudge createJudge(Long contestId, Long judgeId, String specialty, BigDecimal weight, String description); + + List> findByContest(Long contestId); + + Map findDetail(Long id); + + BizContestJudge updateJudge(Long id, String specialty, BigDecimal weight, String description); + + void removeJudge(Long id); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestResultService.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestResultService.java new file mode 100644 index 0000000..856019c --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestResultService.java @@ -0,0 +1,29 @@ +package com.competition.modules.biz.review.service; + +import com.competition.common.result.PageResult; +import com.competition.modules.biz.review.dto.AutoSetAwardsDto; +import com.competition.modules.biz.review.dto.BatchSetAwardsDto; +import com.competition.modules.biz.review.dto.SetAwardDto; + +import java.util.Map; + +public interface IContestResultService { + + Map calculateAllFinalScores(Long contestId); + + Map calculateRankings(Long contestId); + + void setAward(Long workId, SetAwardDto dto); + + Map batchSetAwards(Long contestId, BatchSetAwardsDto dto); + + Map autoSetAwards(Long contestId, AutoSetAwardsDto dto); + + void publishResults(Long contestId); + + void unpublishResults(Long contestId); + + PageResult> getResults(Long contestId, Long page, Long pageSize, String workNo, String accountNo); + + Map getResultsSummary(Long contestId); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestReviewRuleService.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestReviewRuleService.java new file mode 100644 index 0000000..8664eb6 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestReviewRuleService.java @@ -0,0 +1,24 @@ +package com.competition.modules.biz.review.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.review.dto.CreateReviewRuleDto; +import com.competition.modules.biz.review.entity.BizContestReviewRule; + +import java.util.List; +import java.util.Map; + +public interface IContestReviewRuleService extends IService { + + BizContestReviewRule createRule(CreateReviewRuleDto dto, Long tenantId); + + PageResult> findAll(Long page, Long pageSize, String ruleName, Long tenantId); + + List> findAllForSelect(Long tenantId); + + Map findDetail(Long id); + + BizContestReviewRule updateRule(Long id, CreateReviewRuleDto dto); + + void removeRule(Long id); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestReviewService.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestReviewService.java new file mode 100644 index 0000000..23a4097 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/IContestReviewService.java @@ -0,0 +1,34 @@ +package com.competition.modules.biz.review.service; + +import com.competition.common.result.PageResult; +import com.competition.modules.biz.review.dto.CreateScoreDto; + +import java.util.List; +import java.util.Map; + +public interface IContestReviewService { + + Map assignWork(Long contestId, Long workId, List judgeIds, Long creatorId); + + Map batchAssignWorks(Long contestId, List workIds, List judgeIds, Long creatorId); + + Map autoAssignWorks(Long contestId, Long creatorId); + + Map score(CreateScoreDto dto, Long judgeId, Long tenantId); + + Map updateScore(Long scoreId, CreateScoreDto dto, Long judgeId); + + List> getAssignedWorks(Long judgeId, Long contestId); + + List> getJudgeContests(Long judgeId); + + PageResult> getJudgeContestWorks(Long judgeId, Long contestId, Long page, Long pageSize, String workNo, String accountNo, String reviewStatus); + + Map getReviewProgress(Long contestId); + + Map getWorkStatusStats(Long contestId); + + List> getWorkScores(Long workId); + + Map calculateFinalScore(Long workId); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/IPresetCommentService.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/IPresetCommentService.java new file mode 100644 index 0000000..3300585 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/IPresetCommentService.java @@ -0,0 +1,29 @@ +package com.competition.modules.biz.review.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.modules.biz.review.dto.CreatePresetCommentDto; +import com.competition.modules.biz.review.entity.BizPresetComment; + +import java.util.List; +import java.util.Map; + +public interface IPresetCommentService extends IService { + + BizPresetComment createComment(CreatePresetCommentDto dto, Long judgeId); + + List> findAll(Long contestId, Long judgeId); + + Map findDetail(Long id, Long judgeId); + + BizPresetComment updateComment(Long id, CreatePresetCommentDto dto, Long judgeId); + + void removeComment(Long id, Long judgeId); + + void batchDelete(List ids, Long judgeId); + + void incrementUseCount(Long id, Long judgeId); + + List> getJudgeContests(Long judgeId); + + Map syncComments(Long sourceContestId, List targetContestIds, Long judgeId); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestJudgeServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestJudgeServiceImpl.java new file mode 100644 index 0000000..d132871 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestJudgeServiceImpl.java @@ -0,0 +1,150 @@ +package com.competition.modules.biz.review.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.modules.biz.review.entity.BizContestJudge; +import com.competition.modules.biz.review.mapper.ContestJudgeMapper; +import com.competition.modules.biz.review.service.IContestJudgeService; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.mapper.SysUserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ContestJudgeServiceImpl extends ServiceImpl implements IContestJudgeService { + + private final ContestJudgeMapper contestJudgeMapper; + private final SysUserMapper sysUserMapper; + + @Override + public BizContestJudge createJudge(Long contestId, Long judgeId, String specialty, BigDecimal weight, String description) { + log.info("添加评委,赛事ID:{},评委用户ID:{}", contestId, judgeId); + + // 校验是否重复 + LambdaQueryWrapper dupWrapper = new LambdaQueryWrapper<>(); + dupWrapper.eq(BizContestJudge::getContestId, contestId); + dupWrapper.eq(BizContestJudge::getJudgeId, judgeId); + dupWrapper.eq(BizContestJudge::getValidState, 1); + if (count(dupWrapper) > 0) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "该评委已添加到此赛事"); + } + + BizContestJudge entity = new BizContestJudge(); + entity.setContestId(contestId); + entity.setJudgeId(judgeId); + entity.setSpecialty(specialty); + entity.setWeight(weight != null ? weight : BigDecimal.ONE); + entity.setDescription(description); + + save(entity); + log.info("评委添加成功,ID:{}", entity.getId()); + return entity; + } + + @Override + public List> findByContest(Long contestId) { + log.info("查询赛事评委列表,赛事ID:{}", contestId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestJudge::getContestId, contestId); + wrapper.eq(BizContestJudge::getValidState, 1); + wrapper.orderByAsc(BizContestJudge::getCreateTime); + + List judges = contestJudgeMapper.selectList(wrapper); + + // 批量查询用户信息 + Set userIds = judges.stream().map(BizContestJudge::getJudgeId).collect(Collectors.toSet()); + Map userMap = new HashMap<>(); + if (!userIds.isEmpty()) { + List users = sysUserMapper.selectBatchIds(userIds); + for (SysUser user : users) { + userMap.put(user.getId(), user); + } + } + + return judges.stream().map(j -> { + Map map = new LinkedHashMap<>(); + map.put("id", j.getId()); + map.put("contestId", j.getContestId()); + map.put("judgeId", j.getJudgeId()); + map.put("specialty", j.getSpecialty()); + map.put("weight", j.getWeight()); + map.put("description", j.getDescription()); + map.put("createTime", j.getCreateTime()); + + SysUser user = userMap.get(j.getJudgeId()); + if (user != null) { + map.put("judgeName", user.getNickname()); + map.put("judgeUsername", user.getUsername()); + } + return map; + }).collect(Collectors.toList()); + } + + @Override + public Map findDetail(Long id) { + log.info("查询评委详情,ID:{}", id); + + BizContestJudge entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评委记录不存在"); + } + + Map map = new LinkedHashMap<>(); + map.put("id", entity.getId()); + map.put("contestId", entity.getContestId()); + map.put("judgeId", entity.getJudgeId()); + map.put("specialty", entity.getSpecialty()); + map.put("weight", entity.getWeight()); + map.put("description", entity.getDescription()); + map.put("createTime", entity.getCreateTime()); + + SysUser user = sysUserMapper.selectById(entity.getJudgeId()); + if (user != null) { + map.put("judgeName", user.getNickname()); + map.put("judgeUsername", user.getUsername()); + } + + return map; + } + + @Override + public BizContestJudge updateJudge(Long id, String specialty, BigDecimal weight, String description) { + log.info("更新评委信息,ID:{}", id); + + BizContestJudge entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评委记录不存在"); + } + + if (specialty != null) { + entity.setSpecialty(specialty); + } + if (weight != null) { + entity.setWeight(weight); + } + if (description != null) { + entity.setDescription(description); + } + + updateById(entity); + log.info("评委信息更新成功,ID:{}", id); + return entity; + } + + @Override + public void removeJudge(Long id) { + log.info("删除评委,ID:{}", id); + removeById(id); + log.info("评委删除成功,ID:{}", id); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestResultServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestResultServiceImpl.java new file mode 100644 index 0000000..90ead8f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestResultServiceImpl.java @@ -0,0 +1,457 @@ +package com.competition.modules.biz.review.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.contest.entity.BizContest; +import com.competition.modules.biz.contest.entity.BizContestWork; +import com.competition.modules.biz.contest.mapper.ContestMapper; +import com.competition.modules.biz.contest.mapper.ContestWorkMapper; +import com.competition.modules.biz.review.dto.AutoSetAwardsDto; +import com.competition.modules.biz.review.dto.BatchSetAwardsDto; +import com.competition.modules.biz.review.dto.SetAwardDto; +import com.competition.modules.biz.review.entity.BizContestJudge; +import com.competition.modules.biz.review.entity.BizContestReviewRule; +import com.competition.modules.biz.review.entity.BizContestWorkScore; +import com.competition.modules.biz.review.mapper.ContestJudgeMapper; +import com.competition.modules.biz.review.mapper.ContestReviewRuleMapper; +import com.competition.modules.biz.review.mapper.ContestWorkScoreMapper; +import com.competition.modules.biz.review.service.IContestResultService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ContestResultServiceImpl implements IContestResultService { + + private final ContestWorkMapper workMapper; + private final ContestMapper contestMapper; + private final ContestReviewRuleMapper reviewRuleMapper; + private final ContestWorkScoreMapper scoreMapper; + private final ContestJudgeMapper judgeMapper; + + @Override + public Map calculateAllFinalScores(Long contestId) { + log.info("批量计算终分,赛事ID:{}", contestId); + + BizContest contest = contestMapper.selectById(contestId); + if (contest == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在"); + } + + // 获取计算规则 + String calculationRule = "average"; + if (contest.getReviewRuleId() != null) { + BizContestReviewRule rule = reviewRuleMapper.selectById(contest.getReviewRuleId()); + if (rule != null && StringUtils.hasText(rule.getCalculationRule())) { + calculationRule = rule.getCalculationRule(); + } + } + + // 获取评委权重 + LambdaQueryWrapper judgeWrapper = new LambdaQueryWrapper<>(); + judgeWrapper.eq(BizContestJudge::getContestId, contestId); + judgeWrapper.eq(BizContestJudge::getValidState, 1); + List judges = judgeMapper.selectList(judgeWrapper); + Map weightMap = new HashMap<>(); + for (BizContestJudge j : judges) { + weightMap.put(j.getJudgeId(), j.getWeight() != null ? j.getWeight() : BigDecimal.ONE); + } + + // 获取所有最新有效作品 + LambdaQueryWrapper workWrapper = new LambdaQueryWrapper<>(); + workWrapper.eq(BizContestWork::getContestId, contestId); + workWrapper.eq(BizContestWork::getIsLatest, true); + workWrapper.eq(BizContestWork::getValidState, 1); + List works = workMapper.selectList(workWrapper); + + // 获取所有评分记录 + LambdaQueryWrapper scoreWrapper = new LambdaQueryWrapper<>(); + scoreWrapper.eq(BizContestWorkScore::getContestId, contestId); + scoreWrapper.eq(BizContestWorkScore::getValidState, 1); + List allScores = scoreMapper.selectList(scoreWrapper); + + // 按作品分组 + Map> scoresByWork = allScores.stream() + .collect(Collectors.groupingBy(BizContestWorkScore::getWorkId)); + + int calculatedCount = 0; + + for (BizContestWork work : works) { + List workScores = scoresByWork.get(work.getId()); + if (workScores == null || workScores.isEmpty()) { + continue; + } + + BigDecimal finalScore = doCalculate(workScores, calculationRule, weightMap); + if (finalScore != null) { + work.setFinalScore(finalScore); + workMapper.updateById(work); + calculatedCount++; + } + } + + log.info("批量计算终分完成,赛事ID:{},计算数量:{}", contestId, calculatedCount); + + Map result = new LinkedHashMap<>(); + result.put("calculatedCount", calculatedCount); + result.put("calculationRule", calculationRule); + return result; + } + + @Override + public Map calculateRankings(Long contestId) { + log.info("计算排名,赛事ID:{}", contestId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestWork::getContestId, contestId); + wrapper.eq(BizContestWork::getIsLatest, true); + wrapper.eq(BizContestWork::getValidState, 1); + wrapper.isNotNull(BizContestWork::getFinalScore); + wrapper.orderByDesc(BizContestWork::getFinalScore); + + List works = workMapper.selectList(wrapper); + + // 密集排名:相同分数 = 相同名次 + int currentRank = 1; + BigDecimal previousScore = null; + for (int i = 0; i < works.size(); i++) { + BigDecimal score = works.get(i).getFinalScore(); + if (previousScore == null || score.compareTo(previousScore) != 0) { + currentRank = i + 1; + } + works.get(i).setRank(currentRank); + previousScore = score; + } + + // 批量更新排名 + for (BizContestWork work : works) { + workMapper.updateById(work); + } + + log.info("排名计算完成,赛事ID:{},排名数量:{}", contestId, works.size()); + + Map result = new LinkedHashMap<>(); + result.put("rankedCount", works.size()); + return result; + } + + @Override + public void setAward(Long workId, SetAwardDto dto) { + log.info("设置奖项,作品ID:{}", workId); + + BizContestWork work = workMapper.selectById(workId); + if (work == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作品不存在"); + } + + work.setAwardLevel(dto.getAwardLevel()); + work.setAwardName(dto.getAwardName()); + work.setCertificateUrl(dto.getCertificateUrl()); + workMapper.updateById(work); + + log.info("奖项设置成功,作品ID:{},等级:{}", workId, dto.getAwardLevel()); + } + + @Override + public Map batchSetAwards(Long contestId, BatchSetAwardsDto dto) { + log.info("批量设置奖项,赛事ID:{},数量:{}", contestId, dto.getAwards().size()); + + int updated = 0; + for (BatchSetAwardsDto.AwardItem item : dto.getAwards()) { + BizContestWork work = workMapper.selectById(item.getWorkId()); + if (work != null && work.getContestId().equals(contestId)) { + work.setAwardLevel(item.getAwardLevel()); + work.setAwardName(item.getAwardName()); + workMapper.updateById(work); + updated++; + } + } + + log.info("批量设置奖项完成,更新数量:{}", updated); + + Map result = new LinkedHashMap<>(); + result.put("updatedCount", updated); + return result; + } + + @Override + public Map autoSetAwards(Long contestId, AutoSetAwardsDto dto) { + log.info("自动设置奖项,赛事ID:{}", contestId); + + // 1. 获取已排名作品,按排名排序 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestWork::getContestId, contestId); + wrapper.eq(BizContestWork::getIsLatest, true); + wrapper.eq(BizContestWork::getValidState, 1); + wrapper.isNotNull(BizContestWork::getRank); + wrapper.orderByAsc(BizContestWork::getRank); + + List rankedWorks = workMapper.selectList(wrapper); + + if (rankedWorks.isEmpty()) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "没有已排名的作品,请先计算排名"); + } + + // 2. 展开奖项配置 + List awardSlots = new ArrayList<>(); + List tiers = dto.getAwards(); + for (int tierIndex = 0; tierIndex < tiers.size(); tierIndex++) { + AutoSetAwardsDto.AwardTier tier = tiers.get(tierIndex); + for (int j = 0; j < tier.getCount(); j++) { + awardSlots.add(tier.getName()); + } + } + + // 3. 按序分配 + int assignedCount = 0; + int tierIndex = 0; + int slotIndex = 0; + for (int i = 0; i < Math.min(rankedWorks.size(), awardSlots.size()); i++) { + BizContestWork work = rankedWorks.get(i); + + // 确定当前tierIndex + int accumulated = 0; + for (int t = 0; t < tiers.size(); t++) { + accumulated += tiers.get(t).getCount(); + if (i < accumulated) { + tierIndex = t; + break; + } + } + + work.setAwardName(awardSlots.get(i)); + work.setAwardLevel("tier_" + tierIndex); + workMapper.updateById(work); + assignedCount++; + } + + log.info("自动设置奖项完成,分配数量:{}", assignedCount); + + Map result = new LinkedHashMap<>(); + result.put("assignedCount", assignedCount); + result.put("awards", dto.getAwards()); + return result; + } + + @Override + public void publishResults(Long contestId) { + log.info("发布赛果,赛事ID:{}", contestId); + + BizContest contest = contestMapper.selectById(contestId); + if (contest == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在"); + } + + // 验证有排名作品 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestWork::getContestId, contestId); + wrapper.eq(BizContestWork::getIsLatest, true); + wrapper.eq(BizContestWork::getValidState, 1); + wrapper.isNotNull(BizContestWork::getRank); + long rankedCount = workMapper.selectCount(wrapper); + + if (rankedCount == 0) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "没有已排名的作品,请先计算排名"); + } + + contest.setResultState("published"); + contest.setResultPublishTime(LocalDateTime.now()); + contest.setStatus("finished"); + contestMapper.updateById(contest); + + log.info("赛果发布成功,赛事ID:{}", contestId); + } + + @Override + public void unpublishResults(Long contestId) { + log.info("撤回赛果,赛事ID:{}", contestId); + + BizContest contest = contestMapper.selectById(contestId); + if (contest == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在"); + } + + if (!"published".equals(contest.getResultState())) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "赛果未发布,无法撤回"); + } + + contest.setResultState("unpublished"); + contest.setResultPublishTime(null); + contestMapper.updateById(contest); + + log.info("赛果撤回成功,赛事ID:{}", contestId); + } + + @Override + public PageResult> getResults(Long contestId, Long page, Long pageSize, String workNo, String accountNo) { + log.info("查询赛果列表,赛事ID:{},页码:{}", contestId, page); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestWork::getContestId, contestId); + wrapper.eq(BizContestWork::getIsLatest, true); + wrapper.eq(BizContestWork::getValidState, 1); + wrapper.isNotNull(BizContestWork::getFinalScore); + + if (StringUtils.hasText(workNo)) { + wrapper.like(BizContestWork::getWorkNo, workNo); + } + if (StringUtils.hasText(accountNo)) { + wrapper.like(BizContestWork::getSubmitterAccountNo, accountNo); + } + + wrapper.orderByDesc(BizContestWork::getFinalScore); + + Page pageObj = new Page<>(page, pageSize); + Page result = workMapper.selectPage(pageObj, wrapper); + + List> voList = result.getRecords().stream().map(w -> { + Map map = new LinkedHashMap<>(); + map.put("workId", w.getId()); + map.put("workNo", w.getWorkNo()); + map.put("title", w.getTitle()); + map.put("submitterAccountNo", w.getSubmitterAccountNo()); + map.put("previewUrl", w.getPreviewUrl()); + map.put("finalScore", w.getFinalScore()); + map.put("rank", w.getRank()); + map.put("awardLevel", w.getAwardLevel()); + map.put("awardName", w.getAwardName()); + map.put("certificateUrl", w.getCertificateUrl()); + map.put("submitTime", w.getSubmitTime()); + return map; + }).collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + @Override + public Map getResultsSummary(Long contestId) { + log.info("查询赛果概览,赛事ID:{}", contestId); + + LambdaQueryWrapper baseWrapper = new LambdaQueryWrapper<>(); + baseWrapper.eq(BizContestWork::getContestId, contestId); + baseWrapper.eq(BizContestWork::getIsLatest, true); + baseWrapper.eq(BizContestWork::getValidState, 1); + long totalWorks = workMapper.selectCount(baseWrapper); + + LambdaQueryWrapper scoredWrapper = new LambdaQueryWrapper<>(); + scoredWrapper.eq(BizContestWork::getContestId, contestId); + scoredWrapper.eq(BizContestWork::getIsLatest, true); + scoredWrapper.eq(BizContestWork::getValidState, 1); + scoredWrapper.isNotNull(BizContestWork::getFinalScore); + long scoredWorks = workMapper.selectCount(scoredWrapper); + + LambdaQueryWrapper rankedWrapper = new LambdaQueryWrapper<>(); + rankedWrapper.eq(BizContestWork::getContestId, contestId); + rankedWrapper.eq(BizContestWork::getIsLatest, true); + rankedWrapper.eq(BizContestWork::getValidState, 1); + rankedWrapper.isNotNull(BizContestWork::getRank); + long rankedWorks = workMapper.selectCount(rankedWrapper); + + LambdaQueryWrapper awardedWrapper = new LambdaQueryWrapper<>(); + awardedWrapper.eq(BizContestWork::getContestId, contestId); + awardedWrapper.eq(BizContestWork::getIsLatest, true); + awardedWrapper.eq(BizContestWork::getValidState, 1); + awardedWrapper.isNotNull(BizContestWork::getAwardLevel); + long awardedWorks = workMapper.selectCount(awardedWrapper); + + // 分数统计 + LambdaQueryWrapper scoreStatsWrapper = new LambdaQueryWrapper<>(); + scoreStatsWrapper.eq(BizContestWork::getContestId, contestId); + scoreStatsWrapper.eq(BizContestWork::getIsLatest, true); + scoreStatsWrapper.eq(BizContestWork::getValidState, 1); + scoreStatsWrapper.isNotNull(BizContestWork::getFinalScore); + scoreStatsWrapper.orderByDesc(BizContestWork::getFinalScore); + List scoredList = workMapper.selectList(scoreStatsWrapper); + + BigDecimal avgScore = BigDecimal.ZERO; + BigDecimal maxScore = BigDecimal.ZERO; + BigDecimal minScore = BigDecimal.ZERO; + + if (!scoredList.isEmpty()) { + BigDecimal sum = scoredList.stream() + .map(BizContestWork::getFinalScore) + .reduce(BigDecimal.ZERO, BigDecimal::add); + avgScore = sum.divide(BigDecimal.valueOf(scoredList.size()), 2, RoundingMode.HALF_UP); + maxScore = scoredList.get(0).getFinalScore(); + minScore = scoredList.get(scoredList.size() - 1).getFinalScore(); + } + + // 奖项分布 + Map awardDistribution = new LinkedHashMap<>(); + for (BizContestWork w : scoredList) { + if (w.getAwardName() != null) { + awardDistribution.merge(w.getAwardName(), 1L, Long::sum); + } + } + + Map result = new LinkedHashMap<>(); + result.put("totalWorks", totalWorks); + result.put("scoredWorks", scoredWorks); + result.put("rankedWorks", rankedWorks); + result.put("awardedWorks", awardedWorks); + result.put("avgScore", avgScore); + result.put("maxScore", maxScore); + result.put("minScore", minScore); + result.put("awardDistribution", awardDistribution); + return result; + } + + // ====== 私有辅助方法 ====== + + private BigDecimal doCalculate(List scores, String calculationRule, Map weightMap) { + List scoreValues = scores.stream() + .map(BizContestWorkScore::getTotalScore) + .sorted() + .collect(Collectors.toList()); + + switch (calculationRule) { + case "max": + return Collections.max(scoreValues); + case "min": + return Collections.min(scoreValues); + case "weighted": + BigDecimal weightedSum = BigDecimal.ZERO; + BigDecimal totalWeight = BigDecimal.ZERO; + for (BizContestWorkScore s : scores) { + BigDecimal w = weightMap.getOrDefault(s.getJudgeId(), BigDecimal.ONE); + weightedSum = weightedSum.add(s.getTotalScore().multiply(w)); + totalWeight = totalWeight.add(w); + } + return totalWeight.compareTo(BigDecimal.ZERO) > 0 + ? weightedSum.divide(totalWeight, 2, RoundingMode.HALF_UP) + : BigDecimal.ZERO; + case "remove_max_min": + if (scoreValues.size() < 3) { + // 不足3个时回退到平均 + BigDecimal sum = scoreValues.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + return sum.divide(BigDecimal.valueOf(scoreValues.size()), 2, RoundingMode.HALF_UP); + } + List trimmed = scoreValues.subList(1, scoreValues.size() - 1); + BigDecimal trimmedSum = trimmed.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + return trimmedSum.divide(BigDecimal.valueOf(trimmed.size()), 2, RoundingMode.HALF_UP); + case "remove_min": + if (scoreValues.size() < 2) { + return scoreValues.get(0); + } + List withoutMin = scoreValues.subList(1, scoreValues.size()); + BigDecimal withoutMinSum = withoutMin.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + return withoutMinSum.divide(BigDecimal.valueOf(withoutMin.size()), 2, RoundingMode.HALF_UP); + case "average": + default: + BigDecimal sum = scoreValues.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + return sum.divide(BigDecimal.valueOf(scoreValues.size()), 2, RoundingMode.HALF_UP); + } + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestReviewRuleServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestReviewRuleServiceImpl.java new file mode 100644 index 0000000..beb3c23 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestReviewRuleServiceImpl.java @@ -0,0 +1,149 @@ +package com.competition.modules.biz.review.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.review.dto.CreateReviewRuleDto; +import com.competition.modules.biz.review.entity.BizContestReviewRule; +import com.competition.modules.biz.review.mapper.ContestReviewRuleMapper; +import com.competition.modules.biz.review.service.IContestReviewRuleService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ContestReviewRuleServiceImpl extends ServiceImpl implements IContestReviewRuleService { + + private final ContestReviewRuleMapper contestReviewRuleMapper; + + @Override + public BizContestReviewRule createRule(CreateReviewRuleDto dto, Long tenantId) { + log.info("创建评审规则,名称:{},租户:{}", dto.getRuleName(), tenantId); + + BizContestReviewRule entity = new BizContestReviewRule(); + entity.setTenantId(tenantId); + entity.setRuleName(dto.getRuleName()); + entity.setRuleDescription(dto.getRuleDescription()); + entity.setJudgeCount(dto.getJudgeCount()); + entity.setDimensions(dto.getDimensions()); + entity.setCalculationRule(dto.getCalculationRule()); + + save(entity); + log.info("评审规则创建成功,ID:{}", entity.getId()); + return entity; + } + + @Override + public PageResult> findAll(Long page, Long pageSize, String ruleName, Long tenantId) { + log.info("查询评审规则列表,页码:{},每页:{}", page, pageSize); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestReviewRule::getValidState, 1); + + if (tenantId != null) { + wrapper.eq(BizContestReviewRule::getTenantId, tenantId); + } + if (StringUtils.hasText(ruleName)) { + wrapper.like(BizContestReviewRule::getRuleName, ruleName); + } + + wrapper.orderByDesc(BizContestReviewRule::getCreateTime); + + Page pageObj = new Page<>(page, pageSize); + Page result = contestReviewRuleMapper.selectPage(pageObj, wrapper); + + List> voList = result.getRecords().stream() + .map(this::entityToMap) + .collect(Collectors.toList()); + + return PageResult.from(result, voList); + } + + @Override + public List> findAllForSelect(Long tenantId) { + log.info("查询评审规则下拉列表,租户:{}", tenantId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestReviewRule::getValidState, 1); + if (tenantId != null) { + wrapper.eq(BizContestReviewRule::getTenantId, tenantId); + } + wrapper.orderByDesc(BizContestReviewRule::getCreateTime); + + List list = contestReviewRuleMapper.selectList(wrapper); + return list.stream().map(this::entityToMap).collect(Collectors.toList()); + } + + @Override + public Map findDetail(Long id) { + log.info("查询评审规则详情,ID:{}", id); + + BizContestReviewRule entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评审规则不存在"); + } + return entityToMap(entity); + } + + @Override + public BizContestReviewRule updateRule(Long id, CreateReviewRuleDto dto) { + log.info("更新评审规则,ID:{}", id); + + BizContestReviewRule entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评审规则不存在"); + } + + if (StringUtils.hasText(dto.getRuleName())) { + entity.setRuleName(dto.getRuleName()); + } + if (dto.getRuleDescription() != null) { + entity.setRuleDescription(dto.getRuleDescription()); + } + if (dto.getJudgeCount() != null) { + entity.setJudgeCount(dto.getJudgeCount()); + } + if (dto.getDimensions() != null) { + entity.setDimensions(dto.getDimensions()); + } + if (StringUtils.hasText(dto.getCalculationRule())) { + entity.setCalculationRule(dto.getCalculationRule()); + } + + updateById(entity); + log.info("评审规则更新成功,ID:{}", id); + return entity; + } + + @Override + public void removeRule(Long id) { + log.info("删除评审规则,ID:{}", id); + removeById(id); + log.info("评审规则删除成功,ID:{}", id); + } + + // ====== 私有辅助方法 ====== + + private Map entityToMap(BizContestReviewRule entity) { + Map map = new LinkedHashMap<>(); + map.put("id", entity.getId()); + map.put("tenantId", entity.getTenantId()); + map.put("ruleName", entity.getRuleName()); + map.put("ruleDescription", entity.getRuleDescription()); + map.put("judgeCount", entity.getJudgeCount()); + map.put("dimensions", entity.getDimensions()); + map.put("calculationRule", entity.getCalculationRule()); + map.put("createTime", entity.getCreateTime()); + map.put("modifyTime", entity.getModifyTime()); + return map; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestReviewServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestReviewServiceImpl.java new file mode 100644 index 0000000..552c931 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/ContestReviewServiceImpl.java @@ -0,0 +1,648 @@ +package com.competition.modules.biz.review.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.contest.entity.BizContest; +import com.competition.modules.biz.contest.entity.BizContestWork; +import com.competition.modules.biz.contest.mapper.ContestMapper; +import com.competition.modules.biz.contest.mapper.ContestWorkMapper; +import com.competition.modules.biz.review.dto.CreateScoreDto; +import com.competition.modules.biz.review.entity.BizContestJudge; +import com.competition.modules.biz.review.entity.BizContestReviewRule; +import com.competition.modules.biz.review.entity.BizContestWorkJudgeAssignment; +import com.competition.modules.biz.review.entity.BizContestWorkScore; +import com.competition.modules.biz.review.mapper.ContestJudgeMapper; +import com.competition.modules.biz.review.mapper.ContestReviewRuleMapper; +import com.competition.modules.biz.review.mapper.ContestWorkJudgeAssignmentMapper; +import com.competition.modules.biz.review.mapper.ContestWorkScoreMapper; +import com.competition.modules.biz.review.service.IContestReviewService; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.mapper.SysUserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ContestReviewServiceImpl implements IContestReviewService { + + private final ContestWorkJudgeAssignmentMapper assignmentMapper; + private final ContestWorkScoreMapper scoreMapper; + private final ContestJudgeMapper judgeMapper; + private final ContestWorkMapper workMapper; + private final ContestMapper contestMapper; + private final ContestReviewRuleMapper reviewRuleMapper; + private final SysUserMapper sysUserMapper; + + // ====== 作品分配 ====== + + @Override + public Map assignWork(Long contestId, Long workId, List judgeIds, Long creatorId) { + log.info("分配作品,赛事ID:{},作品ID:{},评委数:{}", contestId, workId, judgeIds.size()); + + int created = 0; + int skipped = 0; + + for (Long judgeId : judgeIds) { + // 检查是否已分配 + LambdaQueryWrapper dupWrapper = new LambdaQueryWrapper<>(); + dupWrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId); + dupWrapper.eq(BizContestWorkJudgeAssignment::getWorkId, workId); + dupWrapper.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId); + if (assignmentMapper.selectCount(dupWrapper) > 0) { + skipped++; + continue; + } + + BizContestWorkJudgeAssignment assignment = new BizContestWorkJudgeAssignment(); + assignment.setContestId(contestId); + assignment.setWorkId(workId); + assignment.setJudgeId(judgeId); + assignment.setAssignmentTime(LocalDateTime.now()); + assignment.setStatus("assigned"); + assignment.setCreator(creatorId != null ? creatorId.intValue() : null); + assignment.setCreateTime(LocalDateTime.now()); + + assignmentMapper.insert(assignment); + created++; + } + + log.info("作品分配完成,新建:{},跳过:{}", created, skipped); + + Map result = new LinkedHashMap<>(); + result.put("workId", workId); + result.put("created", created); + result.put("skipped", skipped); + return result; + } + + @Override + public Map batchAssignWorks(Long contestId, List workIds, List judgeIds, Long creatorId) { + log.info("批量分配作品,赛事ID:{},作品数:{},评委数:{}", contestId, workIds.size(), judgeIds.size()); + + int totalCreated = 0; + int totalSkipped = 0; + + for (Long workId : workIds) { + Map single = assignWork(contestId, workId, judgeIds, creatorId); + totalCreated += (int) single.get("created"); + totalSkipped += (int) single.get("skipped"); + } + + Map result = new LinkedHashMap<>(); + result.put("worksCount", workIds.size()); + result.put("created", totalCreated); + result.put("skipped", totalSkipped); + return result; + } + + @Override + public Map autoAssignWorks(Long contestId, Long creatorId) { + log.info("自动分配作品,赛事ID:{}", contestId); + + // 1. 获取所有未分配的作品 + LambdaQueryWrapper workWrapper = new LambdaQueryWrapper<>(); + workWrapper.eq(BizContestWork::getContestId, contestId); + workWrapper.eq(BizContestWork::getIsLatest, true); + workWrapper.eq(BizContestWork::getValidState, 1); + workWrapper.notInSql(BizContestWork::getId, + "SELECT work_id FROM t_biz_contest_work_judge_assignment WHERE contest_id = " + contestId); + List unassignedWorks = workMapper.selectList(workWrapper); + + if (unassignedWorks.isEmpty()) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "没有需要分配的作品"); + } + + // 2. 获取赛事所有评委 + LambdaQueryWrapper judgeWrapper = new LambdaQueryWrapper<>(); + judgeWrapper.eq(BizContestJudge::getContestId, contestId); + judgeWrapper.eq(BizContestJudge::getValidState, 1); + List judges = judgeMapper.selectList(judgeWrapper); + + if (judges.isEmpty()) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "赛事尚未配置评委"); + } + + // 3. 轮询分配:每个作品分配给3个评委 + int judgesPerWork = 3; + int judgeIndex = 0; + int created = 0; + + for (BizContestWork work : unassignedWorks) { + for (int i = 0; i < judgesPerWork && i < judges.size(); i++) { + BizContestJudge judge = judges.get((judgeIndex + i) % judges.size()); + + BizContestWorkJudgeAssignment assignment = new BizContestWorkJudgeAssignment(); + assignment.setContestId(contestId); + assignment.setWorkId(work.getId()); + assignment.setJudgeId(judge.getJudgeId()); + assignment.setAssignmentTime(LocalDateTime.now()); + assignment.setStatus("assigned"); + assignment.setCreator(creatorId != null ? creatorId.intValue() : null); + assignment.setCreateTime(LocalDateTime.now()); + + assignmentMapper.insert(assignment); + created++; + } + judgeIndex = (judgeIndex + 1) % judges.size(); + } + + log.info("自动分配完成,作品数:{},分配记录数:{}", unassignedWorks.size(), created); + + Map result = new LinkedHashMap<>(); + result.put("worksCount", unassignedWorks.size()); + result.put("created", created); + result.put("judgesPerWork", Math.min(judgesPerWork, judges.size())); + return result; + } + + // ====== 评分 ====== + + @Override + public Map score(CreateScoreDto dto, Long judgeId, Long tenantId) { + log.info("评委打分,评委ID:{},作品ID:{},分配ID:{}", judgeId, dto.getWorkId(), dto.getAssignmentId()); + + // 1. 验证分配记录 + BizContestWorkJudgeAssignment assignment = assignmentMapper.selectById(dto.getAssignmentId()); + if (assignment == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "分配记录不存在"); + } + if (!assignment.getJudgeId().equals(judgeId)) { + throw BusinessException.of(ErrorCode.FORBIDDEN, "无权对此作品评分"); + } + if (!assignment.getWorkId().equals(dto.getWorkId())) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "作品ID与分配记录不匹配"); + } + + // 2. 检查是否已评分 + LambdaQueryWrapper existWrapper = new LambdaQueryWrapper<>(); + existWrapper.eq(BizContestWorkScore::getAssignmentId, dto.getAssignmentId()); + existWrapper.eq(BizContestWorkScore::getValidState, 1); + if (scoreMapper.selectCount(existWrapper) > 0) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "该分配记录已评分,请勿重复提交"); + } + + // 3. 获取评委姓名 + SysUser judgeUser = sysUserMapper.selectById(judgeId); + String judgeName = judgeUser != null ? judgeUser.getNickname() : ""; + + // 4. 创建评分记录 + BizContestWorkScore scoreEntity = new BizContestWorkScore(); + scoreEntity.setTenantId(tenantId); + scoreEntity.setContestId(assignment.getContestId()); + scoreEntity.setWorkId(dto.getWorkId()); + scoreEntity.setAssignmentId(dto.getAssignmentId()); + scoreEntity.setJudgeId(judgeId); + scoreEntity.setJudgeName(judgeName); + scoreEntity.setDimensionScores(dto.getDimensionScores()); + scoreEntity.setTotalScore(dto.getTotalScore()); + scoreEntity.setComments(dto.getComments()); + scoreEntity.setScoreTime(LocalDateTime.now()); + + scoreMapper.insert(scoreEntity); + + // 5. 更新分配状态为已完成 + assignment.setStatus("completed"); + assignment.setModifyTime(LocalDateTime.now()); + assignmentMapper.updateById(assignment); + + // 6. 更新作品状态为评审中 + BizContestWork work = workMapper.selectById(dto.getWorkId()); + if (work != null && !"reviewing".equals(work.getStatus())) { + work.setStatus("reviewing"); + workMapper.updateById(work); + } + + log.info("评分成功,评分ID:{}", scoreEntity.getId()); + + Map result = new LinkedHashMap<>(); + result.put("scoreId", scoreEntity.getId()); + result.put("totalScore", scoreEntity.getTotalScore()); + return result; + } + + @Override + public Map updateScore(Long scoreId, CreateScoreDto dto, Long judgeId) { + log.info("更新评分,评分ID:{},评委ID:{}", scoreId, judgeId); + + BizContestWorkScore scoreEntity = scoreMapper.selectById(scoreId); + if (scoreEntity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "评分记录不存在"); + } + if (!scoreEntity.getJudgeId().equals(judgeId)) { + throw BusinessException.of(ErrorCode.FORBIDDEN, "无权修改此评分"); + } + + if (dto.getDimensionScores() != null) { + scoreEntity.setDimensionScores(dto.getDimensionScores()); + } + if (dto.getTotalScore() != null) { + scoreEntity.setTotalScore(dto.getTotalScore()); + } + if (dto.getComments() != null) { + scoreEntity.setComments(dto.getComments()); + } + scoreEntity.setScoreTime(LocalDateTime.now()); + + scoreMapper.updateById(scoreEntity); + log.info("评分更新成功,评分ID:{}", scoreId); + + Map result = new LinkedHashMap<>(); + result.put("scoreId", scoreEntity.getId()); + result.put("totalScore", scoreEntity.getTotalScore()); + return result; + } + + // ====== 评委视角查询 ====== + + @Override + public List> getAssignedWorks(Long judgeId, Long contestId) { + log.info("查询评委已分配作品,评委ID:{},赛事ID:{}", judgeId, contestId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId); + wrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId); + wrapper.orderByAsc(BizContestWorkJudgeAssignment::getAssignmentTime); + + List assignments = assignmentMapper.selectList(wrapper); + + // 批量查询作品信息 + Set workIds = assignments.stream().map(BizContestWorkJudgeAssignment::getWorkId).collect(Collectors.toSet()); + Map workMap = new HashMap<>(); + if (!workIds.isEmpty()) { + List works = workMapper.selectBatchIds(workIds); + for (BizContestWork w : works) { + workMap.put(w.getId(), w); + } + } + + return assignments.stream().map(a -> { + Map map = new LinkedHashMap<>(); + map.put("assignmentId", a.getId()); + map.put("workId", a.getWorkId()); + map.put("status", a.getStatus()); + map.put("assignmentTime", a.getAssignmentTime()); + + BizContestWork work = workMap.get(a.getWorkId()); + if (work != null) { + map.put("workNo", work.getWorkNo()); + map.put("title", work.getTitle()); + map.put("previewUrl", work.getPreviewUrl()); + map.put("previewUrls", work.getPreviewUrls()); + map.put("submitterAccountNo", work.getSubmitterAccountNo()); + } + return map; + }).collect(Collectors.toList()); + } + + @Override + public List> getJudgeContests(Long judgeId) { + log.info("查询评委关联赛事,评委ID:{}", judgeId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestJudge::getJudgeId, judgeId); + wrapper.eq(BizContestJudge::getValidState, 1); + + List judgeRecords = judgeMapper.selectList(wrapper); + Set contestIds = judgeRecords.stream().map(BizContestJudge::getContestId).collect(Collectors.toSet()); + + if (contestIds.isEmpty()) { + return Collections.emptyList(); + } + + List contests = contestMapper.selectBatchIds(contestIds); + return contests.stream().map(c -> { + Map map = new LinkedHashMap<>(); + map.put("contestId", c.getId()); + map.put("contestName", c.getContestName()); + map.put("contestState", c.getContestState()); + map.put("status", c.getStatus()); + map.put("reviewStartTime", c.getReviewStartTime()); + map.put("reviewEndTime", c.getReviewEndTime()); + return map; + }).collect(Collectors.toList()); + } + + @Override + public PageResult> getJudgeContestWorks(Long judgeId, Long contestId, Long page, Long pageSize, + String workNo, String accountNo, String reviewStatus) { + log.info("查询评委赛事作品,评委ID:{},赛事ID:{},页码:{}", judgeId, contestId, page); + + // 分页查询分配记录 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId); + wrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId); + + if (StringUtils.hasText(reviewStatus)) { + wrapper.eq(BizContestWorkJudgeAssignment::getStatus, reviewStatus); + } + + wrapper.orderByAsc(BizContestWorkJudgeAssignment::getAssignmentTime); + + Page pageObj = new Page<>(page, pageSize); + Page assignmentPage = assignmentMapper.selectPage(pageObj, wrapper); + + List assignments = assignmentPage.getRecords(); + + // 批量查询作品和评分 + Set workIds = assignments.stream().map(BizContestWorkJudgeAssignment::getWorkId).collect(Collectors.toSet()); + Map workMap = new HashMap<>(); + if (!workIds.isEmpty()) { + List works = workMapper.selectBatchIds(workIds); + for (BizContestWork w : works) { + workMap.put(w.getId(), w); + } + } + + Set assignmentIds = assignments.stream().map(BizContestWorkJudgeAssignment::getId).collect(Collectors.toSet()); + Map scoreMap = new HashMap<>(); + if (!assignmentIds.isEmpty()) { + LambdaQueryWrapper scoreWrapper = new LambdaQueryWrapper<>(); + scoreWrapper.in(BizContestWorkScore::getAssignmentId, assignmentIds); + scoreWrapper.eq(BizContestWorkScore::getValidState, 1); + List scores = scoreMapper.selectList(scoreWrapper); + for (BizContestWorkScore s : scores) { + scoreMap.put(s.getAssignmentId(), s); + } + } + + // 组装VO并应用筛选 + List> voList = new ArrayList<>(); + for (BizContestWorkJudgeAssignment a : assignments) { + BizContestWork work = workMap.get(a.getWorkId()); + if (work == null) continue; + + // workNo筛选 + if (StringUtils.hasText(workNo) && !work.getWorkNo().contains(workNo)) { + continue; + } + // accountNo筛选 + if (StringUtils.hasText(accountNo) && work.getSubmitterAccountNo() != null + && !work.getSubmitterAccountNo().contains(accountNo)) { + continue; + } + + Map map = new LinkedHashMap<>(); + map.put("assignmentId", a.getId()); + map.put("workId", a.getWorkId()); + map.put("status", a.getStatus()); + map.put("assignmentTime", a.getAssignmentTime()); + map.put("workNo", work.getWorkNo()); + map.put("title", work.getTitle()); + map.put("previewUrl", work.getPreviewUrl()); + map.put("previewUrls", work.getPreviewUrls()); + map.put("submitterAccountNo", work.getSubmitterAccountNo()); + + BizContestWorkScore scoreRecord = scoreMap.get(a.getId()); + if (scoreRecord != null) { + map.put("scoreId", scoreRecord.getId()); + map.put("totalScore", scoreRecord.getTotalScore()); + map.put("dimensionScores", scoreRecord.getDimensionScores()); + map.put("comments", scoreRecord.getComments()); + map.put("scoreTime", scoreRecord.getScoreTime()); + } + + voList.add(map); + } + + return PageResult.from(assignmentPage, voList); + } + + // ====== 评审进度 ====== + + @Override + public Map getReviewProgress(Long contestId) { + log.info("查询评审进度,赛事ID:{}", contestId); + + // 总作品数 + LambdaQueryWrapper workWrapper = new LambdaQueryWrapper<>(); + workWrapper.eq(BizContestWork::getContestId, contestId); + workWrapper.eq(BizContestWork::getIsLatest, true); + workWrapper.eq(BizContestWork::getValidState, 1); + long totalWorks = workMapper.selectCount(workWrapper); + + // 已分配作品数(去重) + LambdaQueryWrapper assignWrapper = new LambdaQueryWrapper<>(); + assignWrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId); + assignWrapper.select(BizContestWorkJudgeAssignment::getWorkId); + assignWrapper.groupBy(BizContestWorkJudgeAssignment::getWorkId); + long assignedWorks = assignmentMapper.selectList(assignWrapper).size(); + + // 总分配记录和已完成记录 + LambdaQueryWrapper allAssignWrapper = new LambdaQueryWrapper<>(); + allAssignWrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId); + long totalAssignments = assignmentMapper.selectCount(allAssignWrapper); + + LambdaQueryWrapper completedWrapper = new LambdaQueryWrapper<>(); + completedWrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId); + completedWrapper.eq(BizContestWorkJudgeAssignment::getStatus, "completed"); + long completedAssignments = assignmentMapper.selectCount(completedWrapper); + + // 每个评委的进度 + LambdaQueryWrapper judgeWrapper = new LambdaQueryWrapper<>(); + judgeWrapper.eq(BizContestJudge::getContestId, contestId); + judgeWrapper.eq(BizContestJudge::getValidState, 1); + List judges = judgeMapper.selectList(judgeWrapper); + + List> judgeProgress = new ArrayList<>(); + for (BizContestJudge judge : judges) { + LambdaQueryWrapper jAssignWrapper = new LambdaQueryWrapper<>(); + jAssignWrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId); + jAssignWrapper.eq(BizContestWorkJudgeAssignment::getJudgeId, judge.getJudgeId()); + long jTotal = assignmentMapper.selectCount(jAssignWrapper); + + LambdaQueryWrapper jCompletedWrapper = new LambdaQueryWrapper<>(); + jCompletedWrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId); + jCompletedWrapper.eq(BizContestWorkJudgeAssignment::getJudgeId, judge.getJudgeId()); + jCompletedWrapper.eq(BizContestWorkJudgeAssignment::getStatus, "completed"); + long jCompleted = assignmentMapper.selectCount(jCompletedWrapper); + + SysUser user = sysUserMapper.selectById(judge.getJudgeId()); + + Map jp = new LinkedHashMap<>(); + jp.put("judgeId", judge.getJudgeId()); + jp.put("judgeName", user != null ? user.getNickname() : ""); + jp.put("totalAssigned", jTotal); + jp.put("completed", jCompleted); + jp.put("pending", jTotal - jCompleted); + judgeProgress.add(jp); + } + + Map result = new LinkedHashMap<>(); + result.put("totalWorks", totalWorks); + result.put("assignedWorks", assignedWorks); + result.put("unassignedWorks", totalWorks - assignedWorks); + result.put("totalAssignments", totalAssignments); + result.put("completedAssignments", completedAssignments); + result.put("pendingAssignments", totalAssignments - completedAssignments); + result.put("judgeProgress", judgeProgress); + return result; + } + + @Override + public Map getWorkStatusStats(Long contestId) { + log.info("查询作品状态统计,赛事ID:{}", contestId); + + LambdaQueryWrapper baseWrapper = new LambdaQueryWrapper<>(); + baseWrapper.eq(BizContestWork::getContestId, contestId); + baseWrapper.eq(BizContestWork::getIsLatest, true); + baseWrapper.eq(BizContestWork::getValidState, 1); + long total = workMapper.selectCount(baseWrapper); + + String[] statuses = {"submitted", "locked", "reviewing", "rejected", "accepted"}; + Map result = new LinkedHashMap<>(); + result.put("total", total); + + for (String status : statuses) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestWork::getContestId, contestId); + wrapper.eq(BizContestWork::getIsLatest, true); + wrapper.eq(BizContestWork::getValidState, 1); + wrapper.eq(BizContestWork::getStatus, status); + result.put(status, workMapper.selectCount(wrapper)); + } + + return result; + } + + @Override + public List> getWorkScores(Long workId) { + log.info("查询作品评分列表,作品ID:{}", workId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestWorkScore::getWorkId, workId); + wrapper.eq(BizContestWorkScore::getValidState, 1); + wrapper.orderByAsc(BizContestWorkScore::getScoreTime); + + List scores = scoreMapper.selectList(wrapper); + + return scores.stream().map(s -> { + Map map = new LinkedHashMap<>(); + map.put("scoreId", s.getId()); + map.put("assignmentId", s.getAssignmentId()); + map.put("judgeId", s.getJudgeId()); + map.put("judgeName", s.getJudgeName()); + map.put("dimensionScores", s.getDimensionScores()); + map.put("totalScore", s.getTotalScore()); + map.put("comments", s.getComments()); + map.put("scoreTime", s.getScoreTime()); + return map; + }).collect(Collectors.toList()); + } + + // ====== 终分计算 ====== + + @Override + public Map calculateFinalScore(Long workId) { + log.info("计算作品终分,作品ID:{}", workId); + + // 1. 获取作品、赛事、评审规则 + BizContestWork work = workMapper.selectById(workId); + if (work == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "作品不存在"); + } + + BizContest contest = contestMapper.selectById(work.getContestId()); + if (contest == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在"); + } + + String calculationRule = "average"; // 默认 + if (contest.getReviewRuleId() != null) { + BizContestReviewRule rule = reviewRuleMapper.selectById(contest.getReviewRuleId()); + if (rule != null && StringUtils.hasText(rule.getCalculationRule())) { + calculationRule = rule.getCalculationRule(); + } + } + + // 2. 获取所有有效评分 + LambdaQueryWrapper scoreWrapper = new LambdaQueryWrapper<>(); + scoreWrapper.eq(BizContestWorkScore::getWorkId, workId); + scoreWrapper.eq(BizContestWorkScore::getValidState, 1); + List scores = scoreMapper.selectList(scoreWrapper); + + if (scores.isEmpty()) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "该作品尚无评分记录"); + } + + // 3. 获取评委权重 + LambdaQueryWrapper judgeWrapper = new LambdaQueryWrapper<>(); + judgeWrapper.eq(BizContestJudge::getContestId, work.getContestId()); + judgeWrapper.eq(BizContestJudge::getValidState, 1); + List judges = judgeMapper.selectList(judgeWrapper); + Map weightMap = new HashMap<>(); + for (BizContestJudge j : judges) { + weightMap.put(j.getJudgeId(), j.getWeight() != null ? j.getWeight() : BigDecimal.ONE); + } + + // 4. 计算终分 + List scoreValues = scores.stream() + .map(BizContestWorkScore::getTotalScore) + .sorted() + .collect(Collectors.toList()); + + BigDecimal finalScore; + + switch (calculationRule) { + case "max": + finalScore = Collections.max(scoreValues); + break; + case "min": + finalScore = Collections.min(scoreValues); + break; + case "weighted": + BigDecimal weightedSum = BigDecimal.ZERO; + BigDecimal totalWeight = BigDecimal.ZERO; + for (BizContestWorkScore s : scores) { + BigDecimal w = weightMap.getOrDefault(s.getJudgeId(), BigDecimal.ONE); + weightedSum = weightedSum.add(s.getTotalScore().multiply(w)); + totalWeight = totalWeight.add(w); + } + finalScore = totalWeight.compareTo(BigDecimal.ZERO) > 0 + ? weightedSum.divide(totalWeight, 2, RoundingMode.HALF_UP) + : BigDecimal.ZERO; + break; + case "remove_max_min": + if (scoreValues.size() < 3) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "去掉最高最低分需要至少3个评分"); + } + List trimmed = scoreValues.subList(1, scoreValues.size() - 1); + BigDecimal trimmedSum = trimmed.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + finalScore = trimmedSum.divide(BigDecimal.valueOf(trimmed.size()), 2, RoundingMode.HALF_UP); + break; + case "remove_min": + if (scoreValues.size() < 2) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "去掉最低分需要至少2个评分"); + } + List withoutMin = scoreValues.subList(1, scoreValues.size()); + BigDecimal withoutMinSum = withoutMin.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + finalScore = withoutMinSum.divide(BigDecimal.valueOf(withoutMin.size()), 2, RoundingMode.HALF_UP); + break; + case "average": + default: + BigDecimal sum = scoreValues.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + finalScore = sum.divide(BigDecimal.valueOf(scoreValues.size()), 2, RoundingMode.HALF_UP); + break; + } + + log.info("作品终分计算完成,作品ID:{},终分:{},规则:{}", workId, finalScore, calculationRule); + + Map result = new LinkedHashMap<>(); + result.put("workId", workId); + result.put("finalScore", finalScore); + result.put("scoreCount", scores.size()); + result.put("calculationRule", calculationRule); + return result; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/PresetCommentServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/PresetCommentServiceImpl.java new file mode 100644 index 0000000..bd26dde --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/impl/PresetCommentServiceImpl.java @@ -0,0 +1,225 @@ +package com.competition.modules.biz.review.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.modules.biz.review.dto.CreatePresetCommentDto; +import com.competition.modules.biz.review.entity.BizContestJudge; +import com.competition.modules.biz.review.entity.BizPresetComment; +import com.competition.modules.biz.review.mapper.ContestJudgeMapper; +import com.competition.modules.biz.review.mapper.PresetCommentMapper; +import com.competition.modules.biz.review.service.IPresetCommentService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PresetCommentServiceImpl extends ServiceImpl implements IPresetCommentService { + + private final PresetCommentMapper presetCommentMapper; + private final ContestJudgeMapper contestJudgeMapper; + + @Override + public BizPresetComment createComment(CreatePresetCommentDto dto, Long judgeId) { + log.info("创建预设评语,评委ID:{},赛事ID:{}", judgeId, dto.getContestId()); + + BizPresetComment entity = new BizPresetComment(); + entity.setContestId(dto.getContestId()); + entity.setJudgeId(judgeId); + entity.setContent(dto.getContent()); + entity.setScore(dto.getScore()); + entity.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : 0); + entity.setUseCount(0); + + save(entity); + log.info("预设评语创建成功,ID:{}", entity.getId()); + return entity; + } + + @Override + public List> findAll(Long contestId, Long judgeId) { + log.info("查询预设评语列表,赛事ID:{},评委ID:{}", contestId, judgeId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizPresetComment::getValidState, 1); + wrapper.eq(BizPresetComment::getJudgeId, judgeId); + if (contestId != null) { + wrapper.eq(BizPresetComment::getContestId, contestId); + } + wrapper.orderByAsc(BizPresetComment::getSortOrder); + wrapper.orderByDesc(BizPresetComment::getUseCount); + + List list = presetCommentMapper.selectList(wrapper); + return list.stream().map(this::entityToMap).collect(Collectors.toList()); + } + + @Override + public Map findDetail(Long id, Long judgeId) { + log.info("查询预设评语详情,ID:{},评委ID:{}", id, judgeId); + + BizPresetComment entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "预设评语不存在"); + } + if (!entity.getJudgeId().equals(judgeId)) { + throw BusinessException.of(ErrorCode.FORBIDDEN, "无权查看此评语"); + } + return entityToMap(entity); + } + + @Override + public BizPresetComment updateComment(Long id, CreatePresetCommentDto dto, Long judgeId) { + log.info("更新预设评语,ID:{},评委ID:{}", id, judgeId); + + BizPresetComment entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "预设评语不存在"); + } + if (!entity.getJudgeId().equals(judgeId)) { + throw BusinessException.of(ErrorCode.FORBIDDEN, "无权修改此评语"); + } + + if (dto.getContent() != null) { + entity.setContent(dto.getContent()); + } + if (dto.getScore() != null) { + entity.setScore(dto.getScore()); + } + if (dto.getSortOrder() != null) { + entity.setSortOrder(dto.getSortOrder()); + } + if (dto.getContestId() != null) { + entity.setContestId(dto.getContestId()); + } + + updateById(entity); + log.info("预设评语更新成功,ID:{}", id); + return entity; + } + + @Override + public void removeComment(Long id, Long judgeId) { + log.info("删除预设评语,ID:{},评委ID:{}", id, judgeId); + + BizPresetComment entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "预设评语不存在"); + } + if (!entity.getJudgeId().equals(judgeId)) { + throw BusinessException.of(ErrorCode.FORBIDDEN, "无权删除此评语"); + } + + removeById(id); + log.info("预设评语删除成功,ID:{}", id); + } + + @Override + public void batchDelete(List ids, Long judgeId) { + log.info("批量删除预设评语,数量:{},评委ID:{}", ids.size(), judgeId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(BizPresetComment::getId, ids); + wrapper.eq(BizPresetComment::getJudgeId, judgeId); + + remove(wrapper); + log.info("批量删除预设评语完成"); + } + + @Override + public void incrementUseCount(Long id, Long judgeId) { + log.info("增加预设评语使用次数,ID:{},评委ID:{}", id, judgeId); + + BizPresetComment entity = getById(id); + if (entity == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "预设评语不存在"); + } + if (!entity.getJudgeId().equals(judgeId)) { + throw BusinessException.of(ErrorCode.FORBIDDEN, "无权操作此评语"); + } + + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(BizPresetComment::getId, id); + updateWrapper.setSql("use_count = use_count + 1"); + update(updateWrapper); + } + + @Override + public List> getJudgeContests(Long judgeId) { + log.info("查询评委关联赛事(预设评语视角),评委ID:{}", judgeId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContestJudge::getJudgeId, judgeId); + wrapper.eq(BizContestJudge::getValidState, 1); + + List judgeRecords = contestJudgeMapper.selectList(wrapper); + + return judgeRecords.stream().map(j -> { + Map map = new LinkedHashMap<>(); + map.put("contestId", j.getContestId()); + return map; + }).collect(Collectors.toList()); + } + + @Override + public Map syncComments(Long sourceContestId, List targetContestIds, Long judgeId) { + log.info("同步预设评语,源赛事ID:{},目标赛事数:{},评委ID:{}", sourceContestId, targetContestIds.size(), judgeId); + + // 查询源赛事的评语 + LambdaQueryWrapper sourceWrapper = new LambdaQueryWrapper<>(); + sourceWrapper.eq(BizPresetComment::getContestId, sourceContestId); + sourceWrapper.eq(BizPresetComment::getJudgeId, judgeId); + sourceWrapper.eq(BizPresetComment::getValidState, 1); + List sourceComments = presetCommentMapper.selectList(sourceWrapper); + + if (sourceComments.isEmpty()) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "源赛事没有预设评语"); + } + + int created = 0; + for (Long targetContestId : targetContestIds) { + for (BizPresetComment source : sourceComments) { + BizPresetComment copy = new BizPresetComment(); + copy.setContestId(targetContestId); + copy.setJudgeId(judgeId); + copy.setContent(source.getContent()); + copy.setScore(source.getScore()); + copy.setSortOrder(source.getSortOrder()); + copy.setUseCount(0); + + save(copy); + created++; + } + } + + log.info("预设评语同步完成,新建数量:{}", created); + + Map result = new LinkedHashMap<>(); + result.put("sourceCount", sourceComments.size()); + result.put("targetContests", targetContestIds.size()); + result.put("createdCount", created); + return result; + } + + // ====== 私有辅助方法 ====== + + private Map entityToMap(BizPresetComment entity) { + Map map = new LinkedHashMap<>(); + map.put("id", entity.getId()); + map.put("contestId", entity.getContestId()); + map.put("judgeId", entity.getJudgeId()); + map.put("content", entity.getContent()); + map.put("score", entity.getScore()); + map.put("sortOrder", entity.getSortOrder()); + map.put("useCount", entity.getUseCount()); + map.put("createTime", entity.getCreateTime()); + map.put("modifyTime", entity.getModifyTime()); + return map; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/oss/config/OssConfig.java b/backend-java/src/main/java/com/competition/modules/oss/config/OssConfig.java new file mode 100644 index 0000000..975381d --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/oss/config/OssConfig.java @@ -0,0 +1,21 @@ +package com.competition.modules.oss.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Data +@Configuration +@ConfigurationProperties(prefix = "oss") +public class OssConfig { + + private String secretId; + + private String secretKey; + + private String bucket; + + private String region = "ap-guangzhou"; + + private String urlPrefix; +} diff --git a/backend-java/src/main/java/com/competition/modules/oss/controller/UploadController.java b/backend-java/src/main/java/com/competition/modules/oss/controller/UploadController.java new file mode 100644 index 0000000..224f465 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/oss/controller/UploadController.java @@ -0,0 +1,34 @@ +package com.competition.modules.oss.controller; + +import com.competition.common.result.Result; +import com.competition.modules.oss.service.OssService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +@Tag(name = "文件上传") +@RestController +@RequestMapping("/upload") +@RequiredArgsConstructor +public class UploadController { + + private final OssService ossService; + + @Operation(summary = "上传文件") + @PostMapping + public Result> upload(@RequestParam("file") MultipartFile file) { + String url = ossService.uploadFile(file); + return Result.success(Map.of( + "url", url, + "fileName", file.getOriginalFilename() != null ? file.getOriginalFilename() : "", + "size", file.getSize() + )); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/oss/service/OssService.java b/backend-java/src/main/java/com/competition/modules/oss/service/OssService.java new file mode 100644 index 0000000..72086bd --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/oss/service/OssService.java @@ -0,0 +1,100 @@ +package com.competition.modules.oss.service; + +import com.competition.modules.oss.config.OssConfig; +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.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import com.qcloud.cos.region.Region; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OssService { + + private final OssConfig ossConfig; + + /** + * 上传文件,优先使用腾讯云 COS,未配置时降级到本地存储 + */ + public String uploadFile(MultipartFile file) { + String originalFilename = file.getOriginalFilename(); + String ext = ""; + if (originalFilename != null && originalFilename.contains(".")) { + ext = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + + String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + String uniqueName = UUID.randomUUID().toString().replace("-", "") + ext; + String key = datePath + "/" + uniqueName; + + if (StringUtils.hasText(ossConfig.getSecretId())) { + return uploadToCos(file, key); + } else { + return uploadToLocal(file, key); + } + } + + private String uploadToCos(MultipartFile file, String key) { + COSCredentials cred = new BasicCOSCredentials(ossConfig.getSecretId(), ossConfig.getSecretKey()); + ClientConfig clientConfig = new ClientConfig(new Region(ossConfig.getRegion())); + COSClient cosClient = new COSClient(cred, clientConfig); + + try { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + PutObjectRequest putRequest = new PutObjectRequest( + ossConfig.getBucket(), key, file.getInputStream(), metadata); + cosClient.putObject(putRequest); + + String url; + if (StringUtils.hasText(ossConfig.getUrlPrefix())) { + url = ossConfig.getUrlPrefix().replaceAll("/$", "") + "/" + key; + } else { + url = "https://" + ossConfig.getBucket() + ".cos." + ossConfig.getRegion() + ".myqcloud.com/" + key; + } + + log.info("文件上传至 COS 成功: {}", url); + return url; + } catch (IOException e) { + log.error("文件上传至 COS 失败", e); + throw new RuntimeException("文件上传失败", e); + } finally { + cosClient.shutdown(); + } + } + + private String uploadToLocal(MultipartFile file, String key) { + try { + Path basePath = Paths.get(System.getProperty("user.dir"), "uploads"); + Path filePath = basePath.resolve(key); + Files.createDirectories(filePath.getParent()); + file.transferTo(filePath.toAbsolutePath().toFile()); + + String url = "/uploads/" + key; + log.info("文件上传至本地成功: {}", filePath.toAbsolutePath()); + return url; + } catch (IOException e) { + log.error("文件上传至本地失败", e); + throw new RuntimeException("文件上传失败", e); + } + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/ContentReviewController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/ContentReviewController.java new file mode 100644 index 0000000..3cbd105 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/ContentReviewController.java @@ -0,0 +1,134 @@ +package com.competition.modules.pub.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.pub.service.PublicContentReviewService; +import com.competition.modules.ugc.entity.UgcReviewLog; +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; +import java.util.Map; + +@Tag(name = "内容审核") +@RestController +@RequestMapping("/content-review") +@RequiredArgsConstructor +public class ContentReviewController { + + private final PublicContentReviewService publicContentReviewService; + + @GetMapping("/works/stats") + @Operation(summary = "获取审核状态统计") + public Result> getStats() { + return Result.success(publicContentReviewService.getStats()); + } + + @GetMapping("/works") + @Operation(summary = "审核作品列表") + public Result>> getWorkQueue( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int pageSize, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String startTime, + @RequestParam(required = false) String endTime, + @RequestParam(required = false) String sortBy, + @RequestParam(required = false) Boolean isRecommended) { + return Result.success(publicContentReviewService.getWorkQueue( + page, pageSize, status, keyword, startTime, endTime, sortBy, isRecommended)); + } + + @GetMapping("/works/{id}") + @Operation(summary = "审核作品详情") + public Result> getWorkDetail(@PathVariable Long id) { + return Result.success(publicContentReviewService.getWorkDetail(id)); + } + + @PostMapping("/works/{id}/approve") + @Operation(summary = "通过审核") + public Result approve(@PathVariable Long id, @RequestBody(required = false) Map body) { + Long operatorId = SecurityUtil.getCurrentUserId(); + String note = body != null ? body.get("note") : null; + publicContentReviewService.approve(id, note, operatorId); + return Result.success(); + } + + @PostMapping("/works/{id}/reject") + @Operation(summary = "驳回") + public Result reject(@PathVariable Long id, @RequestBody Map body) { + Long operatorId = SecurityUtil.getCurrentUserId(); + publicContentReviewService.reject(id, body.get("reason"), body.get("note"), operatorId); + return Result.success(); + } + + @PostMapping("/works/batch-approve") + @Operation(summary = "批量通过") + public Result batchApprove(@RequestBody Map body) { + Long operatorId = SecurityUtil.getCurrentUserId(); + @SuppressWarnings("unchecked") + List ids = ((List) body.get("ids")).stream().map(Number::longValue).toList(); + publicContentReviewService.batchApprove(ids, operatorId); + return Result.success(); + } + + @PostMapping("/works/batch-reject") + @Operation(summary = "批量驳回") + public Result batchReject(@RequestBody Map body) { + Long operatorId = SecurityUtil.getCurrentUserId(); + @SuppressWarnings("unchecked") + List ids = ((List) body.get("ids")).stream().map(Number::longValue).toList(); + String reason = (String) body.get("reason"); + publicContentReviewService.batchReject(ids, reason, operatorId); + return Result.success(); + } + + @PostMapping("/works/{id}/revoke") + @Operation(summary = "撤回审核") + public Result revoke(@PathVariable Long id) { + Long operatorId = SecurityUtil.getCurrentUserId(); + publicContentReviewService.revoke(id, operatorId); + return Result.success(); + } + + @PostMapping("/works/{id}/takedown") + @Operation(summary = "下架") + public Result takedown(@PathVariable Long id, @RequestBody Map body) { + Long operatorId = SecurityUtil.getCurrentUserId(); + publicContentReviewService.takedown(id, body.get("reason"), operatorId); + return Result.success(); + } + + @PostMapping("/works/{id}/restore") + @Operation(summary = "恢复") + public Result restore(@PathVariable Long id) { + Long operatorId = SecurityUtil.getCurrentUserId(); + publicContentReviewService.restore(id, operatorId); + return Result.success(); + } + + @PostMapping("/works/{id}/recommend") + @Operation(summary = "切换推荐状态") + public Result> toggleRecommend(@PathVariable Long id) { + Long operatorId = SecurityUtil.getCurrentUserId(); + return Result.success(publicContentReviewService.toggleRecommend(id, operatorId)); + } + + @GetMapping("/management/stats") + @Operation(summary = "管理概览统计") + public Result> getManagementStats() { + return Result.success(publicContentReviewService.getManagementStats()); + } + + @GetMapping("/logs") + @Operation(summary = "审核日志") + public Result> getLogs( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int pageSize, + @RequestParam(required = false) Long workId) { + return Result.success(publicContentReviewService.getLogs(page, pageSize, workId)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicActivityController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicActivityController.java new file mode 100644 index 0000000..78ea446 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicActivityController.java @@ -0,0 +1,71 @@ +package com.competition.modules.pub.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.contest.entity.BizContest; +import com.competition.modules.biz.contest.entity.BizContestRegistration; +import com.competition.modules.biz.contest.entity.BizContestWork; +import com.competition.modules.pub.dto.PublicRegisterActivityDto; +import com.competition.modules.pub.service.PublicActivityService; +import com.competition.security.annotation.Public; +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.Map; + +@Tag(name = "活动大厅") +@RestController +@RequestMapping("/public/activities") +@RequiredArgsConstructor +public class PublicActivityController { + + private final PublicActivityService publicActivityService; + + @Public + @GetMapping + @Operation(summary = "活动列表") + public Result> listActivities( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int pageSize, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String contestType) { + return Result.success(publicActivityService.listActivities(page, pageSize, keyword, contestType)); + } + + @Public + @GetMapping("/{id}") + @Operation(summary = "活动详情") + public Result> getActivityDetail(@PathVariable Long id) { + return Result.success(publicActivityService.getActivityDetail(id)); + } + + @GetMapping("/{id}/my-registration") + @Operation(summary = "查询我的报名信息") + public Result getMyRegistration(@PathVariable Long id) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicActivityService.getMyRegistration(id, userId)); + } + + @PostMapping("/{id}/register") + @Operation(summary = "报名活动") + public Result register( + @PathVariable Long id, + @RequestBody PublicRegisterActivityDto dto) { + Long userId = SecurityUtil.getCurrentUserId(); + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(publicActivityService.register(id, userId, tenantId, dto)); + } + + @PostMapping("/{id}/submit-work") + @Operation(summary = "提交作品") + public Result submitWork( + @PathVariable Long id, + @RequestBody Map body) { + Long userId = SecurityUtil.getCurrentUserId(); + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(publicActivityService.submitWork(id, userId, tenantId, body)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicAuthController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicAuthController.java new file mode 100644 index 0000000..128d5bf --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicAuthController.java @@ -0,0 +1,46 @@ +package com.competition.modules.pub.controller; + +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.pub.dto.PublicLoginDto; +import com.competition.modules.pub.dto.PublicRegisterDto; +import com.competition.modules.pub.service.PublicAuthService; +import com.competition.security.annotation.Public; +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.Map; + +@Tag(name = "公众端认证") +@RestController +@RequestMapping("/public/auth") +@RequiredArgsConstructor +public class PublicAuthController { + + private final PublicAuthService publicAuthService; + + @Public + @PostMapping("/register") + @Operation(summary = "公众端注册") + public Result> register(@Valid @RequestBody PublicRegisterDto dto) { + return Result.success(publicAuthService.register(dto)); + } + + @Public + @PostMapping("/login") + @Operation(summary = "公众端登录") + public Result> login(@Valid @RequestBody PublicLoginDto dto) { + return Result.success(publicAuthService.login(dto)); + } + + @PostMapping("/switch-child") + @Operation(summary = "切换到子女账号") + public Result> switchChild(@RequestBody Map body) { + Long parentUserId = SecurityUtil.getCurrentUserId(); + Long childUserId = body.get("childUserId"); + return Result.success(publicAuthService.switchChild(parentUserId, childUserId)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicChildController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicChildController.java new file mode 100644 index 0000000..d012efd --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicChildController.java @@ -0,0 +1,90 @@ +package com.competition.modules.pub.controller; + +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.pub.dto.CreateChildDto; +import com.competition.modules.pub.service.PublicProfileService; +import com.competition.modules.user.entity.UserChild; +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; +import java.util.Map; + +@Tag(name = "子女管理") +@RestController +@RequestMapping("/public") +@RequiredArgsConstructor +public class PublicChildController { + + private final PublicProfileService publicProfileService; + + @GetMapping("/mine/children") + @Operation(summary = "获取子女列表") + public Result> getChildren() { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicProfileService.getChildren(userId)); + } + + @PostMapping("/mine/children") + @Operation(summary = "添加子女信息") + public Result createChild(@Valid @RequestBody CreateChildDto dto) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicProfileService.createChild(userId, dto)); + } + + @GetMapping("/mine/children/{id}") + @Operation(summary = "获取子女详情") + public Result getChild(@PathVariable Long id) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicProfileService.getChild(id, userId)); + } + + @PutMapping("/mine/children/{id}") + @Operation(summary = "更新子女信息") + public Result updateChild(@PathVariable Long id, @RequestBody CreateChildDto dto) { + Long userId = SecurityUtil.getCurrentUserId(); + publicProfileService.updateChild(id, userId, dto); + return Result.success(); + } + + @DeleteMapping("/mine/children/{id}") + @Operation(summary = "删除子女信息") + public Result deleteChild(@PathVariable Long id) { + Long userId = SecurityUtil.getCurrentUserId(); + publicProfileService.deleteChild(id, userId); + return Result.success(); + } + + @PostMapping("/children/create-account") + @Operation(summary = "创建子女独立账号") + public Result> createChildAccount(@RequestBody Map body) { + Long parentUserId = SecurityUtil.getCurrentUserId(); + return Result.success(publicProfileService.createChildAccount( + parentUserId, + body.get("username"), + body.get("password"), + body.get("nickname"), + body.get("gender"), + body.get("city"), + body.get("avatar"))); + } + + @GetMapping("/children/accounts") + @Operation(summary = "获取子女账号列表") + public Result>> getChildAccounts() { + Long parentUserId = SecurityUtil.getCurrentUserId(); + return Result.success(publicProfileService.getChildAccounts(parentUserId)); + } + + @PutMapping("/children/accounts/{id}") + @Operation(summary = "更新子女账号") + public Result updateChildAccount(@PathVariable Long id, @RequestBody Map body) { + Long parentUserId = SecurityUtil.getCurrentUserId(); + publicProfileService.updateChildAccount(id, parentUserId, body); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicCreationController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicCreationController.java new file mode 100644 index 0000000..0134326 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicCreationController.java @@ -0,0 +1,55 @@ +package com.competition.modules.pub.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.pub.service.PublicCreationService; +import com.competition.modules.ugc.entity.UgcWork; +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.Map; + +@Tag(name = "AI创作") +@RestController +@RequestMapping("/public/creation") +@RequiredArgsConstructor +public class PublicCreationController { + + private final PublicCreationService publicCreationService; + + @PostMapping("/submit") + @Operation(summary = "提交AI创作") + public Result submit(@RequestBody Map body) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicCreationService.submit(userId, + body.get("originalImageUrl"), + body.get("voiceInputUrl"), + body.get("textInput"))); + } + + @GetMapping("/{id}/status") + @Operation(summary = "获取创作状态") + public Result> getStatus(@PathVariable Long id) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicCreationService.getStatus(id, userId)); + } + + @GetMapping("/{id}/result") + @Operation(summary = "获取创作结果") + public Result> getResult(@PathVariable Long id) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicCreationService.getResult(id, userId)); + } + + @GetMapping("/history") + @Operation(summary = "创作历史") + public Result> getHistory( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int pageSize) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicCreationService.getHistory(userId, page, pageSize)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicGalleryController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicGalleryController.java new file mode 100644 index 0000000..205acd2 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicGalleryController.java @@ -0,0 +1,58 @@ +package com.competition.modules.pub.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.modules.pub.service.PublicGalleryService; +import com.competition.security.annotation.Public; +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; +import java.util.Map; + +@Tag(name = "作品广场") +@RestController +@RequiredArgsConstructor +public class PublicGalleryController { + + private final PublicGalleryService publicGalleryService; + + @Public + @GetMapping("/public/gallery") + @Operation(summary = "广场作品列表") + public Result>> getGalleryList( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int pageSize, + @RequestParam(required = false) Long tagId, + @RequestParam(required = false) String category, + @RequestParam(required = false, defaultValue = "latest") String sortBy, + @RequestParam(required = false) String keyword) { + return Result.success(publicGalleryService.getGalleryList(page, pageSize, tagId, category, sortBy, keyword)); + } + + @Public + @GetMapping("/public/gallery/recommended") + @Operation(summary = "推荐作品") + public Result>> getRecommended() { + return Result.success(publicGalleryService.getRecommended()); + } + + @Public + @GetMapping("/public/gallery/{id}") + @Operation(summary = "广场作品详情") + public Result> getGalleryDetail(@PathVariable Long id) { + return Result.success(publicGalleryService.getGalleryDetail(id)); + } + + @Public + @GetMapping("/public/users/{id}/works") + @Operation(summary = "用户公开作品") + public Result>> getUserPublicWorks( + @PathVariable Long id, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int pageSize) { + return Result.success(publicGalleryService.getUserPublicWorks(id, page, pageSize)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicProfileController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicProfileController.java new file mode 100644 index 0000000..87c2568 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicProfileController.java @@ -0,0 +1,79 @@ +package com.competition.modules.pub.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.biz.contest.entity.BizContestRegistration; +import com.competition.modules.pub.service.PublicActivityService; +import com.competition.modules.pub.service.PublicInteractionService; +import com.competition.modules.pub.service.PublicProfileService; +import com.competition.modules.pub.service.PublicUserWorkService; +import com.competition.modules.ugc.entity.UgcWork; +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.Map; + +@Tag(name = "个人中心") +@RestController +@RequestMapping("/public/mine") +@RequiredArgsConstructor +public class PublicProfileController { + + private final PublicProfileService publicProfileService; + private final PublicUserWorkService publicUserWorkService; + private final PublicInteractionService publicInteractionService; + + @GetMapping("/profile") + @Operation(summary = "获取个人资料") + public Result> getProfile() { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicProfileService.getProfile(userId)); + } + + @PutMapping("/profile") + @Operation(summary = "更新个人资料") + public Result updateProfile(@RequestBody Map body) { + Long userId = SecurityUtil.getCurrentUserId(); + publicProfileService.updateProfile(userId, + body.get("nickname"), body.get("city"), body.get("avatar"), body.get("gender")); + return Result.success(); + } + + @GetMapping("/registrations") + @Operation(summary = "我的报名列表") + public Result> getRegistrations( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int pageSize) { + Long userId = SecurityUtil.getCurrentUserId(); + // Reuse user work service for simplicity - returns user's works + return Result.success(publicUserWorkService.findMyWorks(userId, page, pageSize, null, null)); + } + + @GetMapping("/works") + @Operation(summary = "我的作品列表") + public Result> getWorks( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int pageSize) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicUserWorkService.findMyWorks(userId, page, pageSize, null, null)); + } + + @GetMapping("/favorites") + @Operation(summary = "我的收藏列表") + public Result>> getFavorites( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int pageSize) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicInteractionService.getMyFavorites(userId, page, pageSize)); + } + + @GetMapping("/parent-info") + @Operation(summary = "获取家长信息(子女视角)") + public Result> getParentInfo() { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicProfileService.getParentInfo(userId)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicTagController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicTagController.java new file mode 100644 index 0000000..3672cb9 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicTagController.java @@ -0,0 +1,37 @@ +package com.competition.modules.pub.controller; + +import com.competition.common.result.Result; +import com.competition.modules.pub.service.PublicTagService; +import com.competition.modules.ugc.entity.UgcTag; +import com.competition.security.annotation.Public; +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 = "标签") +@RestController +@RequestMapping("/public/tags") +@RequiredArgsConstructor +public class PublicTagController { + + private final PublicTagService publicTagService; + + @Public + @GetMapping + @Operation(summary = "获取所有标签") + public Result> listTags() { + return Result.success(publicTagService.listTags()); + } + + @Public + @GetMapping("/hot") + @Operation(summary = "获取热门标签") + public Result> getHotTags() { + return Result.success(publicTagService.getHotTags()); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/PublicUserWorkController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicUserWorkController.java new file mode 100644 index 0000000..22fd3c5 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/PublicUserWorkController.java @@ -0,0 +1,133 @@ +package com.competition.modules.pub.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.pub.service.PublicInteractionService; +import com.competition.modules.pub.service.PublicUserWorkService; +import com.competition.modules.ugc.entity.UgcWork; +import com.competition.modules.ugc.entity.UgcWorkPage; +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; +import java.util.Map; + +@Tag(name = "作品库") +@RestController +@RequestMapping("/public/works") +@RequiredArgsConstructor +public class PublicUserWorkController { + + private final PublicUserWorkService publicUserWorkService; + private final PublicInteractionService publicInteractionService; + + @PostMapping + @Operation(summary = "创建作品") + public Result create(@RequestBody Map body) { + Long userId = SecurityUtil.getCurrentUserId(); + String title = (String) body.get("title"); + String coverUrl = (String) body.get("coverUrl"); + String description = (String) body.get("description"); + String visibility = (String) body.get("visibility"); + @SuppressWarnings("unchecked") + List> pages = (List>) body.get("pages"); + @SuppressWarnings("unchecked") + List tagIds = body.get("tagIds") != null + ? ((List) body.get("tagIds")).stream().map(Number::longValue).toList() + : null; + return Result.success(publicUserWorkService.create(userId, title, coverUrl, description, visibility, pages, tagIds)); + } + + @GetMapping + @Operation(summary = "我的作品列表") + public Result> list( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int pageSize, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicUserWorkService.findMyWorks(userId, page, pageSize, status, keyword)); + } + + @GetMapping("/{id}") + @Operation(summary = "作品详情") + public Result> detail(@PathVariable Long id) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicUserWorkService.findDetail(id, userId)); + } + + @PutMapping("/{id}") + @Operation(summary = "更新作品") + public Result update(@PathVariable Long id, @RequestBody Map body) { + Long userId = SecurityUtil.getCurrentUserId(); + publicUserWorkService.update(id, userId, body); + return Result.success(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除作品") + public Result delete(@PathVariable Long id) { + Long userId = SecurityUtil.getCurrentUserId(); + publicUserWorkService.delete(id, userId); + return Result.success(); + } + + @PostMapping("/{id}/publish") + @Operation(summary = "发布作品(提交审核)") + public Result publish(@PathVariable Long id) { + Long userId = SecurityUtil.getCurrentUserId(); + publicUserWorkService.publish(id, userId); + return Result.success(); + } + + @GetMapping("/{id}/pages") + @Operation(summary = "获取作品页面") + public Result> getPages(@PathVariable Long id) { + return Result.success(publicUserWorkService.getPages(id)); + } + + @PostMapping("/{id}/pages") + @Operation(summary = "保存作品页面") + public Result savePages(@PathVariable Long id, @RequestBody Map body) { + Long userId = SecurityUtil.getCurrentUserId(); + @SuppressWarnings("unchecked") + List> pages = (List>) body.get("pages"); + publicUserWorkService.savePages(id, userId, pages); + return Result.success(); + } + + @PostMapping("/batch-interaction") + @Operation(summary = "批量获取互动状态") + public Result>> batchInteraction(@RequestBody Map body) { + Long userId = SecurityUtil.getCurrentUserId(); + @SuppressWarnings("unchecked") + List workIds = body.get("workIds") != null + ? ((List) body.get("workIds")).stream().map(Number::longValue).toList() + : List.of(); + return Result.success(publicInteractionService.batchGetInteractionStatus(workIds, userId)); + } + + @PostMapping("/{id}/like") + @Operation(summary = "点赞/取消点赞") + public Result> toggleLike(@PathVariable Long id) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicInteractionService.toggleLike(id, userId)); + } + + @PostMapping("/{id}/favorite") + @Operation(summary = "收藏/取消收藏") + public Result> toggleFavorite(@PathVariable Long id) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicInteractionService.toggleFavorite(id, userId)); + } + + @GetMapping("/{id}/interaction") + @Operation(summary = "获取互动状态") + public Result> getInteraction(@PathVariable Long id) { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(publicInteractionService.getInteractionStatus(id, userId)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/controller/TagManagementController.java b/backend-java/src/main/java/com/competition/modules/pub/controller/TagManagementController.java new file mode 100644 index 0000000..bae23b1 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/controller/TagManagementController.java @@ -0,0 +1,112 @@ +package com.competition.modules.pub.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.Result; +import com.competition.modules.ugc.entity.UgcTag; +import com.competition.modules.ugc.mapper.UgcTagMapper; +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.Map; +import java.util.stream.Collectors; + +@Tag(name = "标签管理") +@RestController +@RequestMapping("/tags") +@RequiredArgsConstructor +public class TagManagementController { + + private final UgcTagMapper ugcTagMapper; + + @GetMapping + @Operation(summary = "标签列表(管理端)") + public Result> list() { + return Result.success(ugcTagMapper.selectList( + new LambdaQueryWrapper().orderByAsc(UgcTag::getSort))); + } + + @PostMapping + @Operation(summary = "创建标签") + public Result create(@RequestBody Map body) { + UgcTag tag = new UgcTag(); + tag.setName((String) body.get("name")); + tag.setCategory((String) body.get("category")); + tag.setColor((String) body.get("color")); + tag.setSort(body.get("sort") != null ? ((Number) body.get("sort")).intValue() : 0); + tag.setStatus("enabled"); + tag.setUsageCount(0); + tag.setCreateTime(LocalDateTime.now()); + tag.setModifyTime(LocalDateTime.now()); + ugcTagMapper.insert(tag); + return Result.success(tag); + } + + @PutMapping("/{id}") + @Operation(summary = "更新标签") + public Result update(@PathVariable Long id, @RequestBody Map body) { + UgcTag tag = ugcTagMapper.selectById(id); + if (tag == null) { + throw new BusinessException(404, "标签不存在"); + } + if (body.containsKey("name")) tag.setName((String) body.get("name")); + if (body.containsKey("category")) tag.setCategory((String) body.get("category")); + if (body.containsKey("color")) tag.setColor((String) body.get("color")); + if (body.containsKey("sort")) tag.setSort(((Number) body.get("sort")).intValue()); + tag.setModifyTime(LocalDateTime.now()); + ugcTagMapper.updateById(tag); + return Result.success(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除标签") + public Result delete(@PathVariable Long id) { + ugcTagMapper.deleteById(id); + return Result.success(); + } + + @PostMapping("/batch-sort") + @Operation(summary = "批量排序") + public Result batchSort(@RequestBody List> items) { + for (Map item : items) { + Long id = ((Number) item.get("id")).longValue(); + Integer sort = ((Number) item.get("sort")).intValue(); + ugcTagMapper.update(null, new LambdaUpdateWrapper() + .eq(UgcTag::getId, id) + .set(UgcTag::getSort, sort) + .set(UgcTag::getModifyTime, LocalDateTime.now())); + } + return Result.success(); + } + + @PatchMapping("/{id}/status") + @Operation(summary = "切换标签状态") + public Result toggleStatus(@PathVariable Long id) { + UgcTag tag = ugcTagMapper.selectById(id); + if (tag == null) { + throw new BusinessException(404, "标签不存在"); + } + String newStatus = "enabled".equals(tag.getStatus()) ? "disabled" : "enabled"; + tag.setStatus(newStatus); + tag.setModifyTime(LocalDateTime.now()); + ugcTagMapper.updateById(tag); + return Result.success(); + } + + @GetMapping("/categories") + @Operation(summary = "获取标签分类") + public Result> getCategories() { + List tags = ugcTagMapper.selectList( + new LambdaQueryWrapper().isNotNull(UgcTag::getCategory).groupBy(UgcTag::getCategory)); + List categories = tags.stream() + .map(UgcTag::getCategory) + .distinct() + .collect(Collectors.toList()); + return Result.success(categories); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/dto/CreateChildDto.java b/backend-java/src/main/java/com/competition/modules/pub/dto/CreateChildDto.java new file mode 100644 index 0000000..17cf76d --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/dto/CreateChildDto.java @@ -0,0 +1,32 @@ +package com.competition.modules.pub.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "创建子女信息") +public class CreateChildDto { + + @NotBlank(message = "姓名不能为空") + @Schema(description = "姓名") + private String name; + + @Schema(description = "性别") + private String gender; + + @Schema(description = "出生日期") + private String birthday; + + @Schema(description = "年级") + private String grade; + + @Schema(description = "城市") + private String city; + + @Schema(description = "学校名称") + private String schoolName; + + @Schema(description = "头像") + private String avatar; +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/dto/PublicLoginDto.java b/backend-java/src/main/java/com/competition/modules/pub/dto/PublicLoginDto.java new file mode 100644 index 0000000..a22ec4b --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/dto/PublicLoginDto.java @@ -0,0 +1,18 @@ +package com.competition.modules.pub.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "公众端登录请求") +public class PublicLoginDto { + + @NotBlank(message = "用户名不能为空") + @Schema(description = "用户名") + private String username; + + @NotBlank(message = "密码不能为空") + @Schema(description = "密码") + private String password; +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterActivityDto.java b/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterActivityDto.java new file mode 100644 index 0000000..58d9c53 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterActivityDto.java @@ -0,0 +1,18 @@ +package com.competition.modules.pub.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "公众端活动报名请求") +public class PublicRegisterActivityDto { + + @Schema(description = "参与者类型:self/child", defaultValue = "self") + private String participantType = "self"; + + @Schema(description = "子女ID(participantType=child时必填)") + private Long childId; + + @Schema(description = "团队ID(团队赛事时填写)") + private Long teamId; +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterDto.java b/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterDto.java new file mode 100644 index 0000000..a1b0bd7 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterDto.java @@ -0,0 +1,32 @@ +package com.competition.modules.pub.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +@Schema(description = "公众端注册请求") +public class PublicRegisterDto { + + @NotBlank(message = "用户名不能为空") + @Size(min = 4, max = 20, message = "用户名长度为4-20个字符") + @Schema(description = "用户名") + private String username; + + @NotBlank(message = "密码不能为空") + @Size(min = 6, max = 20, message = "密码长度为6-20个字符") + @Schema(description = "密码") + private String password; + + @NotBlank(message = "昵称不能为空") + @Size(min = 2, max = 20, message = "昵称长度为2-20个字符") + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "城市") + private String city; +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicActivityService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicActivityService.java new file mode 100644 index 0000000..78a619f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicActivityService.java @@ -0,0 +1,172 @@ +package com.competition.modules.pub.service; + +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.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.biz.contest.entity.BizContest; +import com.competition.modules.biz.contest.entity.BizContestRegistration; +import com.competition.modules.biz.contest.entity.BizContestWork; +import com.competition.modules.biz.contest.mapper.ContestMapper; +import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper; +import com.competition.modules.biz.contest.mapper.ContestWorkMapper; +import com.competition.modules.pub.dto.PublicRegisterActivityDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicActivityService { + + private final ContestMapper contestMapper; + private final ContestRegistrationMapper contestRegistrationMapper; + private final ContestWorkMapper contestWorkMapper; + + /** + * 活动列表(公开) + */ + public PageResult listActivities(int page, int pageSize, String keyword, String contestType) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BizContest::getVisibility, "public") + .eq(BizContest::getContestState, "published"); + if (StringUtils.hasText(keyword)) { + wrapper.like(BizContest::getContestName, keyword); + } + if (StringUtils.hasText(contestType)) { + wrapper.eq(BizContest::getContestType, contestType); + } + wrapper.orderByDesc(BizContest::getCreateTime); + + IPage result = contestMapper.selectPage(new Page<>(page, pageSize), wrapper); + return PageResult.from(result); + } + + /** + * 活动详情 + */ + public Map getActivityDetail(Long id) { + BizContest contest = contestMapper.selectById(id); + if (contest == null) { + throw new BusinessException(404, "活动不存在"); + } + + Map result = new LinkedHashMap<>(); + result.put("id", contest.getId()); + result.put("contestName", contest.getContestName()); + result.put("contestType", contest.getContestType()); + result.put("contestState", contest.getContestState()); + result.put("status", contest.getStatus()); + result.put("startTime", contest.getStartTime()); + result.put("endTime", contest.getEndTime()); + result.put("coverUrl", contest.getCoverUrl()); + result.put("posterUrl", contest.getPosterUrl()); + result.put("content", contest.getContent()); + result.put("address", contest.getAddress()); + result.put("contactName", contest.getContactName()); + result.put("contactPhone", contest.getContactPhone()); + result.put("contactQrcode", contest.getContactQrcode()); + result.put("organizers", contest.getOrganizers()); + result.put("coOrganizers", contest.getCoOrganizers()); + result.put("sponsors", contest.getSponsors()); + result.put("registerStartTime", contest.getRegisterStartTime()); + result.put("registerEndTime", contest.getRegisterEndTime()); + result.put("registerState", contest.getRegisterState()); + result.put("submitStartTime", contest.getSubmitStartTime()); + result.put("submitEndTime", contest.getSubmitEndTime()); + result.put("workType", contest.getWorkType()); + result.put("workRequirement", contest.getWorkRequirement()); + result.put("resultState", contest.getResultState()); + result.put("resultPublishTime", contest.getResultPublishTime()); + return result; + } + + /** + * 查询当前用户的报名信息 + */ + public BizContestRegistration getMyRegistration(Long contestId, Long userId) { + return contestRegistrationMapper.selectOne( + new LambdaQueryWrapper() + .eq(BizContestRegistration::getContestId, contestId) + .eq(BizContestRegistration::getUserId, userId) + .last("LIMIT 1")); + } + + /** + * 报名活动 + */ + @Transactional + public BizContestRegistration register(Long contestId, Long userId, Long tenantId, PublicRegisterActivityDto dto) { + // 检查是否已报名 + Long existCount = contestRegistrationMapper.selectCount( + new LambdaQueryWrapper() + .eq(BizContestRegistration::getContestId, contestId) + .eq(BizContestRegistration::getUserId, userId)); + if (existCount > 0) { + throw new BusinessException(400, "您已报名该活动"); + } + + // 检查活动是否存在且可报名 + BizContest contest = contestMapper.selectById(contestId); + if (contest == null) { + throw new BusinessException(404, "活动不存在"); + } + if (!"published".equals(contest.getContestState())) { + throw new BusinessException(400, "活动未发布"); + } + + BizContestRegistration reg = new BizContestRegistration(); + reg.setContestId(contestId); + reg.setUserId(userId); + reg.setTenantId(tenantId); + reg.setRegistrationType(contest.getContestType()); + reg.setParticipantType(dto.getParticipantType() != null ? dto.getParticipantType() : "self"); + reg.setChildId(dto.getChildId()); + reg.setTeamId(dto.getTeamId()); + reg.setRegistrationState(Boolean.TRUE.equals(contest.getRequireAudit()) ? "pending" : "passed"); + reg.setRegistrationTime(LocalDateTime.now()); + contestRegistrationMapper.insert(reg); + return reg; + } + + /** + * 提交作品 + */ + @Transactional + public BizContestWork submitWork(Long contestId, Long userId, Long tenantId, Map dto) { + // 检查报名状态 + BizContestRegistration reg = contestRegistrationMapper.selectOne( + new LambdaQueryWrapper() + .eq(BizContestRegistration::getContestId, contestId) + .eq(BizContestRegistration::getUserId, userId) + .eq(BizContestRegistration::getRegistrationState, "passed")); + if (reg == null) { + throw new BusinessException(400, "未报名或报名未通过"); + } + + BizContestWork work = new BizContestWork(); + work.setContestId(contestId); + work.setRegistrationId(reg.getId()); + work.setTenantId(tenantId); + work.setTitle((String) dto.get("title")); + work.setDescription((String) dto.get("description")); + work.setFiles(dto.get("files")); + work.setSubmitterUserId(userId); + work.setStatus("submitted"); + work.setSubmitTime(LocalDateTime.now()); + work.setVersion(1); + work.setIsLatest(true); + if (dto.get("userWorkId") != null) { + work.setUserWorkId(Long.valueOf(dto.get("userWorkId").toString())); + } + contestWorkMapper.insert(work); + return work; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicAuthService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicAuthService.java new file mode 100644 index 0000000..b606851 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicAuthService.java @@ -0,0 +1,203 @@ +package com.competition.modules.pub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.competition.common.exception.BusinessException; +import com.competition.modules.pub.dto.PublicLoginDto; +import com.competition.modules.pub.dto.PublicRegisterDto; +import com.competition.modules.sys.entity.SysRole; +import com.competition.modules.sys.entity.SysTenant; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.entity.SysUserRole; +import com.competition.modules.sys.mapper.SysRoleMapper; +import com.competition.modules.sys.mapper.SysTenantMapper; +import com.competition.modules.sys.mapper.SysUserMapper; +import com.competition.modules.sys.mapper.SysUserRoleMapper; +import com.competition.modules.user.entity.UserParentChild; +import com.competition.modules.user.mapper.UserParentChildMapper; +import com.competition.security.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicAuthService { + + private final SysUserMapper sysUserMapper; + private final SysUserRoleMapper sysUserRoleMapper; + private final SysRoleMapper sysRoleMapper; + private final SysTenantMapper sysTenantMapper; + private final UserParentChildMapper userParentChildMapper; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final StringRedisTemplate stringRedisTemplate; + + /** + * 公众端注册 + */ + @Transactional + public Map register(PublicRegisterDto dto) { + // 查找公众租户 + SysTenant publicTenant = sysTenantMapper.selectOne( + new LambdaQueryWrapper().eq(SysTenant::getCode, "public")); + if (publicTenant == null) { + throw new BusinessException(500, "公众租户未配置"); + } + + // 检查用户名是否已存在 + Long existCount = sysUserMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysUser::getUsername, dto.getUsername()) + .eq(SysUser::getTenantId, publicTenant.getId())); + if (existCount > 0) { + throw new BusinessException(400, "用户名已存在"); + } + + // 创建用户 + SysUser user = new SysUser(); + user.setTenantId(publicTenant.getId()); + user.setUsername(dto.getUsername()); + user.setPassword(passwordEncoder.encode(dto.getPassword())); + user.setNickname(dto.getNickname()); + user.setPhone(dto.getPhone()); + user.setCity(dto.getCity()); + user.setUserSource("self_registered"); + user.setUserType("adult"); + user.setStatus("enabled"); + sysUserMapper.insert(user); + + // 查找或创建 public_user 角色 + SysRole publicRole = sysRoleMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysRole::getCode, "public_user") + .eq(SysRole::getTenantId, publicTenant.getId())); + if (publicRole == null) { + publicRole = new SysRole(); + publicRole.setTenantId(publicTenant.getId()); + publicRole.setName("公众用户"); + publicRole.setCode("public_user"); + publicRole.setDescription("公众端注册用户默认角色"); + sysRoleMapper.insert(publicRole); + } + + // 分配角色 + SysUserRole userRole = new SysUserRole(); + userRole.setUserId(user.getId()); + userRole.setRoleId(publicRole.getId()); + sysUserRoleMapper.insert(userRole); + + // 生成 JWT + String token = jwtUtil.generateToken(user.getId(), user.getUsername(), publicTenant.getId()); + + return buildAuthResult(token, user, List.of(publicRole.getCode())); + } + + /** + * 公众端登录 + */ + public Map login(PublicLoginDto dto) { + // 先在公众租户下查找 + SysTenant publicTenant = sysTenantMapper.selectOne( + new LambdaQueryWrapper().eq(SysTenant::getCode, "public")); + + SysUser user = null; + if (publicTenant != null) { + user = sysUserMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysUser::getUsername, dto.getUsername()) + .eq(SysUser::getTenantId, publicTenant.getId())); + } + + // 如未找到,在所有租户中搜索 + if (user == null) { + user = sysUserMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysUser::getUsername, dto.getUsername()) + .last("LIMIT 1")); + } + + if (user == null) { + throw new BusinessException(401, "用户名或密码错误"); + } + + if (!"enabled".equals(user.getStatus())) { + throw new BusinessException(403, "账号已被禁用"); + } + + if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) { + throw new BusinessException(401, "用户名或密码错误"); + } + + // 获取角色 + List roles = getUserRoleCodes(user.getId()); + + String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getTenantId()); + return buildAuthResult(token, user, roles); + } + + /** + * 切换到子女账号 + */ + public Map switchChild(Long parentUserId, Long childUserId) { + // 验证亲子关系 + Long count = userParentChildMapper.selectCount( + new LambdaQueryWrapper() + .eq(UserParentChild::getParentUserId, parentUserId) + .eq(UserParentChild::getChildUserId, childUserId)); + if (count == 0) { + throw new BusinessException(403, "无权切换到该子女账号"); + } + + SysUser childUser = sysUserMapper.selectById(childUserId); + if (childUser == null) { + throw new BusinessException(404, "子女账号不存在"); + } + + List roles = getUserRoleCodes(childUser.getId()); + String token = jwtUtil.generateToken(childUser.getId(), childUser.getUsername(), childUser.getTenantId()); + + Map result = buildAuthResult(token, childUser, roles); + // 在用户信息中附加 parentUserId + @SuppressWarnings("unchecked") + Map userInfo = (Map) result.get("user"); + userInfo.put("parentUserId", parentUserId); + return result; + } + + private List getUserRoleCodes(Long userId) { + List userRoles = sysUserRoleMapper.selectList( + new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); + if (userRoles.isEmpty()) { + return Collections.emptyList(); + } + List roleIds = userRoles.stream().map(SysUserRole::getRoleId).collect(Collectors.toList()); + List roles = sysRoleMapper.selectBatchIds(roleIds); + return roles.stream().map(SysRole::getCode).collect(Collectors.toList()); + } + + private Map buildAuthResult(String token, SysUser user, List roles) { + Map userInfo = new LinkedHashMap<>(); + userInfo.put("id", user.getId()); + userInfo.put("username", user.getUsername()); + userInfo.put("nickname", user.getNickname()); + userInfo.put("avatar", user.getAvatar()); + userInfo.put("phone", user.getPhone()); + userInfo.put("city", user.getCity()); + userInfo.put("gender", user.getGender()); + userInfo.put("userType", user.getUserType()); + userInfo.put("roles", roles); + userInfo.put("permissions", Collections.emptyList()); + + Map result = new LinkedHashMap<>(); + result.put("token", token); + result.put("user", userInfo); + return result; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicContentReviewService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicContentReviewService.java new file mode 100644 index 0000000..b48d19e --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicContentReviewService.java @@ -0,0 +1,301 @@ +package com.competition.modules.pub.service; + +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.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.mapper.SysUserMapper; +import com.competition.modules.ugc.entity.UgcReviewLog; +import com.competition.modules.ugc.entity.UgcWork; +import com.competition.modules.ugc.mapper.UgcReviewLogMapper; +import com.competition.modules.ugc.mapper.UgcWorkMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicContentReviewService { + + private final UgcWorkMapper ugcWorkMapper; + private final UgcReviewLogMapper ugcReviewLogMapper; + private final SysUserMapper sysUserMapper; + + /** + * 获取各状态统计 + */ + public Map getStats() { + Map stats = new LinkedHashMap<>(); + stats.put("pending_review", countByStatus("pending_review")); + stats.put("published", countByStatus("published")); + stats.put("rejected", countByStatus("rejected")); + stats.put("taken_down", countByStatus("taken_down")); + return stats; + } + + /** + * 获取待审核作品队列 + */ + public PageResult> getWorkQueue(int page, int pageSize, String status, + String keyword, String startTime, String endTime, + String sortBy, Boolean isRecommended) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UgcWork::getIsDeleted, 0); + if (StringUtils.hasText(status)) { + wrapper.eq(UgcWork::getStatus, status); + } + if (StringUtils.hasText(keyword)) { + wrapper.like(UgcWork::getTitle, keyword); + } + if (StringUtils.hasText(startTime)) { + wrapper.ge(UgcWork::getCreateTime, LocalDateTime.parse(startTime)); + } + if (StringUtils.hasText(endTime)) { + wrapper.le(UgcWork::getCreateTime, LocalDateTime.parse(endTime)); + } + if (isRecommended != null) { + wrapper.eq(UgcWork::getIsRecommended, isRecommended); + } + + if ("oldest".equals(sortBy)) { + wrapper.orderByAsc(UgcWork::getCreateTime); + } else { + wrapper.orderByDesc(UgcWork::getCreateTime); + } + + IPage result = ugcWorkMapper.selectPage(new Page<>(page, pageSize), wrapper); + + List> voList = new ArrayList<>(); + for (UgcWork work : result.getRecords()) { + Map vo = buildReviewWorkVo(work); + voList.add(vo); + } + return PageResult.from(result, voList); + } + + /** + * 获取作品详情(审核用) + */ + public Map getWorkDetail(Long id) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null) { + throw new BusinessException(404, "作品不存在"); + } + return buildReviewWorkVo(work); + } + + /** + * 通过审核 + */ + @Transactional + public void approve(Long id, String note, Long operatorId) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null) { + throw new BusinessException(404, "作品不存在"); + } + work.setStatus("published"); + work.setReviewNote(note); + work.setReviewerId(operatorId); + work.setReviewTime(LocalDateTime.now()); + work.setPublishTime(LocalDateTime.now()); + work.setModifyTime(LocalDateTime.now()); + ugcWorkMapper.updateById(work); + createLog(id, "approve", null, note, operatorId); + } + + /** + * 驳回 + */ + @Transactional + public void reject(Long id, String reason, String note, Long operatorId) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null) { + throw new BusinessException(404, "作品不存在"); + } + work.setStatus("rejected"); + work.setReviewNote(note); + work.setReviewerId(operatorId); + work.setReviewTime(LocalDateTime.now()); + work.setModifyTime(LocalDateTime.now()); + ugcWorkMapper.updateById(work); + createLog(id, "reject", reason, note, operatorId); + } + + /** + * 批量通过 + */ + @Transactional + public void batchApprove(List ids, Long operatorId) { + for (Long id : ids) { + approve(id, null, operatorId); + } + } + + /** + * 批量驳回 + */ + @Transactional + public void batchReject(List ids, String reason, Long operatorId) { + for (Long id : ids) { + reject(id, reason, null, operatorId); + } + } + + /** + * 撤回审核 + */ + @Transactional + public void revoke(Long id, Long operatorId) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null) { + throw new BusinessException(404, "作品不存在"); + } + work.setStatus("pending_review"); + work.setModifyTime(LocalDateTime.now()); + ugcWorkMapper.updateById(work); + createLog(id, "revoke", null, null, operatorId); + } + + /** + * 下架 + */ + @Transactional + public void takedown(Long id, String reason, Long operatorId) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null) { + throw new BusinessException(404, "作品不存在"); + } + work.setStatus("taken_down"); + work.setModifyTime(LocalDateTime.now()); + ugcWorkMapper.updateById(work); + createLog(id, "takedown", reason, null, operatorId); + } + + /** + * 恢复 + */ + @Transactional + public void restore(Long id, Long operatorId) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null) { + throw new BusinessException(404, "作品不存在"); + } + work.setStatus("published"); + work.setModifyTime(LocalDateTime.now()); + ugcWorkMapper.updateById(work); + createLog(id, "restore", null, null, operatorId); + } + + /** + * 切换推荐状态 + */ + @Transactional + public Map toggleRecommend(Long id, Long operatorId) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null) { + throw new BusinessException(404, "作品不存在"); + } + boolean newState = !Boolean.TRUE.equals(work.getIsRecommended()); + work.setIsRecommended(newState); + work.setModifyTime(LocalDateTime.now()); + ugcWorkMapper.updateById(work); + createLog(id, newState ? "recommend" : "unrecommend", null, null, operatorId); + + Map result = new LinkedHashMap<>(); + result.put("isRecommended", newState); + return result; + } + + /** + * 管理概览统计 + */ + public Map getManagementStats() { + Map stats = new LinkedHashMap<>(); + stats.put("total", ugcWorkMapper.selectCount( + new LambdaQueryWrapper().eq(UgcWork::getIsDeleted, 0))); + stats.put("pendingReview", countByStatus("pending_review")); + stats.put("published", countByStatus("published")); + stats.put("rejected", countByStatus("rejected")); + stats.put("takenDown", countByStatus("taken_down")); + stats.put("recommended", ugcWorkMapper.selectCount( + new LambdaQueryWrapper() + .eq(UgcWork::getIsDeleted, 0) + .eq(UgcWork::getIsRecommended, true))); + return stats; + } + + /** + * 获取审核日志 + */ + public PageResult getLogs(int page, int pageSize, Long workId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UgcReviewLog::getTargetType, "work"); + if (workId != null) { + wrapper.eq(UgcReviewLog::getWorkId, workId); + } + wrapper.orderByDesc(UgcReviewLog::getCreateTime); + + IPage result = ugcReviewLogMapper.selectPage(new Page<>(page, pageSize), wrapper); + return PageResult.from(result); + } + + private long countByStatus(String status) { + return ugcWorkMapper.selectCount( + new LambdaQueryWrapper() + .eq(UgcWork::getIsDeleted, 0) + .eq(UgcWork::getStatus, status)); + } + + private void createLog(Long workId, String action, String reason, String note, Long operatorId) { + UgcReviewLog logEntry = new UgcReviewLog(); + logEntry.setTargetType("work"); + logEntry.setTargetId(workId); + logEntry.setWorkId(workId); + logEntry.setAction(action); + logEntry.setReason(reason); + logEntry.setNote(note); + logEntry.setOperatorId(operatorId); + logEntry.setCreateTime(LocalDateTime.now()); + ugcReviewLogMapper.insert(logEntry); + } + + private Map buildReviewWorkVo(UgcWork work) { + Map vo = new LinkedHashMap<>(); + vo.put("id", work.getId()); + vo.put("title", work.getTitle()); + vo.put("coverUrl", work.getCoverUrl()); + vo.put("description", work.getDescription()); + vo.put("status", work.getStatus()); + vo.put("visibility", work.getVisibility()); + vo.put("isRecommended", work.getIsRecommended()); + vo.put("viewCount", work.getViewCount()); + vo.put("likeCount", work.getLikeCount()); + vo.put("favoriteCount", work.getFavoriteCount()); + vo.put("reviewNote", work.getReviewNote()); + vo.put("reviewTime", work.getReviewTime()); + vo.put("publishTime", work.getPublishTime()); + vo.put("createTime", work.getCreateTime()); + + // 补充作者信息 + if (work.getUserId() != null) { + SysUser user = sysUserMapper.selectById(work.getUserId()); + if (user != null) { + Map userInfo = new LinkedHashMap<>(); + userInfo.put("id", user.getId()); + userInfo.put("username", user.getUsername()); + userInfo.put("nickname", user.getNickname()); + userInfo.put("avatar", user.getAvatar()); + vo.put("user", userInfo); + } + } + return vo; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicCreationService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicCreationService.java new file mode 100644 index 0000000..3b6892a --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicCreationService.java @@ -0,0 +1,98 @@ +package com.competition.modules.pub.service; + +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.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.ugc.entity.UgcWork; +import com.competition.modules.ugc.mapper.UgcWorkMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicCreationService { + + private final UgcWorkMapper ugcWorkMapper; + + /** + * 提交 AI 创作 + */ + public UgcWork submit(Long userId, String originalImageUrl, String voiceInputUrl, String textInput) { + UgcWork work = new UgcWork(); + work.setUserId(userId); + work.setStatus("draft"); + work.setOriginalImageUrl(originalImageUrl); + work.setVoiceInputUrl(voiceInputUrl); + work.setTextInput(textInput); + work.setVisibility("private"); + work.setViewCount(0); + work.setLikeCount(0); + work.setFavoriteCount(0); + work.setCommentCount(0); + work.setShareCount(0); + work.setIsRecommended(false); + work.setIsDeleted(0); + work.setCreateTime(LocalDateTime.now()); + work.setModifyTime(LocalDateTime.now()); + ugcWorkMapper.insert(work); + return work; + } + + /** + * 获取创作状态 + */ + public Map getStatus(Long id, Long userId) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null || !work.getUserId().equals(userId)) { + throw new BusinessException(404, "创作记录不存在"); + } + Map result = new LinkedHashMap<>(); + result.put("id", work.getId()); + result.put("status", work.getStatus()); + result.put("aiMeta", work.getAiMeta()); + return result; + } + + /** + * 获取创作结果 + */ + public Map getResult(Long id, Long userId) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null || !work.getUserId().equals(userId)) { + throw new BusinessException(404, "创作记录不存在"); + } + Map result = new LinkedHashMap<>(); + result.put("id", work.getId()); + result.put("title", work.getTitle()); + result.put("coverUrl", work.getCoverUrl()); + result.put("description", work.getDescription()); + result.put("status", work.getStatus()); + result.put("originalImageUrl", work.getOriginalImageUrl()); + result.put("voiceInputUrl", work.getVoiceInputUrl()); + result.put("textInput", work.getTextInput()); + result.put("aiMeta", work.getAiMeta()); + result.put("createTime", work.getCreateTime()); + return result; + } + + /** + * 获取创作历史 + */ + public PageResult getHistory(Long userId, int page, int pageSize) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UgcWork::getUserId, userId) + .eq(UgcWork::getIsDeleted, 0) + .isNotNull(UgcWork::getOriginalImageUrl) + .orderByDesc(UgcWork::getCreateTime); + + IPage result = ugcWorkMapper.selectPage(new Page<>(page, pageSize), wrapper); + return PageResult.from(result); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicGalleryService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicGalleryService.java new file mode 100644 index 0000000..10e7985 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicGalleryService.java @@ -0,0 +1,179 @@ +package com.competition.modules.pub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.mapper.SysUserMapper; +import com.competition.modules.ugc.entity.UgcWork; +import com.competition.modules.ugc.entity.UgcWorkPage; +import com.competition.modules.ugc.mapper.UgcWorkMapper; +import com.competition.modules.ugc.mapper.UgcWorkPageMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicGalleryService { + + private final UgcWorkMapper ugcWorkMapper; + private final UgcWorkPageMapper ugcWorkPageMapper; + private final SysUserMapper sysUserMapper; + + /** + * 获取广场作品列表 + */ + public PageResult> getGalleryList(int page, int pageSize, Long tagId, + String category, String sortBy, String keyword) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UgcWork::getStatus, "published") + .eq(UgcWork::getVisibility, "public") + .eq(UgcWork::getIsDeleted, 0); + if (StringUtils.hasText(keyword)) { + wrapper.like(UgcWork::getTitle, keyword); + } + + // 排序 + if ("hot".equals(sortBy)) { + wrapper.orderByDesc(UgcWork::getLikeCount); + } else if ("views".equals(sortBy)) { + wrapper.orderByDesc(UgcWork::getViewCount); + } else { + wrapper.orderByDesc(UgcWork::getPublishTime); + } + + IPage result = ugcWorkMapper.selectPage(new Page<>(page, pageSize), wrapper); + + // 补充用户信息 + List> voList = new ArrayList<>(); + for (UgcWork work : result.getRecords()) { + Map vo = buildGalleryItem(work); + voList.add(vo); + } + + return PageResult.from(result, voList); + } + + /** + * 获取推荐作品 + */ + public List> getRecommended() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UgcWork::getIsRecommended, true) + .eq(UgcWork::getStatus, "published") + .eq(UgcWork::getVisibility, "public") + .eq(UgcWork::getIsDeleted, 0) + .orderByDesc(UgcWork::getPublishTime) + .last("LIMIT 20"); + + List works = ugcWorkMapper.selectList(wrapper); + List> result = new ArrayList<>(); + for (UgcWork work : works) { + result.add(buildGalleryItem(work)); + } + return result; + } + + /** + * 获取广场作品详情 + */ + public Map getGalleryDetail(Long id) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null || work.getIsDeleted() == 1) { + throw new BusinessException(404, "作品不存在"); + } + + // 增加浏览量 + ugcWorkMapper.update(null, new LambdaUpdateWrapper() + .eq(UgcWork::getId, id) + .setSql("view_count = view_count + 1")); + + // 获取页面 + List pages = ugcWorkPageMapper.selectList( + new LambdaQueryWrapper() + .eq(UgcWorkPage::getWorkId, id) + .orderByAsc(UgcWorkPage::getPageNo)); + + // 获取作者信息 + Map userInfo = null; + if (work.getUserId() != null) { + SysUser user = sysUserMapper.selectById(work.getUserId()); + if (user != null) { + userInfo = new LinkedHashMap<>(); + userInfo.put("id", user.getId()); + userInfo.put("nickname", user.getNickname()); + userInfo.put("avatar", user.getAvatar()); + } + } + + Map result = new LinkedHashMap<>(); + result.put("id", work.getId()); + result.put("title", work.getTitle()); + result.put("coverUrl", work.getCoverUrl()); + result.put("description", work.getDescription()); + result.put("status", work.getStatus()); + result.put("viewCount", (work.getViewCount() != null ? work.getViewCount() : 0) + 1); + result.put("likeCount", work.getLikeCount()); + result.put("favoriteCount", work.getFavoriteCount()); + result.put("commentCount", work.getCommentCount()); + result.put("isRecommended", work.getIsRecommended()); + result.put("publishTime", work.getPublishTime()); + result.put("pages", pages); + result.put("user", userInfo); + return result; + } + + /** + * 获取用户的公开作品 + */ + public PageResult> getUserPublicWorks(Long userId, int page, int pageSize) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UgcWork::getUserId, userId) + .eq(UgcWork::getStatus, "published") + .eq(UgcWork::getVisibility, "public") + .eq(UgcWork::getIsDeleted, 0) + .orderByDesc(UgcWork::getPublishTime); + + IPage result = ugcWorkMapper.selectPage(new Page<>(page, pageSize), wrapper); + + List> voList = new ArrayList<>(); + for (UgcWork work : result.getRecords()) { + voList.add(buildGalleryItem(work)); + } + return PageResult.from(result, voList); + } + + private Map buildGalleryItem(UgcWork work) { + Map vo = new LinkedHashMap<>(); + vo.put("id", work.getId()); + vo.put("title", work.getTitle()); + vo.put("coverUrl", work.getCoverUrl()); + vo.put("description", work.getDescription()); + vo.put("viewCount", work.getViewCount()); + vo.put("likeCount", work.getLikeCount()); + vo.put("favoriteCount", work.getFavoriteCount()); + vo.put("isRecommended", work.getIsRecommended()); + vo.put("publishTime", work.getPublishTime()); + + // 补充作者信息 + if (work.getUserId() != null) { + SysUser user = sysUserMapper.selectById(work.getUserId()); + if (user != null) { + Map userInfo = new LinkedHashMap<>(); + userInfo.put("id", user.getId()); + userInfo.put("nickname", user.getNickname()); + userInfo.put("avatar", user.getAvatar()); + vo.put("user", userInfo); + } + } + return vo; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicInteractionService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicInteractionService.java new file mode 100644 index 0000000..a331caf --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicInteractionService.java @@ -0,0 +1,184 @@ +package com.competition.modules.pub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.competition.common.result.PageResult; +import com.competition.modules.ugc.entity.UgcWork; +import com.competition.modules.ugc.entity.UgcWorkFavorite; +import com.competition.modules.ugc.entity.UgcWorkLike; +import com.competition.modules.ugc.mapper.UgcWorkFavoriteMapper; +import com.competition.modules.ugc.mapper.UgcWorkLikeMapper; +import com.competition.modules.ugc.mapper.UgcWorkMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicInteractionService { + + private final UgcWorkLikeMapper ugcWorkLikeMapper; + private final UgcWorkFavoriteMapper ugcWorkFavoriteMapper; + private final UgcWorkMapper ugcWorkMapper; + + /** + * 切换点赞状态 + */ + @Transactional + public Map toggleLike(Long workId, Long userId) { + UgcWorkLike existing = ugcWorkLikeMapper.selectOne( + new LambdaQueryWrapper() + .eq(UgcWorkLike::getWorkId, workId) + .eq(UgcWorkLike::getUserId, userId)); + + boolean liked; + if (existing != null) { + ugcWorkLikeMapper.deleteById(existing.getId()); + ugcWorkMapper.update(null, new LambdaUpdateWrapper() + .eq(UgcWork::getId, workId) + .setSql("like_count = GREATEST(like_count - 1, 0)")); + liked = false; + } else { + UgcWorkLike like = new UgcWorkLike(); + like.setWorkId(workId); + like.setUserId(userId); + like.setCreateTime(LocalDateTime.now()); + ugcWorkLikeMapper.insert(like); + ugcWorkMapper.update(null, new LambdaUpdateWrapper() + .eq(UgcWork::getId, workId) + .setSql("like_count = like_count + 1")); + liked = true; + } + + UgcWork work = ugcWorkMapper.selectById(workId); + Map result = new LinkedHashMap<>(); + result.put("liked", liked); + result.put("likeCount", work != null ? work.getLikeCount() : 0); + return result; + } + + /** + * 切换收藏状态 + */ + @Transactional + public Map toggleFavorite(Long workId, Long userId) { + UgcWorkFavorite existing = ugcWorkFavoriteMapper.selectOne( + new LambdaQueryWrapper() + .eq(UgcWorkFavorite::getWorkId, workId) + .eq(UgcWorkFavorite::getUserId, userId)); + + boolean favorited; + if (existing != null) { + ugcWorkFavoriteMapper.deleteById(existing.getId()); + ugcWorkMapper.update(null, new LambdaUpdateWrapper() + .eq(UgcWork::getId, workId) + .setSql("favorite_count = GREATEST(favorite_count - 1, 0)")); + favorited = false; + } else { + UgcWorkFavorite fav = new UgcWorkFavorite(); + fav.setWorkId(workId); + fav.setUserId(userId); + fav.setCreateTime(LocalDateTime.now()); + ugcWorkFavoriteMapper.insert(fav); + ugcWorkMapper.update(null, new LambdaUpdateWrapper() + .eq(UgcWork::getId, workId) + .setSql("favorite_count = favorite_count + 1")); + favorited = true; + } + + UgcWork work = ugcWorkMapper.selectById(workId); + Map result = new LinkedHashMap<>(); + result.put("favorited", favorited); + result.put("favoriteCount", work != null ? work.getFavoriteCount() : 0); + return result; + } + + /** + * 获取互动状态 + */ + public Map getInteractionStatus(Long workId, Long userId) { + boolean liked = ugcWorkLikeMapper.selectCount( + new LambdaQueryWrapper() + .eq(UgcWorkLike::getWorkId, workId) + .eq(UgcWorkLike::getUserId, userId)) > 0; + + boolean favorited = ugcWorkFavoriteMapper.selectCount( + new LambdaQueryWrapper() + .eq(UgcWorkFavorite::getWorkId, workId) + .eq(UgcWorkFavorite::getUserId, userId)) > 0; + + Map result = new LinkedHashMap<>(); + result.put("liked", liked); + result.put("favorited", favorited); + return result; + } + + /** + * 批量获取互动状态 + */ + public Map> batchGetInteractionStatus(List workIds, Long userId) { + if (workIds == null || workIds.isEmpty()) { + return Collections.emptyMap(); + } + + // 查询点赞 + Set likedIds = ugcWorkLikeMapper.selectList( + new LambdaQueryWrapper() + .in(UgcWorkLike::getWorkId, workIds) + .eq(UgcWorkLike::getUserId, userId)) + .stream().map(UgcWorkLike::getWorkId).collect(Collectors.toSet()); + + // 查询收藏 + Set favoritedIds = ugcWorkFavoriteMapper.selectList( + new LambdaQueryWrapper() + .in(UgcWorkFavorite::getWorkId, workIds) + .eq(UgcWorkFavorite::getUserId, userId)) + .stream().map(UgcWorkFavorite::getWorkId).collect(Collectors.toSet()); + + Map> result = new LinkedHashMap<>(); + for (Long workId : workIds) { + Map status = new LinkedHashMap<>(); + status.put("liked", likedIds.contains(workId)); + status.put("favorited", favoritedIds.contains(workId)); + result.put(workId, status); + } + return result; + } + + /** + * 获取我的收藏(分页) + */ + public PageResult> getMyFavorites(Long userId, int page, int pageSize) { + IPage favPage = ugcWorkFavoriteMapper.selectPage( + new Page<>(page, pageSize), + new LambdaQueryWrapper() + .eq(UgcWorkFavorite::getUserId, userId) + .orderByDesc(UgcWorkFavorite::getCreateTime)); + + List> voList = new ArrayList<>(); + for (UgcWorkFavorite fav : favPage.getRecords()) { + UgcWork work = ugcWorkMapper.selectById(fav.getWorkId()); + if (work != null && work.getIsDeleted() == 0) { + Map vo = new LinkedHashMap<>(); + vo.put("favoriteId", fav.getId()); + vo.put("favoriteTime", fav.getCreateTime()); + vo.put("workId", work.getId()); + vo.put("title", work.getTitle()); + vo.put("coverUrl", work.getCoverUrl()); + vo.put("status", work.getStatus()); + vo.put("likeCount", work.getLikeCount()); + vo.put("viewCount", work.getViewCount()); + voList.add(vo); + } + } + return PageResult.from(favPage, voList); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicProfileService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicProfileService.java new file mode 100644 index 0000000..ddc21ca --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicProfileService.java @@ -0,0 +1,277 @@ +package com.competition.modules.pub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.competition.common.exception.BusinessException; +import com.competition.modules.pub.dto.CreateChildDto; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.entity.SysTenant; +import com.competition.modules.sys.mapper.SysUserMapper; +import com.competition.modules.sys.mapper.SysTenantMapper; +import com.competition.modules.user.entity.UserChild; +import com.competition.modules.user.entity.UserParentChild; +import com.competition.modules.user.mapper.UserChildMapper; +import com.competition.modules.user.mapper.UserParentChildMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicProfileService { + + private final SysUserMapper sysUserMapper; + private final SysTenantMapper sysTenantMapper; + private final UserChildMapper userChildMapper; + private final UserParentChildMapper userParentChildMapper; + private final PasswordEncoder passwordEncoder; + + /** + * 获取个人资料 + */ + public Map getProfile(Long userId) { + SysUser user = sysUserMapper.selectById(userId); + if (user == null) { + throw new BusinessException(404, "用户不存在"); + } + Map result = new LinkedHashMap<>(); + result.put("id", user.getId()); + result.put("username", user.getUsername()); + result.put("nickname", user.getNickname()); + result.put("avatar", user.getAvatar()); + result.put("phone", user.getPhone()); + result.put("city", user.getCity()); + result.put("gender", user.getGender()); + result.put("email", user.getEmail()); + result.put("userType", user.getUserType()); + result.put("birthday", user.getBirthday()); + result.put("createTime", user.getCreateTime()); + return result; + } + + /** + * 更新个人资料 + */ + public void updateProfile(Long userId, String nickname, String city, String avatar, String gender) { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(SysUser::getId, userId); + if (nickname != null) wrapper.set(SysUser::getNickname, nickname); + if (city != null) wrapper.set(SysUser::getCity, city); + if (avatar != null) wrapper.set(SysUser::getAvatar, avatar); + if (gender != null) wrapper.set(SysUser::getGender, gender); + sysUserMapper.update(null, wrapper); + } + + /** + * 获取子女列表(旧版 UserChild 表) + */ + public List getChildren(Long userId) { + return userChildMapper.selectList( + new LambdaQueryWrapper() + .eq(UserChild::getParentId, userId) + .eq(UserChild::getIsDeleted, 0) + .orderByDesc(UserChild::getCreateTime)); + } + + /** + * 创建子女信息 + */ + public UserChild createChild(Long userId, CreateChildDto dto) { + UserChild child = new UserChild(); + child.setParentId(userId); + child.setName(dto.getName()); + child.setGender(dto.getGender()); + if (dto.getBirthday() != null) { + child.setBirthday(LocalDate.parse(dto.getBirthday())); + } + child.setGrade(dto.getGrade()); + child.setCity(dto.getCity()); + child.setSchoolName(dto.getSchoolName()); + child.setAvatar(dto.getAvatar()); + child.setIsDeleted(0); + child.setCreateTime(LocalDateTime.now()); + child.setModifyTime(LocalDateTime.now()); + userChildMapper.insert(child); + return child; + } + + /** + * 获取单个子女信息 + */ + public UserChild getChild(Long id, Long userId) { + UserChild child = userChildMapper.selectById(id); + if (child == null || !child.getParentId().equals(userId) || child.getIsDeleted() == 1) { + throw new BusinessException(404, "子女信息不存在"); + } + return child; + } + + /** + * 更新子女信息 + */ + public void updateChild(Long id, Long userId, CreateChildDto dto) { + UserChild child = getChild(id, userId); + if (dto.getName() != null) child.setName(dto.getName()); + if (dto.getGender() != null) child.setGender(dto.getGender()); + if (dto.getBirthday() != null) child.setBirthday(LocalDate.parse(dto.getBirthday())); + if (dto.getGrade() != null) child.setGrade(dto.getGrade()); + if (dto.getCity() != null) child.setCity(dto.getCity()); + if (dto.getSchoolName() != null) child.setSchoolName(dto.getSchoolName()); + if (dto.getAvatar() != null) child.setAvatar(dto.getAvatar()); + child.setModifyTime(LocalDateTime.now()); + userChildMapper.updateById(child); + } + + /** + * 删除子女信息(软删除) + */ + public void deleteChild(Long id, Long userId) { + UserChild child = getChild(id, userId); + child.setIsDeleted(1); + child.setModifyTime(LocalDateTime.now()); + userChildMapper.updateById(child); + } + + /** + * 创建子女独立账号 + */ + @Transactional + public Map createChildAccount(Long parentUserId, String username, String password, + String nickname, String gender, String city, String avatar) { + // 找公众租户 + SysTenant publicTenant = sysTenantMapper.selectOne( + new LambdaQueryWrapper().eq(SysTenant::getCode, "public")); + if (publicTenant == null) { + throw new BusinessException(500, "公众租户未配置"); + } + + // 检查用户名是否已存在 + Long existCount = sysUserMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysUser::getUsername, username) + .eq(SysUser::getTenantId, publicTenant.getId())); + if (existCount > 0) { + throw new BusinessException(400, "用户名已存在"); + } + + // 创建子女用户 + SysUser childUser = new SysUser(); + childUser.setTenantId(publicTenant.getId()); + childUser.setUsername(username); + childUser.setPassword(passwordEncoder.encode(password)); + childUser.setNickname(nickname); + childUser.setGender(gender); + childUser.setCity(city); + childUser.setAvatar(avatar); + childUser.setUserSource("admin_created"); + childUser.setUserType("child"); + childUser.setStatus("enabled"); + sysUserMapper.insert(childUser); + + // 创建亲子关系 + UserParentChild relation = new UserParentChild(); + relation.setParentUserId(parentUserId); + relation.setChildUserId(childUser.getId()); + relation.setRelationship("parent"); + relation.setControlMode("full"); + relation.setCreateTime(LocalDateTime.now()); + userParentChildMapper.insert(relation); + + Map result = new LinkedHashMap<>(); + result.put("id", childUser.getId()); + result.put("username", childUser.getUsername()); + result.put("nickname", childUser.getNickname()); + result.put("userType", childUser.getUserType()); + return result; + } + + /** + * 获取子女账号列表 + */ + public List> getChildAccounts(Long parentUserId) { + List relations = userParentChildMapper.selectList( + new LambdaQueryWrapper() + .eq(UserParentChild::getParentUserId, parentUserId)); + if (relations.isEmpty()) { + return Collections.emptyList(); + } + + List childUserIds = relations.stream() + .map(UserParentChild::getChildUserId) + .collect(Collectors.toList()); + List children = sysUserMapper.selectBatchIds(childUserIds); + + return children.stream().map(child -> { + Map map = new LinkedHashMap<>(); + map.put("id", child.getId()); + map.put("username", child.getUsername()); + map.put("nickname", child.getNickname()); + map.put("avatar", child.getAvatar()); + map.put("gender", child.getGender()); + map.put("city", child.getCity()); + map.put("userType", child.getUserType()); + map.put("status", child.getStatus()); + return map; + }).collect(Collectors.toList()); + } + + /** + * 更新子女账号 + */ + public void updateChildAccount(Long childUserId, Long parentUserId, Map dto) { + // 验证亲子关系 + Long count = userParentChildMapper.selectCount( + new LambdaQueryWrapper() + .eq(UserParentChild::getParentUserId, parentUserId) + .eq(UserParentChild::getChildUserId, childUserId)); + if (count == 0) { + throw new BusinessException(403, "无权操作该子女账号"); + } + + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(SysUser::getId, childUserId); + if (dto.containsKey("nickname")) wrapper.set(SysUser::getNickname, (String) dto.get("nickname")); + if (dto.containsKey("avatar")) wrapper.set(SysUser::getAvatar, (String) dto.get("avatar")); + if (dto.containsKey("gender")) wrapper.set(SysUser::getGender, (String) dto.get("gender")); + if (dto.containsKey("city")) wrapper.set(SysUser::getCity, (String) dto.get("city")); + if (dto.containsKey("password")) { + wrapper.set(SysUser::getPassword, passwordEncoder.encode((String) dto.get("password"))); + } + sysUserMapper.update(null, wrapper); + } + + /** + * 获取家长信息(子女视角) + */ + public Map getParentInfo(Long childUserId) { + UserParentChild relation = userParentChildMapper.selectOne( + new LambdaQueryWrapper() + .eq(UserParentChild::getChildUserId, childUserId) + .last("LIMIT 1")); + if (relation == null) { + return null; + } + + SysUser parent = sysUserMapper.selectById(relation.getParentUserId()); + if (parent == null) { + return null; + } + + Map result = new LinkedHashMap<>(); + result.put("id", parent.getId()); + result.put("username", parent.getUsername()); + result.put("nickname", parent.getNickname()); + result.put("avatar", parent.getAvatar()); + result.put("phone", parent.getPhone()); + result.put("relationship", relation.getRelationship()); + return result; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicTagService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicTagService.java new file mode 100644 index 0000000..1e58bb4 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicTagService.java @@ -0,0 +1,39 @@ +package com.competition.modules.pub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.competition.modules.ugc.entity.UgcTag; +import com.competition.modules.ugc.mapper.UgcTagMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicTagService { + + private final UgcTagMapper ugcTagMapper; + + /** + * 获取所有启用标签 + */ + public List listTags() { + return ugcTagMapper.selectList( + new LambdaQueryWrapper() + .eq(UgcTag::getStatus, "enabled") + .orderByAsc(UgcTag::getSort)); + } + + /** + * 获取热门标签 + */ + public List getHotTags() { + return ugcTagMapper.selectList( + new LambdaQueryWrapper() + .eq(UgcTag::getStatus, "enabled") + .orderByDesc(UgcTag::getUsageCount) + .last("LIMIT 20")); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicUserWorkService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicUserWorkService.java new file mode 100644 index 0000000..b859bdd --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicUserWorkService.java @@ -0,0 +1,205 @@ +package com.competition.modules.pub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.ugc.entity.UgcWork; +import com.competition.modules.ugc.entity.UgcWorkPage; +import com.competition.modules.ugc.entity.UgcWorkTag; +import com.competition.modules.ugc.mapper.UgcWorkMapper; +import com.competition.modules.ugc.mapper.UgcWorkPageMapper; +import com.competition.modules.ugc.mapper.UgcWorkTagMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicUserWorkService { + + private final UgcWorkMapper ugcWorkMapper; + private final UgcWorkPageMapper ugcWorkPageMapper; + private final UgcWorkTagMapper ugcWorkTagMapper; + + /** + * 创建作品 + */ + @Transactional + public UgcWork create(Long userId, String title, String coverUrl, String description, + String visibility, List> pages, List tagIds) { + UgcWork work = new UgcWork(); + work.setUserId(userId); + work.setTitle(title); + work.setCoverUrl(coverUrl); + work.setDescription(description); + work.setVisibility(visibility != null ? visibility : "private"); + work.setStatus("draft"); + work.setViewCount(0); + work.setLikeCount(0); + work.setFavoriteCount(0); + work.setCommentCount(0); + work.setShareCount(0); + work.setIsRecommended(false); + work.setIsDeleted(0); + work.setCreateTime(LocalDateTime.now()); + work.setModifyTime(LocalDateTime.now()); + ugcWorkMapper.insert(work); + + // 保存页面 + if (pages != null && !pages.isEmpty()) { + saveWorkPages(work.getId(), pages); + } + + // 保存标签关联 + if (tagIds != null && !tagIds.isEmpty()) { + for (Long tagId : tagIds) { + UgcWorkTag wt = new UgcWorkTag(); + wt.setWorkId(work.getId()); + wt.setTagId(tagId); + ugcWorkTagMapper.insert(wt); + } + } + + return work; + } + + /** + * 查询我的作品(分页) + */ + public PageResult findMyWorks(Long userId, int page, int pageSize, String status, String keyword) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UgcWork::getUserId, userId) + .eq(UgcWork::getIsDeleted, 0); + if (StringUtils.hasText(status)) { + wrapper.eq(UgcWork::getStatus, status); + } + if (StringUtils.hasText(keyword)) { + wrapper.like(UgcWork::getTitle, keyword); + } + wrapper.orderByDesc(UgcWork::getCreateTime); + + IPage result = ugcWorkMapper.selectPage(new Page<>(page, pageSize), wrapper); + return PageResult.from(result); + } + + /** + * 获取作品详情 + */ + public Map findDetail(Long id, Long userId) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null || work.getIsDeleted() == 1) { + throw new BusinessException(404, "作品不存在"); + } + if (!work.getUserId().equals(userId)) { + throw new BusinessException(403, "无权访问该作品"); + } + + List pages = ugcWorkPageMapper.selectList( + new LambdaQueryWrapper() + .eq(UgcWorkPage::getWorkId, id) + .orderByAsc(UgcWorkPage::getPageNo)); + + Map result = new LinkedHashMap<>(); + result.put("work", work); + result.put("pages", pages); + return result; + } + + /** + * 更新作品 + */ + public void update(Long id, Long userId, Map dto) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null || work.getIsDeleted() == 1 || !work.getUserId().equals(userId)) { + throw new BusinessException(404, "作品不存在或无权操作"); + } + + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(UgcWork::getId, id); + if (dto.containsKey("title")) wrapper.set(UgcWork::getTitle, dto.get("title")); + if (dto.containsKey("coverUrl")) wrapper.set(UgcWork::getCoverUrl, dto.get("coverUrl")); + if (dto.containsKey("description")) wrapper.set(UgcWork::getDescription, dto.get("description")); + if (dto.containsKey("visibility")) wrapper.set(UgcWork::getVisibility, dto.get("visibility")); + wrapper.set(UgcWork::getModifyTime, LocalDateTime.now()); + ugcWorkMapper.update(null, wrapper); + } + + /** + * 删除作品(软删除) + */ + public void delete(Long id, Long userId) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null || work.getIsDeleted() == 1 || !work.getUserId().equals(userId)) { + throw new BusinessException(404, "作品不存在或无权操作"); + } + work.setIsDeleted(1); + work.setModifyTime(LocalDateTime.now()); + ugcWorkMapper.updateById(work); + } + + /** + * 发布作品(提交审核) + */ + public void publish(Long id, Long userId) { + UgcWork work = ugcWorkMapper.selectById(id); + if (work == null || work.getIsDeleted() == 1 || !work.getUserId().equals(userId)) { + throw new BusinessException(404, "作品不存在或无权操作"); + } + if (!"draft".equals(work.getStatus()) && !"rejected".equals(work.getStatus())) { + throw new BusinessException(400, "当前状态不可发布"); + } + work.setStatus("pending_review"); + work.setModifyTime(LocalDateTime.now()); + ugcWorkMapper.updateById(work); + } + + /** + * 获取作品页面 + */ + public List getPages(Long workId) { + return ugcWorkPageMapper.selectList( + new LambdaQueryWrapper() + .eq(UgcWorkPage::getWorkId, workId) + .orderByAsc(UgcWorkPage::getPageNo)); + } + + /** + * 保存作品页面(覆盖式) + */ + @Transactional + public void savePages(Long workId, Long userId, List> pages) { + UgcWork work = ugcWorkMapper.selectById(workId); + if (work == null || work.getIsDeleted() == 1 || !work.getUserId().equals(userId)) { + throw new BusinessException(404, "作品不存在或无权操作"); + } + + // 删除旧页面 + ugcWorkPageMapper.delete( + new LambdaQueryWrapper().eq(UgcWorkPage::getWorkId, workId)); + + // 插入新页面 + saveWorkPages(workId, pages); + } + + private void saveWorkPages(Long workId, List> pages) { + for (int i = 0; i < pages.size(); i++) { + Map p = pages.get(i); + UgcWorkPage page = new UgcWorkPage(); + page.setWorkId(workId); + page.setPageNo(i + 1); + page.setImageUrl((String) p.get("imageUrl")); + page.setText((String) p.get("text")); + page.setAudioUrl((String) p.get("audioUrl")); + ugcWorkPageMapper.insert(page); + } + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/controller/AuthController.java b/backend-java/src/main/java/com/competition/modules/sys/controller/AuthController.java new file mode 100644 index 0000000..5c14f77 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/controller/AuthController.java @@ -0,0 +1,48 @@ +package com.competition.modules.sys.controller; + +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.sys.dto.LoginDto; +import com.competition.modules.sys.service.AuthService; +import com.competition.security.annotation.Public; +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.Map; + +@Tag(name = "认证管理") +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @Public + @PostMapping("/login") + @Operation(summary = "登录") + public Result> login(@Valid @RequestBody LoginDto dto, HttpServletRequest request) { + // 从请求头获取租户 ID + String tenantIdHeader = request.getHeader("X-Tenant-Id"); + Long tenantId = tenantIdHeader != null ? Long.parseLong(tenantIdHeader) : null; + + return Result.success(authService.login(dto.getUsername(), dto.getPassword(), tenantId)); + } + + @GetMapping("/user-info") + @Operation(summary = "获取当前用户信息") + public Result> getUserInfo() { + Long userId = SecurityUtil.getCurrentUserId(); + return Result.success(authService.getUserInfo(userId)); + } + + @PostMapping("/logout") + @Operation(summary = "登出") + public Result> logout() { + return Result.success(Map.of("message", "登出成功")); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/controller/SysConfigController.java b/backend-java/src/main/java/com/competition/modules/sys/controller/SysConfigController.java new file mode 100644 index 0000000..612c7c8 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/controller/SysConfigController.java @@ -0,0 +1,67 @@ +package com.competition.modules.sys.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.sys.dto.CreateConfigDto; +import com.competition.modules.sys.entity.SysConfig; +import com.competition.modules.sys.service.ISysConfigService; +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.*; + +@Tag(name = "系统配置") +@RestController +@RequestMapping("/config") +@RequiredArgsConstructor +public class SysConfigController { + + private final ISysConfigService configService; + + @PostMapping + @Operation(summary = "创建配置") + public Result create(@Valid @RequestBody CreateConfigDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(configService.createConfig(dto, tenantId)); + } + + @GetMapping + @Operation(summary = "查询配置列表") + public Result> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(configService.findAll(page, pageSize, tenantId)); + } + + @GetMapping("/key/{key}") + @Operation(summary = "根据键查询配置") + public Result findByKey(@PathVariable String key) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(configService.findByKey(key, tenantId)); + } + + @GetMapping("/{id}") + @Operation(summary = "查询配置详情") + public Result findOne(@PathVariable Long id) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(configService.findDetail(id, tenantId)); + } + + @PatchMapping("/{id}") + @Operation(summary = "更新配置") + public Result update(@PathVariable Long id, @RequestBody CreateConfigDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(configService.updateConfig(id, dto, tenantId)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除配置") + public Result remove(@PathVariable Long id) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + configService.removeConfig(id, tenantId); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/controller/SysDictController.java b/backend-java/src/main/java/com/competition/modules/sys/controller/SysDictController.java new file mode 100644 index 0000000..1bffedb --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/controller/SysDictController.java @@ -0,0 +1,67 @@ +package com.competition.modules.sys.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.sys.dto.CreateDictDto; +import com.competition.modules.sys.entity.SysDict; +import com.competition.modules.sys.service.ISysDictService; +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.*; + +@Tag(name = "数据字典") +@RestController +@RequestMapping("/dict") +@RequiredArgsConstructor +public class SysDictController { + + private final ISysDictService dictService; + + @PostMapping + @Operation(summary = "创建字典") + public Result create(@Valid @RequestBody CreateDictDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(dictService.createDict(dto, tenantId)); + } + + @GetMapping + @Operation(summary = "查询字典列表") + public Result> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(dictService.findAll(page, pageSize, tenantId)); + } + + @GetMapping("/code/{code}") + @Operation(summary = "根据编码查询字典") + public Result findByCode(@PathVariable String code) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(dictService.findByCode(code, tenantId)); + } + + @GetMapping("/{id}") + @Operation(summary = "查询字典详情") + public Result findOne(@PathVariable Long id) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(dictService.findDetail(id, tenantId)); + } + + @PatchMapping("/{id}") + @Operation(summary = "更新字典") + public Result update(@PathVariable Long id, @RequestBody CreateDictDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(dictService.updateDict(id, dto, tenantId)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除字典") + public Result remove(@PathVariable Long id) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + dictService.removeDict(id, tenantId); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/controller/SysLogController.java b/backend-java/src/main/java/com/competition/modules/sys/controller/SysLogController.java new file mode 100644 index 0000000..170979f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/controller/SysLogController.java @@ -0,0 +1,60 @@ +package com.competition.modules.sys.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.modules.sys.dto.QueryLogDto; +import com.competition.modules.sys.service.ISysLogService; +import com.competition.security.annotation.RequirePermission; +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; +import java.util.Map; + +@Tag(name = "系统日志") +@RestController +@RequestMapping("/logs") +@RequiredArgsConstructor +public class SysLogController { + + private final ISysLogService logService; + + @GetMapping + @RequirePermission("log:read") + @Operation(summary = "查询日志列表") + public Result>> findAll(QueryLogDto dto) { + return Result.success(logService.findAll(dto)); + } + + @GetMapping("/statistics") + @RequirePermission("log:read") + @Operation(summary = "日志统计") + public Result> getStatistics(@RequestParam(required = false) Integer days) { + return Result.success(logService.getStatistics(days)); + } + + @GetMapping("/{id}") + @RequirePermission("log:read") + @Operation(summary = "查询日志详情") + public Result> findOne(@PathVariable Long id) { + return Result.success(logService.findDetail(id)); + } + + @DeleteMapping + @RequirePermission("log:delete") + @Operation(summary = "批量删除日志") + public Result batchDelete(@RequestBody Map> body) { + logService.batchDelete(body.get("ids")); + return Result.success(); + } + + @PostMapping("/clean") + @RequirePermission("log:delete") + @Operation(summary = "清理旧日志") + public Result> cleanOldLogs(@RequestBody(required = false) Map body) { + Integer daysToKeep = body != null ? body.get("daysToKeep") : null; + return Result.success(logService.cleanOldLogs(daysToKeep)); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/controller/SysMenuController.java b/backend-java/src/main/java/com/competition/modules/sys/controller/SysMenuController.java new file mode 100644 index 0000000..2a7ac3f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/controller/SysMenuController.java @@ -0,0 +1,65 @@ +package com.competition.modules.sys.controller; + +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.sys.dto.CreateMenuDto; +import com.competition.modules.sys.entity.SysMenu; +import com.competition.modules.sys.service.ISysMenuService; +import com.competition.modules.sys.service.ISysTenantService; +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("/menus") +@RequiredArgsConstructor +public class SysMenuController { + + private final ISysMenuService menuService; + private final ISysTenantService tenantService; + + @PostMapping + @Operation(summary = "创建菜单") + public Result create(@Valid @RequestBody CreateMenuDto dto) { + return Result.success(menuService.createMenu(dto)); + } + + @GetMapping + @Operation(summary = "查询菜单列表(树形)") + public Result> findAll() { + return Result.success(menuService.findAllTree()); + } + + @GetMapping("/user-menus") + @Operation(summary = "获取当前用户菜单") + public Result> getUserMenus() { + Long userId = SecurityUtil.getCurrentUserId(); + Long tenantId = SecurityUtil.getCurrentTenantId(); + boolean isSuperAdmin = SecurityUtil.isSuperAdmin() || tenantService.isSuperTenant(tenantId); + return Result.success(menuService.getUserMenus(userId, tenantId, isSuperAdmin)); + } + + @GetMapping("/{id}") + @Operation(summary = "查询菜单详情") + public Result findOne(@PathVariable Long id) { + return Result.success(menuService.findDetail(id)); + } + + @PatchMapping("/{id}") + @Operation(summary = "更新菜单") + public Result update(@PathVariable Long id, @RequestBody CreateMenuDto dto) { + return Result.success(menuService.updateMenu(id, dto)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除菜单") + public Result remove(@PathVariable Long id) { + menuService.removeMenu(id); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/controller/SysPermissionController.java b/backend-java/src/main/java/com/competition/modules/sys/controller/SysPermissionController.java new file mode 100644 index 0000000..f945eca --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/controller/SysPermissionController.java @@ -0,0 +1,60 @@ +package com.competition.modules.sys.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.sys.dto.CreatePermissionDto; +import com.competition.modules.sys.entity.SysPermission; +import com.competition.modules.sys.service.ISysPermissionService; +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.*; + +@Tag(name = "权限管理") +@RestController +@RequestMapping("/permissions") +@RequiredArgsConstructor +public class SysPermissionController { + + private final ISysPermissionService permissionService; + + @PostMapping + @Operation(summary = "创建权限") + public Result create(@Valid @RequestBody CreatePermissionDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(permissionService.createPermission(dto, tenantId)); + } + + @GetMapping + @Operation(summary = "查询权限列表") + public Result> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(permissionService.findAll(page, pageSize, tenantId)); + } + + @GetMapping("/{id}") + @Operation(summary = "查询权限详情") + public Result findOne(@PathVariable Long id) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(permissionService.findDetail(id, tenantId)); + } + + @PatchMapping("/{id}") + @Operation(summary = "更新权限") + public Result update(@PathVariable Long id, @RequestBody CreatePermissionDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(permissionService.updatePermission(id, dto, tenantId)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除权限") + public Result remove(@PathVariable Long id) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + permissionService.removePermission(id, tenantId); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/controller/SysRoleController.java b/backend-java/src/main/java/com/competition/modules/sys/controller/SysRoleController.java new file mode 100644 index 0000000..05d0d29 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/controller/SysRoleController.java @@ -0,0 +1,62 @@ +package com.competition.modules.sys.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.sys.dto.CreateRoleDto; +import com.competition.modules.sys.dto.UpdateRoleDto; +import com.competition.modules.sys.service.ISysRoleService; +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.Map; + +@Tag(name = "角色管理") +@RestController +@RequestMapping("/roles") +@RequiredArgsConstructor +public class SysRoleController { + + private final ISysRoleService roleService; + + @PostMapping + @Operation(summary = "创建角色") + public Result> create(@Valid @RequestBody CreateRoleDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(roleService.createRole(dto, tenantId)); + } + + @GetMapping + @Operation(summary = "查询角色列表") + public Result>> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(roleService.findAll(page, pageSize, tenantId)); + } + + @GetMapping("/{id}") + @Operation(summary = "查询角色详情") + public Result> findOne(@PathVariable Long id) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(roleService.findDetail(id, tenantId)); + } + + @PatchMapping("/{id}") + @Operation(summary = "更新角色") + public Result> update(@PathVariable Long id, @RequestBody UpdateRoleDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(roleService.updateRole(id, dto, tenantId)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除角色") + public Result remove(@PathVariable Long id) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + roleService.removeRole(id, tenantId); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/controller/SysTenantController.java b/backend-java/src/main/java/com/competition/modules/sys/controller/SysTenantController.java new file mode 100644 index 0000000..fd122b6 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/controller/SysTenantController.java @@ -0,0 +1,93 @@ +package com.competition.modules.sys.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.sys.dto.CreateTenantDto; +import com.competition.modules.sys.dto.UpdateTenantDto; +import com.competition.modules.sys.entity.SysTenant; +import com.competition.modules.sys.service.ISysTenantService; +import com.competition.security.annotation.RequirePermission; +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.Map; + +@Tag(name = "租户管理") +@RestController +@RequestMapping("/tenants") +@RequiredArgsConstructor +public class SysTenantController { + + private final ISysTenantService tenantService; + + @PostMapping + @RequirePermission("tenant:create") + @Operation(summary = "创建租户") + public Result create(@Valid @RequestBody CreateTenantDto dto) { + Long currentTenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(tenantService.createTenant(dto, currentTenantId)); + } + + @GetMapping + @RequirePermission("tenant:read") + @Operation(summary = "查询租户列表") + public Result>> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String tenantType) { + return Result.success(tenantService.findAll(page, pageSize, keyword, tenantType)); + } + + @PatchMapping("/{id}/status") + @RequirePermission("tenant:update") + @Operation(summary = "切换租户状态") + public Result toggleStatus(@PathVariable Long id) { + Long currentTenantId = SecurityUtil.getCurrentTenantId(); + tenantService.toggleStatus(id, currentTenantId); + return Result.success(); + } + + @GetMapping("/my-tenant") + @Operation(summary = "获取当前租户信息") + public Result getMyTenant() { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(tenantService.getMyTenant(tenantId)); + } + + @PatchMapping("/my-tenant") + @Operation(summary = "更新当前租户信息") + public Result updateMyTenant(@RequestBody Map body) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + tenantService.updateMyTenant(tenantId, body.get("name"), body.get("description")); + return Result.success(); + } + + @GetMapping("/{id}") + @RequirePermission("tenant:read") + @Operation(summary = "查询租户详情") + public Result> findOne(@PathVariable Long id) { + return Result.success(tenantService.findDetail(id)); + } + + @PatchMapping("/{id}") + @RequirePermission("tenant:update") + @Operation(summary = "更新租户") + public Result update(@PathVariable Long id, @RequestBody UpdateTenantDto dto) { + Long currentTenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(tenantService.updateTenant(id, dto, currentTenantId)); + } + + @DeleteMapping("/{id}") + @RequirePermission("tenant:delete") + @Operation(summary = "删除租户") + public Result remove(@PathVariable Long id) { + Long currentTenantId = SecurityUtil.getCurrentTenantId(); + tenantService.removeTenant(id, currentTenantId); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/controller/SysUserController.java b/backend-java/src/main/java/com/competition/modules/sys/controller/SysUserController.java new file mode 100644 index 0000000..82414d2 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/controller/SysUserController.java @@ -0,0 +1,87 @@ +package com.competition.modules.sys.controller; + +import com.competition.common.result.PageResult; +import com.competition.common.result.Result; +import com.competition.common.util.SecurityUtil; +import com.competition.modules.sys.dto.CreateUserDto; +import com.competition.modules.sys.dto.UpdateUserDto; +import com.competition.modules.sys.service.ISysUserService; +import com.competition.modules.sys.service.ISysTenantService; +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.Map; + +@Tag(name = "用户管理") +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +public class SysUserController { + + private final ISysUserService userService; + private final ISysTenantService tenantService; + + @PostMapping + @Operation(summary = "创建用户") + public Result> create(@Valid @RequestBody CreateUserDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + return Result.success(userService.createUser(dto, tenantId)); + } + + @GetMapping("/stats") + @Operation(summary = "用户统计") + public Result> getStats() { + return Result.success(userService.getStats()); + } + + @GetMapping + @Operation(summary = "查询用户列表") + public Result>> findAll( + @RequestParam(defaultValue = "1") Long page, + @RequestParam(defaultValue = "10") Long pageSize, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String userType, + @RequestParam(required = false) Long filterTenantId, + @RequestParam(required = false) String userSource, + @RequestParam(required = false) String status) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + boolean isSuperTenant = SecurityUtil.isSuperAdmin() || tenantService.isSuperTenant(tenantId); + return Result.success(userService.findAll(page, pageSize, tenantId, keyword, isSuperTenant, userType, filterTenantId, userSource, status)); + } + + @GetMapping("/{id}") + @Operation(summary = "查询用户详情") + public Result> findOne(@PathVariable Long id) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + boolean isSuperTenant = tenantService.isSuperTenant(tenantId); + return Result.success(userService.findDetail(id, tenantId, isSuperTenant)); + } + + @PatchMapping("/{id}/status") + @Operation(summary = "启用/禁用用户") + public Result updateStatus(@PathVariable Long id, @RequestBody Map body) { + String status = body.get("status"); + Long operatorId = SecurityUtil.getCurrentUserId(); + userService.updateStatus(id, status, operatorId); + return Result.success(); + } + + @PatchMapping("/{id}") + @Operation(summary = "更新用户") + public Result> update(@PathVariable Long id, @RequestBody UpdateUserDto dto) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + boolean isSuperTenant = tenantService.isSuperTenant(tenantId); + return Result.success(userService.updateUser(id, dto, isSuperTenant ? null : tenantId)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除用户") + public Result remove(@PathVariable Long id) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + userService.removeUser(id, tenantId); + return Result.success(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/CreateConfigDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateConfigDto.java new file mode 100644 index 0000000..a3c3edd --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateConfigDto.java @@ -0,0 +1,21 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "创建/更新配置请求") +public class CreateConfigDto { + + @NotBlank(message = "配置键不能为空") + @Schema(description = "配置键") + private String key; + + @NotBlank(message = "配置值不能为空") + @Schema(description = "配置值") + private String value; + + @Schema(description = "描述") + private String description; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/CreateDictDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateDictDto.java new file mode 100644 index 0000000..aa9f213 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateDictDto.java @@ -0,0 +1,33 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "创建/更新字典请求") +public class CreateDictDto { + + @NotBlank(message = "字典名称不能为空") + @Schema(description = "字典名称") + private String name; + + @NotBlank(message = "字典编码不能为空") + @Schema(description = "字典编码") + private String code; + + @Schema(description = "描述") + private String description; + + @Schema(description = "字典项列表") + private List items; + + @Data + public static class DictItemDto { + private String label; + private String value; + private Integer sort; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/CreateMenuDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateMenuDto.java new file mode 100644 index 0000000..211d02c --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateMenuDto.java @@ -0,0 +1,32 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "创建/更新菜单请求") +public class CreateMenuDto { + + @NotBlank(message = "菜单名称不能为空") + @Schema(description = "菜单名称") + private String name; + + @Schema(description = "路由路径") + private String path; + + @Schema(description = "图标") + private String icon; + + @Schema(description = "前端组件路径") + private String component; + + @Schema(description = "父菜单 ID") + private Long parentId; + + @Schema(description = "权限标识") + private String permission; + + @Schema(description = "排序") + private Integer sort; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/CreatePermissionDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/CreatePermissionDto.java new file mode 100644 index 0000000..ee62d20 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/CreatePermissionDto.java @@ -0,0 +1,27 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "创建权限请求") +public class CreatePermissionDto { + + @NotBlank(message = "权限名称不能为空") + @Schema(description = "权限名称") + private String name; + + @NotBlank(message = "权限编码不能为空") + @Schema(description = "权限编码(格式:resource:action)") + private String code; + + @Schema(description = "资源") + private String resource; + + @Schema(description = "操作") + private String action; + + @Schema(description = "描述") + private String description; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/CreateRoleDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateRoleDto.java new file mode 100644 index 0000000..80179cf --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateRoleDto.java @@ -0,0 +1,26 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "创建角色请求") +public class CreateRoleDto { + + @NotBlank(message = "角色名称不能为空") + @Schema(description = "角色名称") + private String name; + + @NotBlank(message = "角色编码不能为空") + @Schema(description = "角色编码") + private String code; + + @Schema(description = "角色描述") + private String description; + + @Schema(description = "权限 ID 列表") + private List permissionIds; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/CreateTenantDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateTenantDto.java new file mode 100644 index 0000000..ce3e817 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateTenantDto.java @@ -0,0 +1,32 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "创建租户请求") +public class CreateTenantDto { + + @NotBlank(message = "租户名称不能为空") + @Schema(description = "租户名称") + private String name; + + @NotBlank(message = "租户编码不能为空") + @Schema(description = "租户编码(唯一,用于访问链接)") + private String code; + + @Schema(description = "租户域名") + private String domain; + + @Schema(description = "租户描述") + private String description; + + @Schema(description = "租户类型:library/kindergarten/school/institution/other") + private String tenantType; + + @Schema(description = "分配菜单 ID 列表") + private List menuIds; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/CreateUserDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateUserDto.java new file mode 100644 index 0000000..e3f3f39 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/CreateUserDto.java @@ -0,0 +1,42 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "创建用户请求") +public class CreateUserDto { + + @NotBlank(message = "用户名不能为空") + @Schema(description = "用户名") + private String username; + + @NotBlank(message = "密码不能为空") + @Schema(description = "密码") + private String password; + + @NotBlank(message = "昵称不能为空") + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "性别:male/female") + private String gender; + + @Schema(description = "头像 URL") + private String avatar; + + @Schema(description = "账号状态:enabled/disabled") + private String status; + + @Schema(description = "角色 ID 列表") + private List roleIds; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/LoginDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/LoginDto.java new file mode 100644 index 0000000..ea7b354 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/LoginDto.java @@ -0,0 +1,18 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "登录请求") +public class LoginDto { + + @NotBlank(message = "用户名不能为空") + @Schema(description = "用户名") + private String username; + + @NotBlank(message = "密码不能为空") + @Schema(description = "密码") + private String password; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/QueryLogDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/QueryLogDto.java new file mode 100644 index 0000000..eb6ff5d --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/QueryLogDto.java @@ -0,0 +1,33 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "日志查询参数") +public class QueryLogDto { + + @Schema(description = "页码", defaultValue = "1") + private Long page = 1L; + + @Schema(description = "每页大小", defaultValue = "20") + private Long pageSize = 20L; + + @Schema(description = "用户 ID") + private Long userId; + + @Schema(description = "操作类型") + private String action; + + @Schema(description = "关键字搜索") + private String keyword; + + @Schema(description = "IP 地址") + private String ip; + + @Schema(description = "开始时间") + private String startTime; + + @Schema(description = "结束时间") + private String endTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/UpdateRoleDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/UpdateRoleDto.java new file mode 100644 index 0000000..562f45d --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/UpdateRoleDto.java @@ -0,0 +1,23 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "更新角色请求") +public class UpdateRoleDto { + + @Schema(description = "角色名称") + private String name; + + @Schema(description = "角色编码") + private String code; + + @Schema(description = "角色描述") + private String description; + + @Schema(description = "权限 ID 列表") + private List permissionIds; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/UpdateTenantDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/UpdateTenantDto.java new file mode 100644 index 0000000..fe5cefb --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/UpdateTenantDto.java @@ -0,0 +1,29 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "更新租户请求") +public class UpdateTenantDto { + + @Schema(description = "租户名称") + private String name; + + @Schema(description = "租户编码") + private String code; + + @Schema(description = "租户域名") + private String domain; + + @Schema(description = "租户描述") + private String description; + + @Schema(description = "租户类型") + private String tenantType; + + @Schema(description = "分配菜单 ID 列表") + private List menuIds; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/dto/UpdateUserDto.java b/backend-java/src/main/java/com/competition/modules/sys/dto/UpdateUserDto.java new file mode 100644 index 0000000..811d665 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/dto/UpdateUserDto.java @@ -0,0 +1,38 @@ +package com.competition.modules.sys.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "更新用户请求") +public class UpdateUserDto { + + @Schema(description = "用户名") + private String username; + + @Schema(description = "密码(如需修改)") + private String password; + + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "性别:male/female") + private String gender; + + @Schema(description = "头像 URL") + private String avatar; + + @Schema(description = "账号状态:enabled/disabled") + private String status; + + @Schema(description = "角色 ID 列表") + private List roleIds; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysConfig.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysConfig.java new file mode 100644 index 0000000..654ab9e --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysConfig.java @@ -0,0 +1,30 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 系统配置实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("configs") +public class SysConfig extends BaseEntity { + + /** 租户 ID */ + @TableField("tenant_id") + private Long tenantId; + + /** 配置键(租户内唯一) */ + @TableField("`key`") + private String key; + + /** 配置值 */ + private String value; + + /** 描述 */ + private String description; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysDict.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysDict.java new file mode 100644 index 0000000..5810118 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysDict.java @@ -0,0 +1,35 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 数据字典实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("dicts") +public class SysDict extends BaseEntity { + + /** 租户 ID */ + @TableField("tenant_id") + private Long tenantId; + + /** 字典名称 */ + private String name; + + /** 字典编码(租户内唯一) */ + private String code; + + /** 描述 */ + private String description; + + /** 字典项(非数据库字段) */ + @TableField(exist = false) + private List items; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysDictItem.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysDictItem.java new file mode 100644 index 0000000..ff9accb --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysDictItem.java @@ -0,0 +1,29 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 数据字典项实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("dict_items") +public class SysDictItem extends BaseEntity { + + /** 字典 ID */ + @TableField("dict_id") + private Long dictId; + + /** 标签 */ + private String label; + + /** 值 */ + private String value; + + /** 排序 */ + private Integer sort; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysLog.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysLog.java new file mode 100644 index 0000000..59383b7 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysLog.java @@ -0,0 +1,39 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 系统日志实体(不继承 BaseEntity,字段结构不同) + */ +@Data +@TableName("logs") +public class SysLog implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 用户 ID */ + @TableField("user_id") + private Long userId; + + /** 操作 */ + private String action; + + /** 内容 */ + private String content; + + /** IP 地址 */ + private String ip; + + /** User Agent */ + @TableField("user_agent") + private String userAgent; + + /** 创建时间 */ + @TableField("create_time") + private LocalDateTime createTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysMenu.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysMenu.java new file mode 100644 index 0000000..4eb517d --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysMenu.java @@ -0,0 +1,44 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 菜单实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("menus") +public class SysMenu extends BaseEntity { + + /** 菜单名称 */ + private String name; + + /** 路由路径 */ + private String path; + + /** 图标 */ + private String icon; + + /** 前端组件路径 */ + private String component; + + /** 父菜单 ID */ + @TableField("parent_id") + private Long parentId; + + /** 权限标识 */ + private String permission; + + /** 排序 */ + private Integer sort; + + /** 子菜单(非数据库字段) */ + @TableField(exist = false) + private List children; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysPermission.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysPermission.java new file mode 100644 index 0000000..5e4970b --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysPermission.java @@ -0,0 +1,35 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 权限实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("permissions") +public class SysPermission extends BaseEntity { + + /** 租户 ID */ + @TableField("tenant_id") + private Long tenantId; + + /** 权限名称 */ + private String name; + + /** 权限编码(格式:resource:action) */ + private String code; + + /** 资源 */ + private String resource; + + /** 操作 */ + private String action; + + /** 描述 */ + private String description; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysRole.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysRole.java new file mode 100644 index 0000000..f2bf38e --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysRole.java @@ -0,0 +1,29 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 角色实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("roles") +public class SysRole extends BaseEntity { + + /** 租户 ID */ + @TableField("tenant_id") + private Long tenantId; + + /** 角色名称 */ + private String name; + + /** 角色编码 */ + private String code; + + /** 角色描述 */ + private String description; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysRolePermission.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysRolePermission.java new file mode 100644 index 0000000..27026cf --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysRolePermission.java @@ -0,0 +1,23 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; + +/** + * 角色权限关联实体 + */ +@Data +@TableName("role_permissions") +public class SysRolePermission implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("role_id") + private Long roleId; + + @TableField("permission_id") + private Long permissionId; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysTenant.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysTenant.java new file mode 100644 index 0000000..f2ef9ec --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysTenant.java @@ -0,0 +1,36 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 租户实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("tenants") +public class SysTenant extends BaseEntity { + + /** 租户名称 */ + private String name; + + /** 租户编码(唯一,用于访问链接) */ + private String code; + + /** 租户域名(可选) */ + private String domain; + + /** 租户描述 */ + private String description; + + /** 是否为超级租户:0-否,1-是 */ + @TableField("is_super") + private Integer isSuper; + + /** 租户类型:platform/library/kindergarten/school/institution/other */ + @TableField("tenant_type") + private String tenantType; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysTenantMenu.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysTenantMenu.java new file mode 100644 index 0000000..b5e6160 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysTenantMenu.java @@ -0,0 +1,23 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; + +/** + * 租户菜单关联实体 + */ +@Data +@TableName("tenant_menus") +public class SysTenantMenu implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("tenant_id") + private Long tenantId; + + @TableField("menu_id") + private Long menuId; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysUser.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysUser.java new file mode 100644 index 0000000..e96fe48 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysUser.java @@ -0,0 +1,71 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.competition.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDate; + +/** + * 用户实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("users") +public class SysUser extends BaseEntity { + + /** 租户 ID */ + @TableField("tenant_id") + private Long tenantId; + + /** 用户名(在租户内唯一) */ + private String username; + + /** 密码(加密存储) */ + private String password; + + /** 昵称 */ + private String nickname; + + /** 邮箱 */ + private String email; + + /** 手机号(全局唯一) */ + private String phone; + + /** 微信 OpenID */ + @TableField("wx_openid") + private String wxOpenid; + + /** 微信 UnionID */ + @TableField("wx_unionid") + private String wxUnionid; + + /** 用户来源:admin_created/self_registered/child_migrated */ + @TableField("user_source") + private String userSource; + + /** 用户类型:adult/child */ + @TableField("user_type") + private String userType; + + /** 所在城市 */ + private String city; + + /** 出生日期 */ + private LocalDate birthday; + + /** 性别 */ + private String gender; + + /** 头像 URL */ + private String avatar; + + /** 所属单位 */ + private String organization; + + /** 账号状态:enabled/disabled */ + private String status; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/entity/SysUserRole.java b/backend-java/src/main/java/com/competition/modules/sys/entity/SysUserRole.java new file mode 100644 index 0000000..1f01cf4 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/entity/SysUserRole.java @@ -0,0 +1,23 @@ +package com.competition.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; + +/** + * 用户角色关联实体 + */ +@Data +@TableName("user_roles") +public class SysUserRole implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("user_id") + private Long userId; + + @TableField("role_id") + private Long roleId; +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysConfigMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysConfigMapper.java new file mode 100644 index 0000000..28d5c76 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysConfigMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.sys.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.sys.entity.SysConfig; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysConfigMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysDictItemMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysDictItemMapper.java new file mode 100644 index 0000000..01ef6f9 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysDictItemMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.sys.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.sys.entity.SysDictItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysDictItemMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysDictMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysDictMapper.java new file mode 100644 index 0000000..9885e66 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysDictMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.sys.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.sys.entity.SysDict; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysDictMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysLogMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysLogMapper.java new file mode 100644 index 0000000..35515b5 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysLogMapper.java @@ -0,0 +1,23 @@ +package com.competition.modules.sys.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.sys.entity.SysLog; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Mapper +public interface SysLogMapper extends BaseMapper { + + /** 按操作类型统计(Top 10) */ + @Select("SELECT action, COUNT(*) AS count FROM logs WHERE create_time >= #{since} GROUP BY action ORDER BY count DESC LIMIT 10") + List> selectActionStats(@Param("since") LocalDateTime since); + + /** 按日期统计 */ + @Select("SELECT DATE(create_time) AS date, COUNT(*) AS count FROM logs WHERE create_time >= #{since} GROUP BY DATE(create_time) ORDER BY date") + List> selectDailyStats(@Param("since") LocalDateTime since); +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysMenuMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysMenuMapper.java new file mode 100644 index 0000000..2b3849a --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysMenuMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.sys.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.sys.entity.SysMenu; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysMenuMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysPermissionMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysPermissionMapper.java new file mode 100644 index 0000000..f266215 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysPermissionMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.sys.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.sys.entity.SysPermission; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysPermissionMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysRoleMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysRoleMapper.java new file mode 100644 index 0000000..bc398b9 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysRoleMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.sys.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.sys.entity.SysRole; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysRoleMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysRolePermissionMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysRolePermissionMapper.java new file mode 100644 index 0000000..d928223 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysRolePermissionMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.sys.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.sys.entity.SysRolePermission; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysRolePermissionMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysTenantMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysTenantMapper.java new file mode 100644 index 0000000..8f2a459 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysTenantMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.sys.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.sys.entity.SysTenant; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysTenantMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysTenantMenuMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysTenantMenuMapper.java new file mode 100644 index 0000000..01e967c --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysTenantMenuMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.sys.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.sys.entity.SysTenantMenu; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysTenantMenuMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysUserMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysUserMapper.java new file mode 100644 index 0000000..9f9f387 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysUserMapper.java @@ -0,0 +1,40 @@ +package com.competition.modules.sys.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.competition.modules.sys.entity.SysUser; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface SysUserMapper extends BaseMapper { + + /** + * 查询用户列表(含角色和租户信息) + */ + IPage> selectUserPage(Page page, @Param("params") Map params); + + /** + * 查询用户详情(含角色、权限、租户信息) + */ + Map selectUserDetail(@Param("userId") Long userId, @Param("tenantId") Long tenantId, @Param("isSuperTenant") boolean isSuperTenant); + + /** + * 根据用户名查询(含角色和权限信息,用于登录) + */ + Map selectByUsernameWithRoles(@Param("username") String username, @Param("tenantId") Long tenantId); + + /** + * 查询用户的权限码列表 + */ + List selectPermissionsByUserId(@Param("userId") Long userId); + + /** + * 查询用户的角色码列表 + */ + List selectRolesByUserId(@Param("userId") Long userId); +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/mapper/SysUserRoleMapper.java b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysUserRoleMapper.java new file mode 100644 index 0000000..e50fa72 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/mapper/SysUserRoleMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.sys.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.sys.entity.SysUserRole; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysUserRoleMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/AuthService.java b/backend-java/src/main/java/com/competition/modules/sys/service/AuthService.java new file mode 100644 index 0000000..6651136 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/AuthService.java @@ -0,0 +1,146 @@ +package com.competition.modules.sys.service; + +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.modules.sys.entity.SysTenant; +import com.competition.modules.sys.entity.SysUser; +import com.competition.security.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * 认证服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final ISysUserService userService; + private final ISysTenantService tenantService; + private final JwtUtil jwtUtil; + private final PasswordEncoder passwordEncoder; + private final StringRedisTemplate redisTemplate; + + /** + * 登录 + */ + public Map login(String username, String password, Long tenantId) { + log.info("开始登录,用户名:{},租户:{}", username, tenantId); + + // 查找用户 + SysUser user = userService.findByUsername(username, tenantId); + if (user == null) { + throw BusinessException.of(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); + } + + // 验证密码 + if (!passwordEncoder.matches(password, user.getPassword())) { + throw BusinessException.of(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); + } + + // 验证用户状态 + if ("disabled".equals(user.getStatus())) { + throw BusinessException.of(ErrorCode.FORBIDDEN, "账号已被禁用"); + } + + Long userTenantId = user.getTenantId(); + + // 验证租户有效性 + SysTenant tenant = tenantService.getById(userTenantId); + if (tenant == null) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "租户不存在"); + } + if (tenant.getValidState() != 1) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "租户已失效"); + } + + // 获取角色和权限 + List roles = userService.getRoles(user.getId()); + List permissions = userService.getPermissions(user.getId()); + + // 缓存到 Redis + cacheUserAuth(user.getId(), roles, permissions); + + // 生成 JWT + String token = jwtUtil.generateToken(user.getId(), user.getUsername(), userTenantId); + + // 构造返回 + Map userInfo = new LinkedHashMap<>(); + userInfo.put("id", user.getId()); + userInfo.put("username", user.getUsername()); + userInfo.put("nickname", user.getNickname()); + userInfo.put("email", user.getEmail()); + userInfo.put("avatar", user.getAvatar()); + userInfo.put("tenantId", userTenantId); + userInfo.put("tenantCode", tenant.getCode()); + userInfo.put("roles", roles); + userInfo.put("permissions", permissions); + + Map result = new LinkedHashMap<>(); + result.put("token", token); + result.put("user", userInfo); + + log.info("登录成功,用户:{},ID:{}", username, user.getId()); + return result; + } + + /** + * 获取当前用户信息 + */ + public Map getUserInfo(Long userId) { + SysUser user = userService.getById(userId); + if (user == null) { + throw BusinessException.of(ErrorCode.UNAUTHORIZED, "用户不存在"); + } + + SysTenant tenant = tenantService.getById(user.getTenantId()); + + List roles = userService.getRoles(userId); + List permissions = userService.getPermissions(userId); + + Map result = new LinkedHashMap<>(); + result.put("id", user.getId()); + result.put("username", user.getUsername()); + result.put("nickname", user.getNickname()); + result.put("email", user.getEmail()); + result.put("avatar", user.getAvatar()); + result.put("tenantId", user.getTenantId()); + result.put("tenantCode", tenant != null ? tenant.getCode() : null); + result.put("roles", roles); + result.put("permissions", permissions); + + return result; + } + + /** + * 缓存用户角色和权限到 Redis(7天过期,与 JWT 一致) + */ + private void cacheUserAuth(Long userId, List roles, List permissions) { + try { + String rolesKey = "user:roles:" + userId; + String permsKey = "user:perms:" + userId; + + redisTemplate.delete(rolesKey); + redisTemplate.delete(permsKey); + + if (!roles.isEmpty()) { + redisTemplate.opsForSet().add(rolesKey, roles.toArray(new String[0])); + redisTemplate.expire(rolesKey, 7, TimeUnit.DAYS); + } + if (!permissions.isEmpty()) { + redisTemplate.opsForSet().add(permsKey, permissions.toArray(new String[0])); + redisTemplate.expire(permsKey, 7, TimeUnit.DAYS); + } + } catch (Exception e) { + log.warn("缓存用户权限到 Redis 失败:{}", e.getMessage()); + // Redis 不可用不阻断登录 + } + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/ISysConfigService.java b/backend-java/src/main/java/com/competition/modules/sys/service/ISysConfigService.java new file mode 100644 index 0000000..fae718e --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/ISysConfigService.java @@ -0,0 +1,21 @@ +package com.competition.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreateConfigDto; +import com.competition.modules.sys.entity.SysConfig; + +public interface ISysConfigService extends IService { + + SysConfig createConfig(CreateConfigDto dto, Long tenantId); + + PageResult findAll(Long page, Long pageSize, Long tenantId); + + SysConfig findByKey(String key, Long tenantId); + + SysConfig findDetail(Long id, Long tenantId); + + SysConfig updateConfig(Long id, CreateConfigDto dto, Long tenantId); + + void removeConfig(Long id, Long tenantId); +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/ISysDictService.java b/backend-java/src/main/java/com/competition/modules/sys/service/ISysDictService.java new file mode 100644 index 0000000..e769c21 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/ISysDictService.java @@ -0,0 +1,21 @@ +package com.competition.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreateDictDto; +import com.competition.modules.sys.entity.SysDict; + +public interface ISysDictService extends IService { + + SysDict createDict(CreateDictDto dto, Long tenantId); + + PageResult findAll(Long page, Long pageSize, Long tenantId); + + SysDict findByCode(String code, Long tenantId); + + SysDict findDetail(Long id, Long tenantId); + + SysDict updateDict(Long id, CreateDictDto dto, Long tenantId); + + void removeDict(Long id, Long tenantId); +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/ISysLogService.java b/backend-java/src/main/java/com/competition/modules/sys/service/ISysLogService.java new file mode 100644 index 0000000..b221710 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/ISysLogService.java @@ -0,0 +1,22 @@ +package com.competition.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.QueryLogDto; +import com.competition.modules.sys.entity.SysLog; + +import java.util.List; +import java.util.Map; + +public interface ISysLogService extends IService { + + PageResult> findAll(QueryLogDto dto); + + Map getStatistics(Integer days); + + Map findDetail(Long id); + + void batchDelete(List ids); + + Map cleanOldLogs(Integer daysToKeep); +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/ISysMenuService.java b/backend-java/src/main/java/com/competition/modules/sys/service/ISysMenuService.java new file mode 100644 index 0000000..a3c0d64 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/ISysMenuService.java @@ -0,0 +1,22 @@ +package com.competition.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.modules.sys.dto.CreateMenuDto; +import com.competition.modules.sys.entity.SysMenu; + +import java.util.List; + +public interface ISysMenuService extends IService { + + SysMenu createMenu(CreateMenuDto dto); + + List findAllTree(); + + List getUserMenus(Long userId, Long tenantId, boolean isSuperAdmin); + + SysMenu findDetail(Long id); + + SysMenu updateMenu(Long id, CreateMenuDto dto); + + void removeMenu(Long id); +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/ISysPermissionService.java b/backend-java/src/main/java/com/competition/modules/sys/service/ISysPermissionService.java new file mode 100644 index 0000000..cf41b7f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/ISysPermissionService.java @@ -0,0 +1,21 @@ +package com.competition.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreatePermissionDto; +import com.competition.modules.sys.entity.SysPermission; + +import java.util.Map; + +public interface ISysPermissionService extends IService { + + SysPermission createPermission(CreatePermissionDto dto, Long tenantId); + + PageResult findAll(Long page, Long pageSize, Long tenantId); + + SysPermission findDetail(Long id, Long tenantId); + + SysPermission updatePermission(Long id, CreatePermissionDto dto, Long tenantId); + + void removePermission(Long id, Long tenantId); +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/ISysRoleService.java b/backend-java/src/main/java/com/competition/modules/sys/service/ISysRoleService.java new file mode 100644 index 0000000..a8cf339 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/ISysRoleService.java @@ -0,0 +1,22 @@ +package com.competition.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreateRoleDto; +import com.competition.modules.sys.dto.UpdateRoleDto; +import com.competition.modules.sys.entity.SysRole; + +import java.util.Map; + +public interface ISysRoleService extends IService { + + Map createRole(CreateRoleDto dto, Long tenantId); + + PageResult> findAll(Long page, Long pageSize, Long tenantId); + + Map findDetail(Long id, Long tenantId); + + Map updateRole(Long id, UpdateRoleDto dto, Long tenantId); + + void removeRole(Long id, Long tenantId); +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/ISysTenantService.java b/backend-java/src/main/java/com/competition/modules/sys/service/ISysTenantService.java new file mode 100644 index 0000000..0bb0045 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/ISysTenantService.java @@ -0,0 +1,31 @@ +package com.competition.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreateTenantDto; +import com.competition.modules.sys.dto.UpdateTenantDto; +import com.competition.modules.sys.entity.SysTenant; + +import java.util.Map; + +public interface ISysTenantService extends IService { + + SysTenant createTenant(CreateTenantDto dto, Long currentTenantId); + + PageResult> findAll(Long page, Long pageSize, String keyword, String tenantType); + + Map findDetail(Long id); + + SysTenant updateTenant(Long id, UpdateTenantDto dto, Long currentTenantId); + + void toggleStatus(Long id, Long currentTenantId); + + void removeTenant(Long id, Long currentTenantId); + + SysTenant getMyTenant(Long tenantId); + + void updateMyTenant(Long tenantId, String name, String description); + + /** 检查是否为超级租户 */ + boolean isSuperTenant(Long tenantId); +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/ISysUserService.java b/backend-java/src/main/java/com/competition/modules/sys/service/ISysUserService.java new file mode 100644 index 0000000..3af2298 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/ISysUserService.java @@ -0,0 +1,46 @@ +package com.competition.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreateUserDto; +import com.competition.modules.sys.dto.UpdateUserDto; +import com.competition.modules.sys.entity.SysUser; + +import java.util.List; +import java.util.Map; + +public interface ISysUserService extends IService { + + /** 创建用户 */ + Map createUser(CreateUserDto dto, Long tenantId); + + /** 查询用户列表 */ + PageResult> findAll(Long page, Long pageSize, Long tenantId, + String keyword, boolean isSuperTenant, + String userType, Long filterTenantId, + String userSource, String status); + + /** 用户统计 */ + Map getStats(); + + /** 查询用户详情 */ + Map findDetail(Long id, Long tenantId, boolean isSuperTenant); + + /** 根据用户名查找(用于登录) */ + SysUser findByUsername(String username, Long tenantId); + + /** 更新用户 */ + Map updateUser(Long id, UpdateUserDto dto, Long tenantId); + + /** 更新用户状态 */ + void updateStatus(Long id, String status, Long operatorId); + + /** 删除用户 */ + void removeUser(Long id, Long tenantId); + + /** 查询用户权限码列表 */ + List getPermissions(Long userId); + + /** 查询用户角色码列表 */ + List getRoles(Long userId); +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysConfigServiceImpl.java b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysConfigServiceImpl.java new file mode 100644 index 0000000..71dc22f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysConfigServiceImpl.java @@ -0,0 +1,85 @@ +package com.competition.modules.sys.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.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreateConfigDto; +import com.competition.modules.sys.entity.SysConfig; +import com.competition.modules.sys.mapper.SysConfigMapper; +import com.competition.modules.sys.service.ISysConfigService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class SysConfigServiceImpl extends ServiceImpl implements ISysConfigService { + + @Override + public SysConfig createConfig(CreateConfigDto dto, Long tenantId) { + log.info("开始创建配置,键:{},租户:{}", dto.getKey(), tenantId); + SysConfig config = new SysConfig(); + config.setTenantId(tenantId); + config.setKey(dto.getKey()); + config.setValue(dto.getValue()); + config.setDescription(dto.getDescription()); + save(config); + return config; + } + + @Override + public PageResult findAll(Long page, Long pageSize, Long tenantId) { + IPage result = baseMapper.selectPage( + new Page<>(page, pageSize), + new LambdaQueryWrapper() + .eq(tenantId != null, SysConfig::getTenantId, tenantId) + .orderByDesc(SysConfig::getCreateTime)); + return PageResult.from(result); + } + + @Override + public SysConfig findByKey(String key, Long tenantId) { + if (tenantId == null) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "缺少租户信息"); + } + SysConfig config = getOne(new LambdaQueryWrapper() + .eq(SysConfig::getKey, key) + .eq(SysConfig::getTenantId, tenantId), false); + if (config == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "配置不存在"); + } + return config; + } + + @Override + public SysConfig findDetail(Long id, Long tenantId) { + SysConfig config = getById(id); + if (config == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "配置不存在"); + } + return config; + } + + @Override + public SysConfig updateConfig(Long id, CreateConfigDto dto, Long tenantId) { + log.info("开始更新配置,ID:{}", id); + findDetail(id, tenantId); + SysConfig config = new SysConfig(); + config.setId(id); + if (dto.getKey() != null) config.setKey(dto.getKey()); + if (dto.getValue() != null) config.setValue(dto.getValue()); + if (dto.getDescription() != null) config.setDescription(dto.getDescription()); + updateById(config); + return getById(id); + } + + @Override + public void removeConfig(Long id, Long tenantId) { + log.info("删除配置,ID:{}", id); + findDetail(id, tenantId); + removeById(id); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysDictServiceImpl.java b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysDictServiceImpl.java new file mode 100644 index 0000000..0898ea8 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysDictServiceImpl.java @@ -0,0 +1,137 @@ +package com.competition.modules.sys.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.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreateDictDto; +import com.competition.modules.sys.entity.SysDict; +import com.competition.modules.sys.entity.SysDictItem; +import com.competition.modules.sys.mapper.SysDictItemMapper; +import com.competition.modules.sys.mapper.SysDictMapper; +import com.competition.modules.sys.service.ISysDictService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SysDictServiceImpl extends ServiceImpl implements ISysDictService { + + private final SysDictItemMapper dictItemMapper; + + @Override + @Transactional + public SysDict createDict(CreateDictDto dto, Long tenantId) { + log.info("开始创建字典,编码:{},租户:{}", dto.getCode(), tenantId); + SysDict dict = new SysDict(); + dict.setTenantId(tenantId); + dict.setName(dto.getName()); + dict.setCode(dto.getCode()); + dict.setDescription(dto.getDescription()); + save(dict); + + // 创建字典项 + if (dto.getItems() != null) { + for (CreateDictDto.DictItemDto itemDto : dto.getItems()) { + SysDictItem item = new SysDictItem(); + item.setDictId(dict.getId()); + item.setLabel(itemDto.getLabel()); + item.setValue(itemDto.getValue()); + item.setSort(itemDto.getSort() != null ? itemDto.getSort() : 0); + dictItemMapper.insert(item); + } + } + + return findDetail(dict.getId(), tenantId); + } + + @Override + public PageResult findAll(Long page, Long pageSize, Long tenantId) { + IPage result = baseMapper.selectPage( + new Page<>(page, pageSize), + new LambdaQueryWrapper() + .eq(tenantId != null, SysDict::getTenantId, tenantId) + .eq(SysDict::getValidState, 1) + .orderByDesc(SysDict::getCreateTime)); + + // 为每个字典加载字典项 + for (SysDict dict : result.getRecords()) { + loadItems(dict); + } + + return PageResult.from(result); + } + + @Override + public SysDict findByCode(String code, Long tenantId) { + SysDict dict = getOne(new LambdaQueryWrapper() + .eq(SysDict::getCode, code) + .eq(SysDict::getTenantId, tenantId) + .eq(SysDict::getValidState, 1), false); + if (dict == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "字典不存在"); + } + loadItems(dict); + return dict; + } + + @Override + public SysDict findDetail(Long id, Long tenantId) { + SysDict dict = getById(id); + if (dict == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "字典不存在"); + } + loadItems(dict); + return dict; + } + + @Override + @Transactional + public SysDict updateDict(Long id, CreateDictDto dto, Long tenantId) { + log.info("开始更新字典,ID:{}", id); + SysDict dict = new SysDict(); + dict.setId(id); + if (dto.getName() != null) dict.setName(dto.getName()); + if (dto.getCode() != null) dict.setCode(dto.getCode()); + if (dto.getDescription() != null) dict.setDescription(dto.getDescription()); + updateById(dict); + + // 更新字典项(如果提供) + if (dto.getItems() != null) { + dictItemMapper.delete(new LambdaQueryWrapper().eq(SysDictItem::getDictId, id)); + for (CreateDictDto.DictItemDto itemDto : dto.getItems()) { + SysDictItem item = new SysDictItem(); + item.setDictId(id); + item.setLabel(itemDto.getLabel()); + item.setValue(itemDto.getValue()); + item.setSort(itemDto.getSort() != null ? itemDto.getSort() : 0); + dictItemMapper.insert(item); + } + } + + return findDetail(id, tenantId); + } + + @Override + public void removeDict(Long id, Long tenantId) { + log.info("删除字典,ID:{}", id); + removeById(id); + } + + private void loadItems(SysDict dict) { + List items = dictItemMapper.selectList( + new LambdaQueryWrapper() + .eq(SysDictItem::getDictId, dict.getId()) + .eq(SysDictItem::getValidState, 1) + .orderByAsc(SysDictItem::getSort)); + dict.setItems(items); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysLogServiceImpl.java b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysLogServiceImpl.java new file mode 100644 index 0000000..e7f893a --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysLogServiceImpl.java @@ -0,0 +1,138 @@ +package com.competition.modules.sys.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.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.QueryLogDto; +import com.competition.modules.sys.entity.SysLog; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.mapper.SysLogMapper; +import com.competition.modules.sys.mapper.SysUserMapper; +import com.competition.modules.sys.service.ISysLogService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SysLogServiceImpl extends ServiceImpl implements ISysLogService { + + private final SysUserMapper userMapper; + + @Override + public PageResult> findAll(QueryLogDto dto) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .orderByDesc(SysLog::getCreateTime); + + if (dto.getUserId() != null) wrapper.eq(SysLog::getUserId, dto.getUserId()); + if (dto.getAction() != null) wrapper.like(SysLog::getAction, dto.getAction()); + if (dto.getIp() != null) wrapper.eq(SysLog::getIp, dto.getIp()); + + if (dto.getKeyword() != null && !dto.getKeyword().isBlank()) { + wrapper.and(w -> w.like(SysLog::getAction, dto.getKeyword()).or().like(SysLog::getContent, dto.getKeyword())); + } + + if (dto.getStartTime() != null) { + wrapper.ge(SysLog::getCreateTime, LocalDateTime.parse(dto.getStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } + if (dto.getEndTime() != null) { + wrapper.le(SysLog::getCreateTime, LocalDateTime.parse(dto.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } + + IPage result = baseMapper.selectPage(new Page<>(dto.getPage(), dto.getPageSize()), wrapper); + + // 附加用户信息 + List> list = new ArrayList<>(); + for (SysLog logEntry : result.getRecords()) { + Map map = new LinkedHashMap<>(); + map.put("id", logEntry.getId()); + map.put("userId", logEntry.getUserId()); + map.put("action", logEntry.getAction()); + map.put("content", logEntry.getContent()); + map.put("ip", logEntry.getIp()); + map.put("userAgent", logEntry.getUserAgent()); + map.put("createTime", logEntry.getCreateTime()); + + if (logEntry.getUserId() != null) { + SysUser user = userMapper.selectById(logEntry.getUserId()); + if (user != null) { + map.put("user", Map.of("id", user.getId(), "username", user.getUsername(), "nickname", user.getNickname())); + } + } + list.add(map); + } + + return new PageResult<>(list, result.getTotal(), result.getCurrent(), result.getSize()); + } + + @Override + public Map getStatistics(Integer days) { + if (days == null) days = 7; + LocalDateTime since = LocalDateTime.now().minusDays(days); + + long totalCount = count(); + long recentCount = count(new LambdaQueryWrapper().ge(SysLog::getCreateTime, since)); + + List> actionStats = baseMapper.selectActionStats(since); + List> dailyStats = baseMapper.selectDailyStats(since); + + Map result = new LinkedHashMap<>(); + result.put("totalCount", totalCount); + result.put("recentCount", recentCount); + result.put("days", days); + result.put("actionStats", actionStats); + result.put("dailyStats", dailyStats); + return result; + } + + @Override + public Map findDetail(Long id) { + SysLog logEntry = getById(id); + if (logEntry == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "日志不存在"); + } + + Map map = new LinkedHashMap<>(); + map.put("id", logEntry.getId()); + map.put("userId", logEntry.getUserId()); + map.put("action", logEntry.getAction()); + map.put("content", logEntry.getContent()); + map.put("ip", logEntry.getIp()); + map.put("userAgent", logEntry.getUserAgent()); + map.put("createTime", logEntry.getCreateTime()); + + if (logEntry.getUserId() != null) { + SysUser user = userMapper.selectById(logEntry.getUserId()); + if (user != null) { + map.put("user", Map.of("id", user.getId(), "username", user.getUsername(), "nickname", user.getNickname())); + } + } + return map; + } + + @Override + public void batchDelete(List ids) { + log.info("批量删除日志,数量:{}", ids.size()); + removeByIds(ids); + } + + @Override + public Map cleanOldLogs(Integer daysToKeep) { + if (daysToKeep == null) daysToKeep = 90; + LocalDateTime cutoff = LocalDateTime.now().minusDays(daysToKeep); + + long deleted = baseMapper.delete(new LambdaQueryWrapper().lt(SysLog::getCreateTime, cutoff)); + log.info("清理旧日志,保留天数:{},删除数量:{}", daysToKeep, deleted); + + return Map.of("deleted", deleted, "cutoffDate", cutoff.toString()); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysMenuServiceImpl.java b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysMenuServiceImpl.java new file mode 100644 index 0000000..4d40d62 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysMenuServiceImpl.java @@ -0,0 +1,145 @@ +package com.competition.modules.sys.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.modules.sys.dto.CreateMenuDto; +import com.competition.modules.sys.entity.SysMenu; +import com.competition.modules.sys.entity.SysTenantMenu; +import com.competition.modules.sys.mapper.SysMenuMapper; +import com.competition.modules.sys.mapper.SysTenantMenuMapper; +import com.competition.modules.sys.mapper.SysUserMapper; +import com.competition.modules.sys.service.ISysMenuService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SysMenuServiceImpl extends ServiceImpl implements ISysMenuService { + + private final SysTenantMenuMapper tenantMenuMapper; + private final SysUserMapper userMapper; + + @Override + public SysMenu createMenu(CreateMenuDto dto) { + log.info("开始创建菜单,名称:{}", dto.getName()); + SysMenu menu = new SysMenu(); + menu.setName(dto.getName()); + menu.setPath(dto.getPath()); + menu.setIcon(dto.getIcon()); + menu.setComponent(dto.getComponent()); + menu.setParentId(dto.getParentId()); + menu.setPermission(dto.getPermission()); + menu.setSort(dto.getSort() != null ? dto.getSort() : 0); + save(menu); + return menu; + } + + @Override + public List findAllTree() { + List all = list(new LambdaQueryWrapper() + .eq(SysMenu::getValidState, 1) + .orderByAsc(SysMenu::getSort)); + return buildTree(all, null); + } + + @Override + public List getUserMenus(Long userId, Long tenantId, boolean isSuperAdmin) { + // 获取所有有效菜单 + List allMenus = list(new LambdaQueryWrapper() + .eq(SysMenu::getValidState, 1) + .orderByAsc(SysMenu::getSort)); + + if (isSuperAdmin) { + // 超管看到所有菜单 + return buildTree(allMenus, null); + } + + // 获取租户分配的菜单 ID + List tenantMenus = tenantMenuMapper.selectList( + new LambdaQueryWrapper().eq(SysTenantMenu::getTenantId, tenantId)); + Set tenantMenuIds = tenantMenus.stream().map(SysTenantMenu::getMenuId).collect(Collectors.toSet()); + + // 获取用户权限 + List userPermissions = userMapper.selectPermissionsByUserId(userId); + Set permSet = new HashSet<>(userPermissions); + + // 过滤:菜单必须属于租户,且用户有对应权限(无权限要求的菜单直接放行) + List filteredMenus = allMenus.stream() + .filter(menu -> tenantMenuIds.contains(menu.getId())) + .filter(menu -> menu.getPermission() == null || menu.getPermission().isBlank() || permSet.contains(menu.getPermission())) + .collect(Collectors.toList()); + + // 补全父菜单(确保树结构完整) + Set filteredIds = filteredMenus.stream().map(SysMenu::getId).collect(Collectors.toSet()); + for (SysMenu menu : new ArrayList<>(filteredMenus)) { + addParentsIfMissing(menu, allMenus, filteredMenus, filteredIds); + } + + return buildTree(filteredMenus, null); + } + + @Override + public SysMenu findDetail(Long id) { + SysMenu menu = getById(id); + if (menu == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "菜单不存在"); + } + // 加载子菜单 + List children = list(new LambdaQueryWrapper() + .eq(SysMenu::getParentId, id) + .eq(SysMenu::getValidState, 1) + .orderByAsc(SysMenu::getSort)); + menu.setChildren(children); + return menu; + } + + @Override + public SysMenu updateMenu(Long id, CreateMenuDto dto) { + log.info("开始更新菜单,ID:{}", id); + SysMenu menu = new SysMenu(); + menu.setId(id); + if (dto.getName() != null) menu.setName(dto.getName()); + if (dto.getPath() != null) menu.setPath(dto.getPath()); + if (dto.getIcon() != null) menu.setIcon(dto.getIcon()); + if (dto.getComponent() != null) menu.setComponent(dto.getComponent()); + if (dto.getParentId() != null) menu.setParentId(dto.getParentId()); + if (dto.getPermission() != null) menu.setPermission(dto.getPermission()); + if (dto.getSort() != null) menu.setSort(dto.getSort()); + updateById(menu); + return getById(id); + } + + @Override + public void removeMenu(Long id) { + log.info("删除菜单,ID:{}", id); + removeById(id); + } + + /** 构建菜单树 */ + private List buildTree(List menus, Long parentId) { + return menus.stream() + .filter(m -> Objects.equals(m.getParentId(), parentId)) + .peek(m -> m.setChildren(buildTree(menus, m.getId()))) + .collect(Collectors.toList()); + } + + /** 递归补全父菜单 */ + private void addParentsIfMissing(SysMenu menu, List allMenus, List filtered, Set filteredIds) { + if (menu.getParentId() == null || filteredIds.contains(menu.getParentId())) return; + allMenus.stream() + .filter(m -> m.getId().equals(menu.getParentId())) + .findFirst() + .ifPresent(parent -> { + filtered.add(parent); + filteredIds.add(parent.getId()); + addParentsIfMissing(parent, allMenus, filtered, filteredIds); + }); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysPermissionServiceImpl.java b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysPermissionServiceImpl.java new file mode 100644 index 0000000..c9d114a --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysPermissionServiceImpl.java @@ -0,0 +1,83 @@ +package com.competition.modules.sys.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.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreatePermissionDto; +import com.competition.modules.sys.entity.SysPermission; +import com.competition.modules.sys.mapper.SysPermissionMapper; +import com.competition.modules.sys.service.ISysPermissionService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class SysPermissionServiceImpl extends ServiceImpl + implements ISysPermissionService { + + @Override + public SysPermission createPermission(CreatePermissionDto dto, Long tenantId) { + log.info("开始创建权限,编码:{},租户:{}", dto.getCode(), tenantId); + + SysPermission perm = new SysPermission(); + perm.setTenantId(tenantId); + perm.setName(dto.getName()); + perm.setCode(dto.getCode()); + perm.setResource(dto.getResource()); + perm.setAction(dto.getAction()); + perm.setDescription(dto.getDescription()); + save(perm); + + log.info("权限创建成功,ID:{}", perm.getId()); + return perm; + } + + @Override + public PageResult findAll(Long page, Long pageSize, Long tenantId) { + IPage result = baseMapper.selectPage( + new Page<>(page, pageSize), + new LambdaQueryWrapper() + .eq(tenantId != null, SysPermission::getTenantId, tenantId) + .eq(SysPermission::getValidState, 1) + .orderByDesc(SysPermission::getCreateTime) + ); + return PageResult.from(result); + } + + @Override + public SysPermission findDetail(Long id, Long tenantId) { + SysPermission perm = getById(id); + if (perm == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "权限不存在"); + } + return perm; + } + + @Override + public SysPermission updatePermission(Long id, CreatePermissionDto dto, Long tenantId) { + log.info("开始更新权限,ID:{}", id); + findDetail(id, tenantId); + + SysPermission perm = new SysPermission(); + perm.setId(id); + if (dto.getName() != null) perm.setName(dto.getName()); + if (dto.getCode() != null) perm.setCode(dto.getCode()); + if (dto.getResource() != null) perm.setResource(dto.getResource()); + if (dto.getAction() != null) perm.setAction(dto.getAction()); + if (dto.getDescription() != null) perm.setDescription(dto.getDescription()); + updateById(perm); + + return getById(id); + } + + @Override + public void removePermission(Long id, Long tenantId) { + log.info("删除权限,ID:{}", id); + findDetail(id, tenantId); + removeById(id); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysRoleServiceImpl.java b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysRoleServiceImpl.java new file mode 100644 index 0000000..8e40b03 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysRoleServiceImpl.java @@ -0,0 +1,166 @@ +package com.competition.modules.sys.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.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreateRoleDto; +import com.competition.modules.sys.dto.UpdateRoleDto; +import com.competition.modules.sys.entity.SysPermission; +import com.competition.modules.sys.entity.SysRole; +import com.competition.modules.sys.entity.SysRolePermission; +import com.competition.modules.sys.mapper.SysPermissionMapper; +import com.competition.modules.sys.mapper.SysRoleMapper; +import com.competition.modules.sys.mapper.SysRolePermissionMapper; +import com.competition.modules.sys.service.ISysRoleService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SysRoleServiceImpl extends ServiceImpl implements ISysRoleService { + + private final SysRolePermissionMapper rolePermissionMapper; + private final SysPermissionMapper permissionMapper; + + @Override + @Transactional + public Map createRole(CreateRoleDto dto, Long tenantId) { + log.info("开始创建角色,名称:{},租户:{}", dto.getName(), tenantId); + + SysRole role = new SysRole(); + role.setTenantId(tenantId); + role.setName(dto.getName()); + role.setCode(dto.getCode()); + role.setDescription(dto.getDescription()); + baseMapper.insert(role); + + // 创建权限关联 + if (dto.getPermissionIds() != null) { + for (Long permId : dto.getPermissionIds()) { + SysRolePermission rp = new SysRolePermission(); + rp.setRoleId(role.getId()); + rp.setPermissionId(permId); + rolePermissionMapper.insert(rp); + } + } + + log.info("角色创建成功,ID:{}", role.getId()); + return findDetail(role.getId(), tenantId); + } + + @Override + public PageResult> findAll(Long page, Long pageSize, Long tenantId) { + IPage result = baseMapper.selectPage( + new Page<>(page, pageSize), + new LambdaQueryWrapper() + .eq(tenantId != null, SysRole::getTenantId, tenantId) + .eq(SysRole::getValidState, 1) + .orderByDesc(SysRole::getCreateTime) + ); + + List> list = new ArrayList<>(); + for (SysRole role : result.getRecords()) { + Map map = new HashMap<>(); + map.put("id", role.getId()); + map.put("tenantId", role.getTenantId()); + map.put("name", role.getName()); + map.put("code", role.getCode()); + map.put("description", role.getDescription()); + map.put("validState", role.getValidState()); + map.put("createTime", role.getCreateTime()); + + // 附加权限列表 + List rps = rolePermissionMapper.selectList( + new LambdaQueryWrapper().eq(SysRolePermission::getRoleId, role.getId())); + List permissions = new ArrayList<>(); + for (SysRolePermission rp : rps) { + SysPermission perm = permissionMapper.selectById(rp.getPermissionId()); + if (perm != null) permissions.add(perm); + } + map.put("permissions", permissions); + + list.add(map); + } + + return new PageResult<>(list, result.getTotal(), result.getCurrent(), result.getSize()); + } + + @Override + public Map findDetail(Long id, Long tenantId) { + SysRole role = getById(id); + if (role == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "角色不存在"); + } + + Map map = new HashMap<>(); + map.put("id", role.getId()); + map.put("tenantId", role.getTenantId()); + map.put("name", role.getName()); + map.put("code", role.getCode()); + map.put("description", role.getDescription()); + map.put("validState", role.getValidState()); + map.put("createTime", role.getCreateTime()); + + // 附加权限列表 + List rps = rolePermissionMapper.selectList( + new LambdaQueryWrapper().eq(SysRolePermission::getRoleId, id)); + List permissions = new ArrayList<>(); + for (SysRolePermission rp : rps) { + SysPermission perm = permissionMapper.selectById(rp.getPermissionId()); + if (perm != null) permissions.add(perm); + } + map.put("permissions", permissions); + + return map; + } + + @Override + @Transactional + public Map updateRole(Long id, UpdateRoleDto dto, Long tenantId) { + log.info("开始更新角色,ID:{}", id); + SysRole existing = getById(id); + if (existing == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "角色不存在"); + } + + SysRole role = new SysRole(); + role.setId(id); + if (dto.getName() != null) role.setName(dto.getName()); + if (dto.getCode() != null) role.setCode(dto.getCode()); + if (dto.getDescription() != null) role.setDescription(dto.getDescription()); + baseMapper.updateById(role); + + // 更新权限关联 + if (dto.getPermissionIds() != null) { + rolePermissionMapper.delete( + new LambdaQueryWrapper().eq(SysRolePermission::getRoleId, id)); + for (Long permId : dto.getPermissionIds()) { + SysRolePermission rp = new SysRolePermission(); + rp.setRoleId(id); + rp.setPermissionId(permId); + rolePermissionMapper.insert(rp); + } + } + + return findDetail(id, tenantId); + } + + @Override + public void removeRole(Long id, Long tenantId) { + log.info("删除角色,ID:{}", id); + SysRole role = getById(id); + if (role == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "角色不存在"); + } + removeById(id); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysTenantServiceImpl.java b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysTenantServiceImpl.java new file mode 100644 index 0000000..524138b --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysTenantServiceImpl.java @@ -0,0 +1,209 @@ +package com.competition.modules.sys.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.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreateTenantDto; +import com.competition.modules.sys.dto.UpdateTenantDto; +import com.competition.modules.sys.entity.SysTenant; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.mapper.SysTenantMapper; +import com.competition.modules.sys.mapper.SysUserMapper; +import com.competition.modules.sys.service.ISysTenantService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * 内部租户编码,列表查询时隐藏 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SysTenantServiceImpl extends ServiceImpl implements ISysTenantService { + + private static final Set INTERNAL_TENANT_CODES = Set.of("super", "public", "school", "teacher", "student", "judge"); + + private final SysUserMapper userMapper; + + @Override + public SysTenant createTenant(CreateTenantDto dto, Long currentTenantId) { + log.info("开始创建租户,编码:{}", dto.getCode()); + checkSuperTenant(currentTenantId); + + // 验证编码唯一 + if (getOne(new LambdaQueryWrapper().eq(SysTenant::getCode, dto.getCode()), false) != null) { + throw BusinessException.of(ErrorCode.CONFLICT, "租户编码已存在"); + } + + SysTenant tenant = new SysTenant(); + tenant.setName(dto.getName()); + tenant.setCode(dto.getCode()); + tenant.setDomain(dto.getDomain()); + tenant.setDescription(dto.getDescription()); + tenant.setTenantType(dto.getTenantType() != null ? dto.getTenantType() : "other"); + tenant.setIsSuper(0); + save(tenant); + + // TODO: Phase 2 处理 menuIds 关联 + + log.info("租户创建成功,ID:{}", tenant.getId()); + return tenant; + } + + @Override + public PageResult> findAll(Long page, Long pageSize, String keyword, String tenantType) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(SysTenant::getValidState, 1) + .notIn(SysTenant::getCode, INTERNAL_TENANT_CODES) + .orderByDesc(SysTenant::getCreateTime); + + if (keyword != null && !keyword.isBlank()) { + wrapper.and(w -> w.like(SysTenant::getName, keyword).or().like(SysTenant::getCode, keyword)); + } + if (tenantType != null && !tenantType.isBlank()) { + wrapper.eq(SysTenant::getTenantType, tenantType); + } + + IPage result = baseMapper.selectPage(new Page<>(page, pageSize), wrapper); + + // 为每个租户附加用户数 + List> list = new ArrayList<>(); + for (SysTenant tenant : result.getRecords()) { + Map map = new HashMap<>(); + map.put("id", tenant.getId()); + map.put("name", tenant.getName()); + map.put("code", tenant.getCode()); + map.put("domain", tenant.getDomain()); + map.put("description", tenant.getDescription()); + map.put("isSuper", tenant.getIsSuper()); + map.put("tenantType", tenant.getTenantType()); + map.put("validState", tenant.getValidState()); + map.put("createTime", tenant.getCreateTime()); + + Long userCount = userMapper.selectCount( + new LambdaQueryWrapper().eq(SysUser::getTenantId, tenant.getId()).eq(SysUser::getValidState, 1)); + map.put("_count", Map.of("users", userCount)); + + list.add(map); + } + + return new PageResult<>(list, result.getTotal(), result.getCurrent(), result.getSize()); + } + + @Override + public Map findDetail(Long id) { + SysTenant tenant = getById(id); + if (tenant == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "租户不存在"); + } + + Map map = new HashMap<>(); + map.put("id", tenant.getId()); + map.put("name", tenant.getName()); + map.put("code", tenant.getCode()); + map.put("domain", tenant.getDomain()); + map.put("description", tenant.getDescription()); + map.put("isSuper", tenant.getIsSuper()); + map.put("tenantType", tenant.getTenantType()); + map.put("validState", tenant.getValidState()); + map.put("createTime", tenant.getCreateTime()); + + Long userCount = userMapper.selectCount( + new LambdaQueryWrapper().eq(SysUser::getTenantId, id).eq(SysUser::getValidState, 1)); + map.put("_count", Map.of("users", userCount)); + + return map; + } + + @Override + public SysTenant updateTenant(Long id, UpdateTenantDto dto, Long currentTenantId) { + log.info("开始更新租户,ID:{}", id); + checkSuperTenant(currentTenantId); + + SysTenant existing = getById(id); + if (existing == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "租户不存在"); + } + + SysTenant tenant = new SysTenant(); + tenant.setId(id); + if (dto.getName() != null) tenant.setName(dto.getName()); + if (dto.getCode() != null) tenant.setCode(dto.getCode()); + if (dto.getDomain() != null) tenant.setDomain(dto.getDomain()); + if (dto.getDescription() != null) tenant.setDescription(dto.getDescription()); + if (dto.getTenantType() != null) tenant.setTenantType(dto.getTenantType()); + updateById(tenant); + + // TODO: Phase 2 处理 menuIds 关联 + + return getById(id); + } + + @Override + public void toggleStatus(Long id, Long currentTenantId) { + log.info("切换租户状态,ID:{}", id); + checkSuperTenant(currentTenantId); + + SysTenant tenant = getById(id); + if (tenant == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "租户不存在"); + } + if (tenant.getIsSuper() == 1) { + throw BusinessException.of(ErrorCode.FORBIDDEN, "不能修改超级租户状态"); + } + + SysTenant update = new SysTenant(); + update.setId(id); + update.setValidState(tenant.getValidState() == 1 ? 2 : 1); + updateById(update); + } + + @Override + public void removeTenant(Long id, Long currentTenantId) { + log.info("删除租户,ID:{}", id); + checkSuperTenant(currentTenantId); + + SysTenant tenant = getById(id); + if (tenant == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "租户不存在"); + } + if (tenant.getIsSuper() == 1) { + throw BusinessException.of(ErrorCode.FORBIDDEN, "不能删除超级租户"); + } + removeById(id); + } + + @Override + public SysTenant getMyTenant(Long tenantId) { + return getById(tenantId); + } + + @Override + public void updateMyTenant(Long tenantId, String name, String description) { + SysTenant tenant = new SysTenant(); + tenant.setId(tenantId); + if (name != null) tenant.setName(name); + if (description != null) tenant.setDescription(description); + updateById(tenant); + } + + @Override + public boolean isSuperTenant(Long tenantId) { + if (tenantId == null) return false; + SysTenant tenant = getById(tenantId); + return tenant != null && tenant.getIsSuper() == 1; + } + + private void checkSuperTenant(Long tenantId) { + if (!isSuperTenant(tenantId)) { + throw BusinessException.of(ErrorCode.FORBIDDEN, "仅超级租户可执行此操作"); + } + } +} diff --git a/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysUserServiceImpl.java b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysUserServiceImpl.java new file mode 100644 index 0000000..1eb4733 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/sys/service/impl/SysUserServiceImpl.java @@ -0,0 +1,255 @@ +package com.competition.modules.sys.service.impl; + +import cn.hutool.crypto.SecureUtil; +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.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.competition.common.enums.ErrorCode; +import com.competition.common.exception.BusinessException; +import com.competition.common.result.PageResult; +import com.competition.modules.sys.dto.CreateUserDto; +import com.competition.modules.sys.dto.UpdateUserDto; +import com.competition.modules.sys.entity.SysRole; +import com.competition.modules.sys.entity.SysTenant; +import com.competition.modules.sys.entity.SysUser; +import com.competition.modules.sys.entity.SysUserRole; +import com.competition.modules.sys.mapper.SysRoleMapper; +import com.competition.modules.sys.mapper.SysTenantMapper; +import com.competition.modules.sys.mapper.SysUserMapper; +import com.competition.modules.sys.mapper.SysUserRoleMapper; +import com.competition.modules.sys.service.ISysUserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SysUserServiceImpl extends ServiceImpl implements ISysUserService { + + private final SysUserRoleMapper userRoleMapper; + private final SysRoleMapper roleMapper; + private final SysTenantMapper tenantMapper; + private final PasswordEncoder passwordEncoder; + + @Override + @Transactional + public Map createUser(CreateUserDto dto, Long tenantId) { + log.info("开始创建用户,用户名:{},租户:{}", dto.getUsername(), tenantId); + + // 验证角色是否属于该租户 + if (dto.getRoleIds() != null && !dto.getRoleIds().isEmpty()) { + List roles = roleMapper.selectList( + new LambdaQueryWrapper() + .in(SysRole::getId, dto.getRoleIds()) + .eq(SysRole::getTenantId, tenantId) + ); + if (roles.size() != dto.getRoleIds().size()) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "部分角色不存在或不属于该租户"); + } + } + + SysUser user = new SysUser(); + user.setTenantId(tenantId); + user.setUsername(dto.getUsername()); + user.setPassword(passwordEncoder.encode(dto.getPassword())); + user.setNickname(dto.getNickname()); + user.setEmail(dto.getEmail()); + user.setPhone(dto.getPhone()); + user.setGender(dto.getGender()); + user.setAvatar(dto.getAvatar()); + user.setStatus(dto.getStatus() != null ? dto.getStatus() : "enabled"); + user.setUserSource("admin_created"); + user.setUserType("adult"); + + baseMapper.insert(user); + + // 创建角色关联 + if (dto.getRoleIds() != null) { + for (Long roleId : dto.getRoleIds()) { + SysUserRole ur = new SysUserRole(); + ur.setUserId(user.getId()); + ur.setRoleId(roleId); + userRoleMapper.insert(ur); + } + } + + log.info("用户创建成功,ID:{}", user.getId()); + return findDetail(user.getId(), tenantId, false); + } + + @Override + public PageResult> findAll(Long page, Long pageSize, Long tenantId, + String keyword, boolean isSuperTenant, + String userType, Long filterTenantId, + String userSource, String status) { + Map params = new HashMap<>(); + + if (isSuperTenant) { + // 超管模式:按 userType 映射到租户类型过滤 + if (userType != null) { + switch (userType) { + case "platform" -> params.put("isSuperTenantFilter", 1); + case "org" -> { + // 排除超管、公众、评委租户 + // 此逻辑较复杂,通过 filterTenantId 或 tenantType 筛选 + } + case "judge" -> params.put("tenantTypeFilter", "judge_pool"); + case "public" -> params.put("tenantTypeFilter", "public"); + } + } + if (filterTenantId != null) { + params.put("filterTenantId", filterTenantId); + } + if (userSource != null) params.put("userSource", userSource); + if (status != null) params.put("status", status); + } else { + params.put("tenantId", tenantId); + } + + if (keyword != null && !keyword.isBlank()) { + params.put("keyword", keyword); + } + + IPage> result = baseMapper.selectUserPage(new Page<>(page, pageSize), params); + + // 为每个用户附加角色信息 + for (Map user : result.getRecords()) { + Long userId = ((Number) user.get("id")).longValue(); + user.put("roles", getRolesWithInfo(userId)); + } + + return PageResult.from(result); + } + + @Override + public Map getStats() { + List tenants = tenantMapper.selectList( + new LambdaQueryWrapper().eq(SysTenant::getValidState, 1)); + + List superIds = tenants.stream().filter(t -> t.getIsSuper() == 1).map(SysTenant::getId).toList(); + List publicIds = tenants.stream().filter(t -> "public".equals(t.getCode())).map(SysTenant::getId).toList(); + List judgeIds = tenants.stream().filter(t -> "judge".equals(t.getCode())).map(SysTenant::getId).toList(); + List orgIds = tenants.stream() + .filter(t -> t.getIsSuper() == 0 && !"public".equals(t.getCode()) && !"judge".equals(t.getCode())) + .map(SysTenant::getId).toList(); + + LambdaQueryWrapper base = new LambdaQueryWrapper().eq(SysUser::getValidState, 1); + + Map stats = new HashMap<>(); + stats.put("total", count(base)); + stats.put("platform", superIds.isEmpty() ? 0 : count(new LambdaQueryWrapper().eq(SysUser::getValidState, 1).in(SysUser::getTenantId, superIds))); + stats.put("org", orgIds.isEmpty() ? 0 : count(new LambdaQueryWrapper().eq(SysUser::getValidState, 1).in(SysUser::getTenantId, orgIds))); + stats.put("judge", judgeIds.isEmpty() ? 0 : count(new LambdaQueryWrapper().eq(SysUser::getValidState, 1).in(SysUser::getTenantId, judgeIds))); + stats.put("public", publicIds.isEmpty() ? 0 : count(new LambdaQueryWrapper().eq(SysUser::getValidState, 1).in(SysUser::getTenantId, publicIds))); + + return stats; + } + + @Override + public Map findDetail(Long id, Long tenantId, boolean isSuperTenant) { + Map user = baseMapper.selectUserDetail(id, tenantId, isSuperTenant); + if (user == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "用户不存在"); + } + // 附加角色和权限 + user.put("roles", getRolesWithInfo(id)); + return user; + } + + @Override + public SysUser findByUsername(String username, Long tenantId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(SysUser::getUsername, username) + .eq(SysUser::getValidState, 1); + if (tenantId != null) { + wrapper.eq(SysUser::getTenantId, tenantId); + } + return getOne(wrapper, false); + } + + @Override + @Transactional + public Map updateUser(Long id, UpdateUserDto dto, Long tenantId) { + log.info("开始更新用户,ID:{}", id); + SysUser existing = getById(id); + if (existing == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "用户不存在"); + } + + SysUser user = new SysUser(); + user.setId(id); + if (dto.getUsername() != null) user.setUsername(dto.getUsername()); + if (dto.getNickname() != null) user.setNickname(dto.getNickname()); + if (dto.getEmail() != null) user.setEmail(dto.getEmail()); + if (dto.getPhone() != null) user.setPhone(dto.getPhone()); + if (dto.getGender() != null) user.setGender(dto.getGender()); + if (dto.getAvatar() != null) user.setAvatar(dto.getAvatar()); + if (dto.getStatus() != null) user.setStatus(dto.getStatus()); + if (dto.getPassword() != null && !dto.getPassword().isBlank()) { + user.setPassword(passwordEncoder.encode(dto.getPassword())); + } + baseMapper.updateById(user); + + // 更新角色关联 + if (dto.getRoleIds() != null) { + userRoleMapper.delete(new LambdaQueryWrapper().eq(SysUserRole::getUserId, id)); + for (Long roleId : dto.getRoleIds()) { + SysUserRole ur = new SysUserRole(); + ur.setUserId(id); + ur.setRoleId(roleId); + userRoleMapper.insert(ur); + } + } + + return findDetail(id, tenantId, tenantId == null); + } + + @Override + public void updateStatus(Long id, String status, Long operatorId) { + log.info("更新用户状态,ID:{},状态:{}", id, status); + SysUser user = new SysUser(); + user.setId(id); + user.setStatus(status); + baseMapper.updateById(user); + } + + @Override + public void removeUser(Long id, Long tenantId) { + log.info("删除用户,ID:{}", id); + // 逻辑删除 + removeById(id); + } + + @Override + public List getPermissions(Long userId) { + return baseMapper.selectPermissionsByUserId(userId); + } + + @Override + public List getRoles(Long userId) { + return baseMapper.selectRolesByUserId(userId); + } + + /** 获取用户角色(含角色对象信息) */ + private List> getRolesWithInfo(Long userId) { + List userRoles = userRoleMapper.selectList( + new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); + List> result = new ArrayList<>(); + for (SysUserRole ur : userRoles) { + SysRole role = roleMapper.selectById(ur.getRoleId()); + if (role != null) { + Map roleMap = new HashMap<>(); + roleMap.put("roleId", ur.getRoleId()); + roleMap.put("role", role); + result.add(roleMap); + } + } + return result; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcReviewLog.java b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcReviewLog.java new file mode 100644 index 0000000..27b7ff7 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcReviewLog.java @@ -0,0 +1,39 @@ +package com.competition.modules.ugc.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("content_review_logs") +public class UgcReviewLog implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("target_type") + private String targetType; + + @TableField("target_id") + private Long targetId; + + @TableField("work_id") + private Long workId; + + private String action; + + private String reason; + + private String note; + + @TableField("operator_id") + private Long operatorId; + + @TableField("create_time") + private LocalDateTime createTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcTag.java b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcTag.java new file mode 100644 index 0000000..6b312ad --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcTag.java @@ -0,0 +1,37 @@ +package com.competition.modules.ugc.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("work_tags") +public class UgcTag implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + private String name; + + private String category; + + private String color; + + private Integer sort; + + private String status; + + @TableField("usage_count") + private Integer usageCount; + + @TableField("create_time") + private LocalDateTime createTime; + + @TableField("modify_time") + private LocalDateTime modifyTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWork.java b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWork.java new file mode 100644 index 0000000..6c80755 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWork.java @@ -0,0 +1,90 @@ +package com.competition.modules.ugc.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName(value = "user_works", autoResultMap = true) +public class UgcWork implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("user_id") + private Long userId; + + private String title; + + @TableField("cover_url") + private String coverUrl; + + private String description; + + private String visibility; + + private String status; + + @TableField("review_note") + private String reviewNote; + + @TableField("review_time") + private LocalDateTime reviewTime; + + @TableField("reviewer_id") + private Long reviewerId; + + @TableField("machine_review_result") + private String machineReviewResult; + + @TableField("machine_review_note") + private String machineReviewNote; + + @TableField("is_recommended") + private Boolean isRecommended; + + @TableField("view_count") + private Integer viewCount; + + @TableField("like_count") + private Integer likeCount; + + @TableField("favorite_count") + private Integer favoriteCount; + + @TableField("comment_count") + private Integer commentCount; + + @TableField("share_count") + private Integer shareCount; + + @TableField("original_image_url") + private String originalImageUrl; + + @TableField("voice_input_url") + private String voiceInputUrl; + + @TableField("text_input") + private String textInput; + + @TableField(value = "ai_meta", typeHandler = JacksonTypeHandler.class) + private Object aiMeta; + + @TableField("publish_time") + private LocalDateTime publishTime; + + @TableField("is_deleted") + private Integer isDeleted; + + @TableField("create_time") + private LocalDateTime createTime; + + @TableField("modify_time") + private LocalDateTime modifyTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkComment.java b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkComment.java new file mode 100644 index 0000000..0b9b96f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkComment.java @@ -0,0 +1,34 @@ +package com.competition.modules.ugc.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("user_work_comments") +public class UgcWorkComment implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("work_id") + private Long workId; + + @TableField("user_id") + private Long userId; + + @TableField("parent_id") + private Long parentId; + + private String content; + + private String status; + + @TableField("create_time") + private LocalDateTime createTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkFavorite.java b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkFavorite.java new file mode 100644 index 0000000..c4fd400 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkFavorite.java @@ -0,0 +1,27 @@ +package com.competition.modules.ugc.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("user_work_favorites") +public class UgcWorkFavorite implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("user_id") + private Long userId; + + @TableField("work_id") + private Long workId; + + @TableField("create_time") + private LocalDateTime createTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkLike.java b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkLike.java new file mode 100644 index 0000000..2a26adc --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkLike.java @@ -0,0 +1,27 @@ +package com.competition.modules.ugc.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("user_work_likes") +public class UgcWorkLike implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("user_id") + private Long userId; + + @TableField("work_id") + private Long workId; + + @TableField("create_time") + private LocalDateTime createTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkPage.java b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkPage.java new file mode 100644 index 0000000..1d36620 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkPage.java @@ -0,0 +1,31 @@ +package com.competition.modules.ugc.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; + +@Data +@TableName("user_work_pages") +public class UgcWorkPage implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("work_id") + private Long workId; + + @TableField("page_no") + private Integer pageNo; + + @TableField("image_url") + private String imageUrl; + + private String text; + + @TableField("audio_url") + private String audioUrl; +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkReport.java b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkReport.java new file mode 100644 index 0000000..e9d025e --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkReport.java @@ -0,0 +1,51 @@ +package com.competition.modules.ugc.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("user_work_reports") +public class UgcWorkReport implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("reporter_id") + private Long reporterId; + + @TableField("target_type") + private String targetType; + + @TableField("target_id") + private Long targetId; + + @TableField("target_user_id") + private Long targetUserId; + + private String reason; + + private String description; + + private String status; + + @TableField("handle_action") + private String handleAction; + + @TableField("handle_note") + private String handleNote; + + @TableField("handler_id") + private Long handlerId; + + @TableField("handle_time") + private LocalDateTime handleTime; + + @TableField("create_time") + private LocalDateTime createTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkTag.java b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkTag.java new file mode 100644 index 0000000..25aa0cc --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWorkTag.java @@ -0,0 +1,23 @@ +package com.competition.modules.ugc.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; + +@Data +@TableName("work_tag_relations") +public class UgcWorkTag implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("work_id") + private Long workId; + + @TableField("tag_id") + private Long tagId; +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcReviewLogMapper.java b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcReviewLogMapper.java new file mode 100644 index 0000000..9a594ca --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcReviewLogMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.ugc.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.ugc.entity.UgcReviewLog; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UgcReviewLogMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcTagMapper.java b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcTagMapper.java new file mode 100644 index 0000000..7bb2ecc --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcTagMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.ugc.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.ugc.entity.UgcTag; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UgcTagMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkCommentMapper.java b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkCommentMapper.java new file mode 100644 index 0000000..9bbd53b --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkCommentMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.ugc.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.ugc.entity.UgcWorkComment; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UgcWorkCommentMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkFavoriteMapper.java b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkFavoriteMapper.java new file mode 100644 index 0000000..719de68 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkFavoriteMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.ugc.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.ugc.entity.UgcWorkFavorite; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UgcWorkFavoriteMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkLikeMapper.java b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkLikeMapper.java new file mode 100644 index 0000000..3a3524e --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkLikeMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.ugc.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.ugc.entity.UgcWorkLike; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UgcWorkLikeMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkMapper.java b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkMapper.java new file mode 100644 index 0000000..27fec26 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.ugc.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.ugc.entity.UgcWork; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UgcWorkMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkPageMapper.java b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkPageMapper.java new file mode 100644 index 0000000..3f33757 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkPageMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.ugc.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.ugc.entity.UgcWorkPage; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UgcWorkPageMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkReportMapper.java b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkReportMapper.java new file mode 100644 index 0000000..4ae38e4 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkReportMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.ugc.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.ugc.entity.UgcWorkReport; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UgcWorkReportMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkTagMapper.java b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkTagMapper.java new file mode 100644 index 0000000..ca8a904 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/mapper/UgcWorkTagMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.ugc.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.ugc.entity.UgcWorkTag; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UgcWorkTagMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/user/entity/UserChild.java b/backend-java/src/main/java/com/competition/modules/user/entity/UserChild.java new file mode 100644 index 0000000..6aa6e12 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/user/entity/UserChild.java @@ -0,0 +1,46 @@ +package com.competition.modules.user.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("children") +public class UserChild implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("parent_id") + private Long parentId; + + private String name; + + private String gender; + + private LocalDate birthday; + + private String grade; + + private String city; + + @TableField("school_name") + private String schoolName; + + private String avatar; + + @TableField("is_deleted") + private Integer isDeleted; + + @TableField("create_time") + private LocalDateTime createTime; + + @TableField("modify_time") + private LocalDateTime modifyTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/user/entity/UserParentChild.java b/backend-java/src/main/java/com/competition/modules/user/entity/UserParentChild.java new file mode 100644 index 0000000..14c21ff --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/user/entity/UserParentChild.java @@ -0,0 +1,32 @@ +package com.competition.modules.user.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("user_parent_child") +public class UserParentChild implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("parent_user_id") + private Long parentUserId; + + @TableField("child_user_id") + private Long childUserId; + + private String relationship; + + @TableField("control_mode") + private String controlMode; + + @TableField("create_time") + private LocalDateTime createTime; +} diff --git a/backend-java/src/main/java/com/competition/modules/user/mapper/UserChildMapper.java b/backend-java/src/main/java/com/competition/modules/user/mapper/UserChildMapper.java new file mode 100644 index 0000000..80f9a54 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/user/mapper/UserChildMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.user.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.user.entity.UserChild; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UserChildMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/modules/user/mapper/UserParentChildMapper.java b/backend-java/src/main/java/com/competition/modules/user/mapper/UserParentChildMapper.java new file mode 100644 index 0000000..44d8178 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/user/mapper/UserParentChildMapper.java @@ -0,0 +1,9 @@ +package com.competition.modules.user.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.competition.modules.user.entity.UserParentChild; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UserParentChildMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/competition/security/annotation/Public.java b/backend-java/src/main/java/com/competition/security/annotation/Public.java new file mode 100644 index 0000000..17c2797 --- /dev/null +++ b/backend-java/src/main/java/com/competition/security/annotation/Public.java @@ -0,0 +1,13 @@ +package com.competition.security.annotation; + +import java.lang.annotation.*; + +/** + * 标记公开接口,无需认证即可访问 + * 对应 NestJS 的 @Public() 装饰器 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Public { +} diff --git a/backend-java/src/main/java/com/competition/security/annotation/RequirePermission.java b/backend-java/src/main/java/com/competition/security/annotation/RequirePermission.java new file mode 100644 index 0000000..446b74a --- /dev/null +++ b/backend-java/src/main/java/com/competition/security/annotation/RequirePermission.java @@ -0,0 +1,20 @@ +package com.competition.security.annotation; + +import java.lang.annotation.*; + +/** + * 权限校验注解 + * 对应 NestJS 的 @RequirePermission() 装饰器 + * 多个权限之间为 OR 关系(拥有任一权限即可访问) + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RequirePermission { + + /** + * 所需权限码,格式:resource:action + * 例如:contest:create, user:read + */ + String[] value(); +} diff --git a/backend-java/src/main/java/com/competition/security/aspect/PermissionAspect.java b/backend-java/src/main/java/com/competition/security/aspect/PermissionAspect.java new file mode 100644 index 0000000..67e582e --- /dev/null +++ b/backend-java/src/main/java/com/competition/security/aspect/PermissionAspect.java @@ -0,0 +1,57 @@ +package com.competition.security.aspect; + +import com.competition.common.exception.BusinessException; +import com.competition.common.enums.ErrorCode; +import com.competition.common.util.SecurityUtil; +import com.competition.security.annotation.RequirePermission; +import com.competition.security.model.LoginUser; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Set; + +/** + * 权限校验切面 + * 超管(super_admin 角色)绕过所有权限检查 + * 多个权限之间为 OR 关系 + */ +@Slf4j +@Aspect +@Component +public class PermissionAspect { + + @Before("@annotation(requirePermission)") + public void checkPermission(JoinPoint joinPoint, RequirePermission requirePermission) { + LoginUser currentUser = SecurityUtil.getCurrentUser(); + if (currentUser == null) { + throw BusinessException.of(ErrorCode.UNAUTHORIZED); + } + + // 超管绕过权限检查 + if (currentUser.isSuperAdmin()) { + return; + } + + String[] requiredPermissions = requirePermission.value(); + Set userPermissions = currentUser.getPermissions(); + + if (userPermissions == null || userPermissions.isEmpty()) { + log.warn("权限校验失败,用户:{},所需权限:{}", currentUser.getUsername(), Arrays.toString(requiredPermissions)); + throw BusinessException.of(ErrorCode.FORBIDDEN); + } + + // OR 逻辑:拥有任一权限即可 + boolean hasPermission = Arrays.stream(requiredPermissions) + .anyMatch(userPermissions::contains); + + if (!hasPermission) { + log.warn("权限校验失败,用户:{},所需权限:{},用户权限:{}", + currentUser.getUsername(), Arrays.toString(requiredPermissions), userPermissions); + throw BusinessException.of(ErrorCode.FORBIDDEN); + } + } +} diff --git a/backend-java/src/main/java/com/competition/security/config/SecurityConfig.java b/backend-java/src/main/java/com/competition/security/config/SecurityConfig.java new file mode 100644 index 0000000..9fdcaeb --- /dev/null +++ b/backend-java/src/main/java/com/competition/security/config/SecurityConfig.java @@ -0,0 +1,79 @@ +package com.competition.security.config; + +import com.competition.security.filter.JwtAuthenticationFilter; +import com.competition.security.handler.AuthEntryPoint; +import com.competition.security.handler.CustomAccessDeniedHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * Spring Security 配置 + * 注意:context-path 是 /api,所以 /api/auth/login 在 Security 配置里只需写 /auth/login + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final AuthEntryPoint authEntryPoint; + private final CustomAccessDeniedHandler accessDeniedHandler; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // 禁用 CSRF(无状态 JWT 不需要) + .csrf(AbstractHttpConfigurer::disable) + // 无状态会话 + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // 异常处理 + .exceptionHandling(exception -> exception + .authenticationEntryPoint(authEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + // 请求授权 + .authorizeHttpRequests(auth -> auth + // 公开接口 - 无需认证 + .requestMatchers("/auth/login").permitAll() + .requestMatchers("/public/auth/**").permitAll() + .requestMatchers(HttpMethod.GET, "/public/activities", "/public/activities/**").permitAll() + .requestMatchers(HttpMethod.GET, "/public/gallery", "/public/gallery/**").permitAll() + .requestMatchers(HttpMethod.GET, "/public/tags", "/public/tags/**").permitAll() + .requestMatchers(HttpMethod.GET, "/public/users/*/works").permitAll() + // Knife4j 文档 + .requestMatchers("/doc.html", "/webjars/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll() + // Druid 监控 + .requestMatchers("/druid/**").permitAll() + // OPTIONS 预检请求 + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + // 其余接口需要认证 + .anyRequest().authenticated()) + // JWT 过滤器 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/backend-java/src/main/java/com/competition/security/filter/JwtAuthenticationFilter.java b/backend-java/src/main/java/com/competition/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..acc43c6 --- /dev/null +++ b/backend-java/src/main/java/com/competition/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,121 @@ +package com.competition.security.filter; + +import com.competition.modules.sys.mapper.SysUserMapper; +import com.competition.security.model.LoginUser; +import com.competition.security.util.JwtUtil; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * JWT 认证过滤器 + * 从 Authorization 头中解析 Bearer token,验证后设置 SecurityContext + * 对于公开接口,解析失败不阻断请求(设置 null 用户) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final StringRedisTemplate redisTemplate; + private final SysUserMapper sysUserMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = extractToken(request); + + if (StringUtils.hasText(token)) { + try { + Claims claims = jwtUtil.parseToken(token); + Long userId = Long.parseLong(claims.getSubject()); + String username = claims.get("username", String.class); + Long tenantId = ((Number) claims.get("tenantId")).longValue(); + + // 从 Redis 获取用户角色和权限(如果 Redis 不可用则从 token 中获取) + LoginUser loginUser = buildLoginUser(userId, username, tenantId); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception e) { + log.debug("Token 解析失败:{}", e.getMessage()); + // 不阻断请求,让 Spring Security 根据配置决定是否放行 + } + } + + filterChain.doFilter(request, response); + } + + private String extractToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + /** + * 构建登录用户对象 + * 优先从 Redis 获取角色/权限,Redis 不可用时回退到数据库查询 + */ + private LoginUser buildLoginUser(Long userId, String username, Long tenantId) { + LoginUser loginUser = new LoginUser(); + loginUser.setUserId(userId); + loginUser.setUsername(username); + loginUser.setTenantId(tenantId); + + Set roles = new HashSet<>(); + Set permissions = new HashSet<>(); + + // 优先从 Redis 获取 + try { + String rolesKey = "user:roles:" + userId; + String permsKey = "user:perms:" + userId; + Set cachedRoles = redisTemplate.opsForSet().members(rolesKey); + Set cachedPerms = redisTemplate.opsForSet().members(permsKey); + + if (cachedRoles != null && !cachedRoles.isEmpty()) { + roles = cachedRoles; + permissions = cachedPerms != null ? cachedPerms : new HashSet<>(); + } else { + // Redis 无缓存,回退到数据库查询 + roles = new HashSet<>(sysUserMapper.selectRolesByUserId(userId)); + permissions = new HashSet<>(sysUserMapper.selectPermissionsByUserId(userId)); + } + } catch (Exception e) { + // Redis 不可用,直接从数据库查询 + log.debug("Redis 不可用,从数据库查询用户权限:{}", e.getMessage()); + try { + roles = new HashSet<>(sysUserMapper.selectRolesByUserId(userId)); + permissions = new HashSet<>(sysUserMapper.selectPermissionsByUserId(userId)); + } catch (Exception dbEx) { + log.warn("数据库查询用户权限失败:{}", dbEx.getMessage()); + } + } + + loginUser.setRoles(roles); + loginUser.setPermissions(permissions); + loginUser.setSuperAdmin(roles.contains("super_admin")); + + return loginUser; + } +} diff --git a/backend-java/src/main/java/com/competition/security/handler/AuthEntryPoint.java b/backend-java/src/main/java/com/competition/security/handler/AuthEntryPoint.java new file mode 100644 index 0000000..fc8b779 --- /dev/null +++ b/backend-java/src/main/java/com/competition/security/handler/AuthEntryPoint.java @@ -0,0 +1,30 @@ +package com.competition.security.handler; + +import com.alibaba.fastjson2.JSON; +import com.competition.common.result.Result; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 认证入口点 - 未认证时返回 401 + */ +@Component +public class AuthEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + Result result = Result.error(401, "未登录或 Token 已过期", request.getRequestURI()); + response.getWriter().write(JSON.toJSONString(result)); + } +} diff --git a/backend-java/src/main/java/com/competition/security/handler/CustomAccessDeniedHandler.java b/backend-java/src/main/java/com/competition/security/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..3d94993 --- /dev/null +++ b/backend-java/src/main/java/com/competition/security/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,30 @@ +package com.competition.security.handler; + +import com.alibaba.fastjson2.JSON; +import com.competition.common.result.Result; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 访问拒绝处理器 - 无权限时返回 403 + */ +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + Result result = Result.error(403, "没有访问权限", request.getRequestURI()); + response.getWriter().write(JSON.toJSONString(result)); + } +} diff --git a/backend-java/src/main/java/com/competition/security/model/LoginUser.java b/backend-java/src/main/java/com/competition/security/model/LoginUser.java new file mode 100644 index 0000000..3288db6 --- /dev/null +++ b/backend-java/src/main/java/com/competition/security/model/LoginUser.java @@ -0,0 +1,61 @@ +package com.competition.security.model; + +import lombok.Data; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Spring Security 用户模型 + */ +@Data +public class LoginUser implements UserDetails { + + private Long userId; + private String username; + private String password; + private String nickname; + private Long tenantId; + private String tenantCode; + private boolean superAdmin; + private Set roles; + private Set permissions; + + @Override + public Collection getAuthorities() { + // 将角色和权限都作为 GrantedAuthority + List authorities = new java.util.ArrayList<>(); + if (roles != null) { + roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role))); + } + if (permissions != null) { + permissions.forEach(perm -> authorities.add(new SimpleGrantedAuthority(perm))); + } + return authorities; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/backend-java/src/main/java/com/competition/security/util/JwtUtil.java b/backend-java/src/main/java/com/competition/security/util/JwtUtil.java new file mode 100644 index 0000000..e8f0f89 --- /dev/null +++ b/backend-java/src/main/java/com/competition/security/util/JwtUtil.java @@ -0,0 +1,122 @@ +package com.competition.security.util; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +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.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * JWT 工具类 + * Token payload 格式与 NestJS 保持一致:{ sub: userId, username, tenantId } + */ +@Slf4j +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.expiration}") + private Long expiration; + + private SecretKey getSigningKey() { + // 如果密钥长度不够,自动用 HMAC-SHA256 + byte[] keyBytes; + try { + keyBytes = Decoders.BASE64.decode(secret); + } catch (Exception e) { + keyBytes = secret.getBytes(); + } + if (keyBytes.length < 32) { + // 补齐到 32 字节 + byte[] padded = new byte[32]; + System.arraycopy(keyBytes, 0, padded, 0, Math.min(keyBytes.length, 32)); + keyBytes = padded; + } + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * 生成 JWT Token + * payload: { sub: userId, username, tenantId } + */ + public String generateToken(Long userId, String username, Long tenantId) { + Map claims = new HashMap<>(); + claims.put("username", username); + claims.put("tenantId", tenantId); + + return Jwts.builder() + .claims(claims) + .subject(String.valueOf(userId)) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey()) + .compact(); + } + + /** + * 解析 Token,获取 Claims + */ + public Claims parseToken(String token) { + try { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + log.warn("Token 已过期"); + throw e; + } catch (JwtException e) { + log.warn("Token 解析失败:{}", e.getMessage()); + throw e; + } + } + + /** + * 从 Token 中获取用户 ID + */ + public Long getUserId(String token) { + Claims claims = parseToken(token); + return Long.parseLong(claims.getSubject()); + } + + /** + * 从 Token 中获取用户名 + */ + public String getUsername(String token) { + Claims claims = parseToken(token); + return claims.get("username", String.class); + } + + /** + * 从 Token 中获取租户 ID + */ + public Long getTenantId(String token) { + Claims claims = parseToken(token); + Object tenantId = claims.get("tenantId"); + if (tenantId instanceof Number) { + return ((Number) tenantId).longValue(); + } + return Long.parseLong(tenantId.toString()); + } + + /** + * 验证 Token 是否有效 + */ + public boolean validateToken(String token) { + try { + parseToken(token); + return true; + } catch (JwtException e) { + return false; + } + } +} diff --git a/backend-java/src/main/resources/application-dev.yml b/backend-java/src/main/resources/application-dev.yml new file mode 100644 index 0000000..d621656 --- /dev/null +++ b/backend-java/src/main/resources/application-dev.yml @@ -0,0 +1,38 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:competition_management}?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:} + type: com.alibaba.druid.pool.DruidDataSource + druid: + initial-size: 5 + min-idle: 5 + max-active: 20 + max-wait: 60000 + + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: 0 + + flyway: + clean-disabled: false + +# 开发环境开启 SQL 日志 +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + +oss: + secret-id: ${COS_SECRET_ID:} + secret-key: ${COS_SECRET_KEY:} + bucket: ${COS_BUCKET:} + region: ${COS_REGION:ap-guangzhou} + url-prefix: ${COS_URL_PREFIX:} + +logging: + level: + com.competition: debug diff --git a/backend-java/src/main/resources/application-prod.yml b/backend-java/src/main/resources/application-prod.yml new file mode 100644 index 0000000..237919c --- /dev/null +++ b/backend-java/src/main/resources/application-prod.yml @@ -0,0 +1,40 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB_HOST}:${DB_PORT:3306}/${DB_NAME:competition_management}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=true + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + type: com.alibaba.druid.pool.DruidDataSource + druid: + initial-size: 10 + min-idle: 10 + max-active: 50 + max-wait: 60000 + + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD} + database: 0 + + flyway: + clean-disabled: true + +# 生产环境关闭 SQL 日志和 Swagger +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl + +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false + +knife4j: + enable: false + +logging: + level: + com.competition: info diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml new file mode 100644 index 0000000..03c3de2 --- /dev/null +++ b/backend-java/src/main/resources/application.yml @@ -0,0 +1,58 @@ +server: + port: 3234 + servlet: + context-path: /api + +spring: + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} + + # Jackson 全局配置 + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: Asia/Shanghai + default-property-inclusion: non_null + + # Flyway 数据库迁移(暂不启用,先用现有表名测试) + flyway: + enabled: false + locations: classpath:db/migration + baseline-on-migrate: true + baseline-version: 0 + + # 文件上传限制 + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + +# MyBatis-Plus 配置 +mybatis-plus: + mapper-locations: classpath*:mapper/**/*.xml + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto + # 逻辑删除暂不启用(待 Flyway V2 添加 deleted 列后启用) + # logic-delete-field: deleted + # logic-delete-value: 1 + # logic-not-delete-value: 0 + +# JWT 配置 +jwt: + secret: ${JWT_SECRET:your-secret-key-change-in-production} + expiration: 604800000 # 7天(毫秒) + +# Knife4j 文档配置 +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true + +knife4j: + enable: true + setting: + language: zh_cn diff --git a/backend-java/src/main/resources/db/migration/V1__rename_tables.sql b/backend-java/src/main/resources/db/migration/V1__rename_tables.sql new file mode 100644 index 0000000..0ee059a --- /dev/null +++ b/backend-java/src/main/resources/db/migration/V1__rename_tables.sql @@ -0,0 +1,137 @@ +-- ============================================ +-- V1: 重命名所有表为标准 t_{模块}_{表名} 格式 +-- 同时创建向后兼容视图(让 NestJS/Prisma 继续工作) +-- ============================================ + +-- ========== 系统模块 t_sys_ ========== + +RENAME TABLE `tenants` TO `t_sys_tenant`; +CREATE VIEW `tenants` AS SELECT * FROM `t_sys_tenant`; + +RENAME TABLE `users` TO `t_sys_user`; +CREATE VIEW `users` AS SELECT * FROM `t_sys_user`; + +RENAME TABLE `user_roles` TO `t_sys_user_role`; +CREATE VIEW `user_roles` AS SELECT * FROM `t_sys_user_role`; + +RENAME TABLE `roles` TO `t_sys_role`; +CREATE VIEW `roles` AS SELECT * FROM `t_sys_role`; + +RENAME TABLE `role_permissions` TO `t_sys_role_permission`; +CREATE VIEW `role_permissions` AS SELECT * FROM `t_sys_role_permission`; + +RENAME TABLE `permissions` TO `t_sys_permission`; +CREATE VIEW `permissions` AS SELECT * FROM `t_sys_permission`; + +RENAME TABLE `menus` TO `t_sys_menu`; +CREATE VIEW `menus` AS SELECT * FROM `t_sys_menu`; + +RENAME TABLE `tenant_menus` TO `t_sys_tenant_menu`; +CREATE VIEW `tenant_menus` AS SELECT * FROM `t_sys_tenant_menu`; + +RENAME TABLE `dicts` TO `t_sys_dict`; +CREATE VIEW `dicts` AS SELECT * FROM `t_sys_dict`; + +RENAME TABLE `dict_items` TO `t_sys_dict_item`; +CREATE VIEW `dict_items` AS SELECT * FROM `t_sys_dict_item`; + +RENAME TABLE `configs` TO `t_sys_config`; +CREATE VIEW `configs` AS SELECT * FROM `t_sys_config`; + +RENAME TABLE `logs` TO `t_sys_log`; +CREATE VIEW `logs` AS SELECT * FROM `t_sys_log`; + +-- ========== 用户模块 t_user_ ========== + +RENAME TABLE `children` TO `t_user_child`; +CREATE VIEW `children` AS SELECT * FROM `t_user_child`; + +RENAME TABLE `user_parent_child` TO `t_user_parent_child`; +CREATE VIEW `user_parent_child` AS SELECT * FROM `t_user_parent_child`; + +-- ========== 业务模块 t_biz_(赛事) ========== + +RENAME TABLE `t_contest` TO `t_biz_contest`; +CREATE VIEW `t_contest` AS SELECT * FROM `t_biz_contest`; + +RENAME TABLE `t_contest_attachment` TO `t_biz_contest_attachment`; +CREATE VIEW `t_contest_attachment` AS SELECT * FROM `t_biz_contest_attachment`; + +RENAME TABLE `t_contest_team` TO `t_biz_contest_team`; +CREATE VIEW `t_contest_team` AS SELECT * FROM `t_biz_contest_team`; + +RENAME TABLE `t_contest_team_member` TO `t_biz_contest_team_member`; +CREATE VIEW `t_contest_team_member` AS SELECT * FROM `t_biz_contest_team_member`; + +RENAME TABLE `t_contest_registration` TO `t_biz_contest_registration`; +CREATE VIEW `t_contest_registration` AS SELECT * FROM `t_biz_contest_registration`; + +RENAME TABLE `t_contest_registration_teacher` TO `t_biz_contest_registration_teacher`; +CREATE VIEW `t_contest_registration_teacher` AS SELECT * FROM `t_biz_contest_registration_teacher`; + +RENAME TABLE `t_contest_work` TO `t_biz_contest_work`; +CREATE VIEW `t_contest_work` AS SELECT * FROM `t_biz_contest_work`; + +RENAME TABLE `t_contest_work_attachment` TO `t_biz_contest_work_attachment`; +CREATE VIEW `t_contest_work_attachment` AS SELECT * FROM `t_biz_contest_work_attachment`; + +RENAME TABLE `t_contest_work_judge_assignment` TO `t_biz_contest_work_judge_assignment`; +CREATE VIEW `t_contest_work_judge_assignment` AS SELECT * FROM `t_biz_contest_work_judge_assignment`; + +RENAME TABLE `t_contest_work_score` TO `t_biz_contest_work_score`; +CREATE VIEW `t_contest_work_score` AS SELECT * FROM `t_biz_contest_work_score`; + +RENAME TABLE `t_contest_review_rule` TO `t_biz_contest_review_rule`; +CREATE VIEW `t_contest_review_rule` AS SELECT * FROM `t_biz_contest_review_rule`; + +RENAME TABLE `t_contest_notice` TO `t_biz_contest_notice`; +CREATE VIEW `t_contest_notice` AS SELECT * FROM `t_biz_contest_notice`; + +RENAME TABLE `t_contest_judge` TO `t_biz_contest_judge`; +CREATE VIEW `t_contest_judge` AS SELECT * FROM `t_biz_contest_judge`; + +RENAME TABLE `t_preset_comment` TO `t_biz_preset_comment`; +CREATE VIEW `t_preset_comment` AS SELECT * FROM `t_biz_preset_comment`; + +-- ========== 业务模块 t_biz_(作业) ========== + +RENAME TABLE `t_homework` TO `t_biz_homework`; +CREATE VIEW `t_homework` AS SELECT * FROM `t_biz_homework`; + +RENAME TABLE `t_homework_submission` TO `t_biz_homework_submission`; +CREATE VIEW `t_homework_submission` AS SELECT * FROM `t_biz_homework_submission`; + +RENAME TABLE `t_homework_review_rule` TO `t_biz_homework_review_rule`; +CREATE VIEW `t_homework_review_rule` AS SELECT * FROM `t_biz_homework_review_rule`; + +RENAME TABLE `t_homework_score` TO `t_biz_homework_score`; +CREATE VIEW `t_homework_score` AS SELECT * FROM `t_biz_homework_score`; + +-- ========== UGC 模块 t_ugc_ ========== + +RENAME TABLE `user_works` TO `t_ugc_work`; +CREATE VIEW `user_works` AS SELECT * FROM `t_ugc_work`; + +RENAME TABLE `user_work_pages` TO `t_ugc_work_page`; +CREATE VIEW `user_work_pages` AS SELECT * FROM `t_ugc_work_page`; + +RENAME TABLE `work_tags` TO `t_ugc_tag`; +CREATE VIEW `work_tags` AS SELECT * FROM `t_ugc_tag`; + +RENAME TABLE `work_tag_relations` TO `t_ugc_work_tag`; +CREATE VIEW `work_tag_relations` AS SELECT * FROM `t_ugc_work_tag`; + +RENAME TABLE `user_work_likes` TO `t_ugc_work_like`; +CREATE VIEW `user_work_likes` AS SELECT * FROM `t_ugc_work_like`; + +RENAME TABLE `user_work_favorites` TO `t_ugc_work_favorite`; +CREATE VIEW `user_work_favorites` AS SELECT * FROM `t_ugc_work_favorite`; + +RENAME TABLE `user_work_comments` TO `t_ugc_work_comment`; +CREATE VIEW `user_work_comments` AS SELECT * FROM `t_ugc_work_comment`; + +RENAME TABLE `user_work_reports` TO `t_ugc_work_report`; +CREATE VIEW `user_work_reports` AS SELECT * FROM `t_ugc_work_report`; + +RENAME TABLE `content_review_logs` TO `t_ugc_review_log`; +CREATE VIEW `content_review_logs` AS SELECT * FROM `t_ugc_review_log`; diff --git a/backend-java/src/main/resources/db/migration/V2__add_audit_fields.sql b/backend-java/src/main/resources/db/migration/V2__add_audit_fields.sql new file mode 100644 index 0000000..30670e7 --- /dev/null +++ b/backend-java/src/main/resources/db/migration/V2__add_audit_fields.sql @@ -0,0 +1,125 @@ +-- ============================================ +-- V2: 为所有表添加 Java 规范审计字段 +-- create_by VARCHAR(50) 创建人账号 +-- update_by VARCHAR(50) 更新人账号 +-- deleted TINYINT 逻辑删除(0-未删除,1-已删除) +-- ============================================ + +-- 系统模块 +ALTER TABLE `t_sys_tenant` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_sys_tenant` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_sys_tenant` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_sys_user` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_sys_user` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_sys_user` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_sys_role` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_sys_role` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_sys_role` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_sys_permission` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_sys_permission` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_sys_permission` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_sys_menu` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_sys_menu` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_sys_menu` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_sys_dict` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_sys_dict` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_sys_dict` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_sys_dict_item` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_sys_dict_item` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_sys_dict_item` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_sys_config` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_sys_config` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_sys_config` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +-- 用户模块 +ALTER TABLE `t_user_child` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_user_child` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_user_child` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +-- 赛事模块 +ALTER TABLE `t_biz_contest` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_contest` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_contest_attachment` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_attachment` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_contest_attachment` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_contest_team` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_team` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_contest_team` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_contest_team_member` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_team_member` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_contest_team_member` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_contest_registration` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_registration` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_contest_registration` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_contest_registration_teacher` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_registration_teacher` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_contest_registration_teacher` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_contest_work` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_work` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; + +ALTER TABLE `t_biz_contest_work_attachment` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_work_attachment` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_contest_work_attachment` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_contest_work_judge_assignment` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_work_judge_assignment` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_contest_work_judge_assignment` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_contest_work_score` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_work_score` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; + +ALTER TABLE `t_biz_contest_review_rule` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_review_rule` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_contest_review_rule` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_contest_notice` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_notice` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_contest_notice` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_contest_judge` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_contest_judge` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_contest_judge` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_preset_comment` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_preset_comment` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; + +-- 作业模块 +ALTER TABLE `t_biz_homework` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_homework` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_homework` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_homework_submission` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_homework_submission` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; + +ALTER TABLE `t_biz_homework_review_rule` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_homework_review_rule` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_biz_homework_review_rule` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_biz_homework_score` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_biz_homework_score` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; + +-- UGC 模块 +ALTER TABLE `t_ugc_work` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_ugc_work` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_ugc_work` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +ALTER TABLE `t_ugc_tag` ADD COLUMN `create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人账号'; +ALTER TABLE `t_ugc_tag` ADD COLUMN `update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人账号'; +ALTER TABLE `t_ugc_tag` ADD COLUMN `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'; + +-- 需要删除旧视图并重新创建以包含新字段 +-- MySQL 视图会自动包含底层表的新列,所以 SELECT * 视图无需重建 diff --git a/backend-java/src/main/resources/logback-spring.xml b/backend-java/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..07eaf4e --- /dev/null +++ b/backend-java/src/main/resources/logback-spring.xml @@ -0,0 +1,42 @@ + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n + UTF-8 + + + + + + logs/${APP_NAME}.log + + logs/${APP_NAME}.%d{yyyy-MM-dd}.log + 30 + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n + UTF-8 + + + + + + + + + + + + + + + + + + + + diff --git a/backend-java/src/main/resources/mapper/sys/SysUserMapper.xml b/backend-java/src/main/resources/mapper/sys/SysUserMapper.xml new file mode 100644 index 0000000..fa7ab33 --- /dev/null +++ b/backend-java/src/main/resources/mapper/sys/SysUserMapper.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + +