diff --git a/.xcodemap/config/xcodemap-class-filter.yaml b/.xcodemap/config/xcodemap-class-filter.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4fb872b63cd9a36f42c6f7869b95320371f45689 --- /dev/null +++ b/.xcodemap/config/xcodemap-class-filter.yaml @@ -0,0 +1,23 @@ +autoDetectedPackages: +- cn.edu.gzgs +enableAutoDetect: true +entryDisplayConfig: + excludedPathPatterns: [] + skipJsCss: true +funcDisplayConfig: + skipConstructors: false + skipFieldAccess: true + skipFieldChange: true + skipGetters: false + skipNonProjectPackages: false + skipPrivateMethods: false + skipSetters: false +ignoreSameClassCall: null +ignoreSamePackageCall: null +includedPackagePrefixes: null +includedParentClasses: null +name: xcodemap-filter +recordMode: all +sourceDisplayConfig: + color: blue +startOnDebug: false diff --git a/workload-pojo/src/main/java/cn/edu/gzgs/entity/ListMeta.java b/workload-pojo/src/main/java/cn/edu/gzgs/entity/ListMeta.java new file mode 100644 index 0000000000000000000000000000000000000000..dc526659cd8d7f85f61e0c5fa2a6149792e72829 --- /dev/null +++ b/workload-pojo/src/main/java/cn/edu/gzgs/entity/ListMeta.java @@ -0,0 +1,28 @@ +package cn.edu.gzgs.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +@Data +@TableName("list_meta") +public class ListMeta { + @TableId("id") + private String id; + + @TableField("project_id") + private String projectId; + + @TableField("name") + private String name; + + @TableField("description") + private String description; + + @TableField("status") + private String status; // pending, approved, rejected + + @TableField("leader_id") + private String leaderId; +} \ No newline at end of file diff --git a/workload-pojo/src/main/java/cn/edu/gzgs/entity/Project.java b/workload-pojo/src/main/java/cn/edu/gzgs/entity/Project.java new file mode 100644 index 0000000000000000000000000000000000000000..00f367747df403499969e36a8aea5443b412c232 --- /dev/null +++ b/workload-pojo/src/main/java/cn/edu/gzgs/entity/Project.java @@ -0,0 +1,18 @@ +package cn.edu.gzgs.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; + +import java.io.Serializable; + +public class Project implements Serializable { + @TableId(value = "id", type = IdType.AUTO) + private String id; // 项目ID + + @TableField("name") + private String name; // 项目名称 + + @TableField("type") + private String type; // 项目类型 +} diff --git a/workload-pojo/src/main/java/cn/edu/gzgs/vo/DeclareRequest.java b/workload-pojo/src/main/java/cn/edu/gzgs/vo/DeclareRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..9a7c6ad6d6c8993633e6f2664be881bed090f3fc --- /dev/null +++ b/workload-pojo/src/main/java/cn/edu/gzgs/vo/DeclareRequest.java @@ -0,0 +1,22 @@ +package cn.edu.gzgs.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class DeclareRequest { + @Schema(description = "项目ID", example = "600.001") + private String projectId; + + @Schema(description = "名单表名称", example = "教师名单") + private String tableName; + + @Schema(description = "列名列表") + private List columns; + + @Schema(description = "数据记录") + private List> data; +} \ No newline at end of file diff --git a/workload-pojo/src/main/java/cn/edu/gzgs/vo/DeclareResponse.java b/workload-pojo/src/main/java/cn/edu/gzgs/vo/DeclareResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..969d5c7d8bd838baec7fffd652d4f7f2604c4eec --- /dev/null +++ b/workload-pojo/src/main/java/cn/edu/gzgs/vo/DeclareResponse.java @@ -0,0 +1,12 @@ +package cn.edu.gzgs.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class DeclareResponse { + @Schema(description = "新创建的名单ID", example = "600.001.004") + private String listId; +} \ No newline at end of file diff --git a/workload-pojo/src/main/java/cn/edu/gzgs/vo/ListDetailResponse.java b/workload-pojo/src/main/java/cn/edu/gzgs/vo/ListDetailResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..3a9bf4c4dfbb08e3930d2241562cb1c31d64a665 --- /dev/null +++ b/workload-pojo/src/main/java/cn/edu/gzgs/vo/ListDetailResponse.java @@ -0,0 +1,14 @@ +package cn.edu.gzgs.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +@AllArgsConstructor +public class ListDetailResponse { + private List columns; + private List> records; +} \ No newline at end of file diff --git a/workload-pojo/src/main/java/cn/edu/gzgs/vo/ProjectTreeVO.java b/workload-pojo/src/main/java/cn/edu/gzgs/vo/ProjectTreeVO.java new file mode 100644 index 0000000000000000000000000000000000000000..5ec7b0eea6c047ef13b741c221efee3a3d01fb68 --- /dev/null +++ b/workload-pojo/src/main/java/cn/edu/gzgs/vo/ProjectTreeVO.java @@ -0,0 +1,24 @@ +package cn.edu.gzgs.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "获取项目-名单树") +public class ProjectTreeVO implements Serializable { + @Schema(description = "节点ID") + private String id; + + @Schema(description = "节点名称") + private String name; + + @Schema(description = "节点类型: project-项目, list-名单") + private String type; + + @Schema(description = "子节点列表") + private List children = new ArrayList<>(); +} \ No newline at end of file diff --git a/workload-server/src/main/java/cn/edu/gzgs/config/HttpClientConfig.java b/workload-server/src/main/java/cn/edu/gzgs/config/HttpClientConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..1639f42653705b7f6fe90e329949a7bd9720662a --- /dev/null +++ b/workload-server/src/main/java/cn/edu/gzgs/config/HttpClientConfig.java @@ -0,0 +1,40 @@ +package cn.edu.gzgs.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +/** + * HTTP 客户端配置。 + * + *

提供应用内通用的 {@link RestTemplate} Bean,统一连接与读取超时, + * 以便在启动时或业务逻辑中进行稳定的 HTTP 调用。

+ * + * @author Zyf + */ +@Configuration +public class HttpClientConfig { + + /** + * 创建并配置 RestTemplate。 + * + * 输入: + * - builder:Spring Boot 提供的 `RestTemplateBuilder`,用于链式配置。 + * 输出: + * - 返回配置好超时参数的 `RestTemplate` 实例。 + * 作用: + * - 提供统一、可复用的 HTTP 客户端,避免在各处重复创建与配置。 + */ + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(15)) + .build(); + } +} + + diff --git a/workload-server/src/main/java/cn/edu/gzgs/config/StartupFrontendRunner.java b/workload-server/src/main/java/cn/edu/gzgs/config/StartupFrontendRunner.java new file mode 100644 index 0000000000000000000000000000000000000000..e10dda680c263c2a4cf2e0225dc5569f8e8c9eea --- /dev/null +++ b/workload-server/src/main/java/cn/edu/gzgs/config/StartupFrontendRunner.java @@ -0,0 +1,101 @@ +package cn.edu.gzgs.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +/** + * 启动探测与前端地址输出组件。 + * + *

应用启动完成后,尝试与前端地址建立连接以验证可达性, + * 并在控制台打印可点击的前端访问 URL,便于快速打开。

+ * + *

配置项:

+ *
    + *
  • application.frontend.url:前端登录地址
  • + *
+ * + *

命名规范:变量使用驼峰式命名法;常量使用全大写加下划线分隔;类名使用大驼峰命名法。

+ * + * @author Zyf + */ +@Component +public class StartupFrontendRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(StartupFrontendRunner.class); + + private final RestTemplate restTemplate; + + @Value("${application.frontend.url}") + private String frontendUrl; + + public StartupFrontendRunner(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + /** + * 应用启动后执行探测并输出前端 URL。 + * + * 输入: + * - args:应用启动参数(未使用)。 + * 输出: + * - 无返回值;在日志与控制台输出前端 URL 与连通性结果。 + * 作用: + * - 验证前端地址的可达性,并提供可点击链接以便快速访问。 + */ + @Override + public void run(ApplicationArguments args) { + printFrontendUrl(); + probeFrontendConnectivity(); + } + + /** + * 打印前端 URL 到日志与标准输出,便于控制台点击。 + */ + private void printFrontendUrl() { + if (frontendUrl == null || frontendUrl.isBlank()) { + log.warn("前端地址未配置:application.frontend.url"); + return; + } + + // 日志输出 + log.info("前端地址:{}", frontendUrl); + + // 控制台输出(很多终端/IDE 控制台会将 URL 识别为可点击链接) + System.out.println("[Workload-Management] 前端地址: " + frontendUrl); + } + + /** + * 以轻量方式探测前端连通性。 + * + *

优先使用 HEAD 请求;若失败则回退到 GET 请求,以最大化兼容性。

+ */ + private void probeFrontendConnectivity() { + if (frontendUrl == null || frontendUrl.isBlank()) { + return; + } + + try { + // 尝试 HEAD 探测 + restTemplate.headForHeaders(frontendUrl); + log.info("前端连通性检测成功(HEAD):{}", frontendUrl); + return; + } catch (RestClientException ignored) { + // 回退到 GET 探测 + } + + try { + restTemplate.getForEntity(frontendUrl, String.class); + log.info("前端连通性检测成功(GET):{}", frontendUrl); + } catch (RestClientException ex) { + log.warn("前端连通性检测失败:{},原因:{}", frontendUrl, ex.getMessage()); + } + } +} + + diff --git a/workload-server/src/main/java/cn/edu/gzgs/config/security/JwtAuthenticationFilter.java b/workload-server/src/main/java/cn/edu/gzgs/config/security/JwtAuthenticationFilter.java index 3c6b98da50e59fa0b109e48fd17a1678fad93a7e..d47fe97a2b9d3f836ddc35cf9780d8e1123aecae 100644 --- a/workload-server/src/main/java/cn/edu/gzgs/config/security/JwtAuthenticationFilter.java +++ b/workload-server/src/main/java/cn/edu/gzgs/config/security/JwtAuthenticationFilter.java @@ -6,7 +6,6 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -14,11 +13,21 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +/** + * JWT 认证过滤器 + * + * - 从请求头提取并解析 JWT,校验通过则将认证信息写入 SecurityContext + * - 对公共路径与 CORS 预检请求直接放行 + * - 对空、占位、格式错误或过期的 Token 温和处理并继续放行,避免无意义错误日志 + * + * @author Zyf + */ @Component @Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { @@ -26,19 +35,60 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtils jwtUtils; private final UserDetailsService userDetailsService; - @Autowired + /** + * 构造函数:注入 JWT 工具与用户详情服务 + * + * @param jwtUtils JWT 工具类 + * @param userDetailsService 用户详情服务 + */ public JwtAuthenticationFilter(JwtUtils jwtUtils, UserDetailsService userDetailsService) { this.jwtUtils = jwtUtils; this.userDetailsService = userDetailsService; } + /** 公共放行路径(无需鉴权) */ + private static final String[] PUBLIC_PATH_PATTERNS = new String[] { + "/api/auth/login", + "/api/captcha", + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**", + "/webjars/**" + }; + + private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher(); + + /** + * 决定是否跳过本过滤器。 + * + * @param request 当前 HTTP 请求 + * @return true 表示跳过过滤 + */ + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { + String uri = request.getRequestURI(); + + // 放行 CORS 预检请求 + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + + // 放行公共路径 + for (String pattern : PUBLIC_PATH_PATTERNS) { + if (PATH_MATCHER.match(pattern, uri)) { + return true; + } + } + return false; + } + @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { - // 记录当前请求的URI - log.info("JwtAuthenticationFilter: 处理请求,URI: {}", request.getRequestURI()); + // 记录当前请求的URI(调试级别,减少噪音) + log.debug("JwtAuthenticationFilter: 处理请求,URI: {}", request.getRequestURI()); // 获取请求头中的授权信息 final String authHeader = request.getHeader("Authorization"); @@ -48,7 +98,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { // 检查Authorization头是否存在,且是否以"Bearer "开头 if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) { // 如果没有携带Token,或者格式不正确,直接继续后续过滤链 - log.warn("JwtAuthenticationFilter: Authorization头缺失、不是Bearer类型或为空。URI: {}", request.getRequestURI()); + log.debug("JwtAuthenticationFilter: Authorization头缺失、不是Bearer类型或为空。URI: {}", request.getRequestURI()); filterChain.doFilter(request, response); return; } @@ -57,19 +107,32 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { jwt = authHeader.substring(7); log.debug("JwtAuthenticationFilter: Extracted JWT: {}", jwt); + // 解析前的快速合法性校验,过滤掉空、占位或显著非法的 token + if (!StringUtils.hasText(jwt) || "null".equalsIgnoreCase(jwt) || "undefined".equalsIgnoreCase(jwt)) { + log.debug("JwtAuthenticationFilter: JWT 为空或为占位值,直接放行。URI: {}", request.getRequestURI()); + filterChain.doFilter(request, response); + return; + } + + int dotCount = StringUtils.countOccurrencesOf(jwt, "."); + if (dotCount != 2) { + log.debug("JwtAuthenticationFilter: JWT 格式不正确(应包含2个点),实际: {},URI: {}", dotCount, request.getRequestURI()); + filterChain.doFilter(request, response); + return; + } + try { // 解析JWT,提取用户名 username = jwtUtils.extractUsername(jwt); log.debug("JwtAuthenticationFilter: 从JWT中提取的用户名: {}", username); } catch (io.jsonwebtoken.ExpiredJwtException e) { - // JWT已过期 - log.warn("JwtAuthenticationFilter: JWT已过期,URI: {}。详情: {}", request.getRequestURI(), e.getMessage()); - // 可以选择在这里直接返回401,但此处依赖Spring Security处理 + // JWT已过期(无需打印堆栈,减少噪音) + log.debug("JwtAuthenticationFilter: JWT已过期,URI: {},原因: {}", request.getRequestURI(), e.getMessage()); filterChain.doFilter(request, response); return; } catch (Exception e) { - // 其他解析错误 - log.error("JwtAuthenticationFilter: 提取JWT中的用户名时出错,URI: {}。错误信息: ", request.getRequestURI(), e); + // 其他解析错误(降级为调试日志,避免误报为错误) + log.debug("JwtAuthenticationFilter: 解析JWT失败,URI: {},原因: {}", request.getRequestURI(), e.getMessage()); filterChain.doFilter(request, response); return; } @@ -104,12 +167,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { SecurityContextHolder.getContext().setAuthentication(authToken); } else { // JWT校验失败 - log.warn("JwtAuthenticationFilter: JWT验证失败,用户 '{}'。Token可能无效或用户名不匹配。", username); + log.debug("JwtAuthenticationFilter: JWT验证失败,用户 '{}'。Token可能无效或用户名不匹配。", username); } } else { // 如果没有用户名或者身份信息已存在 if (!StringUtils.hasText(username)) { - log.warn("JwtAuthenticationFilter: JWT中无法提取用户名(为空或null)"); + log.debug("JwtAuthenticationFilter: JWT中无法提取用户名(为空或null)"); } if (SecurityContextHolder.getContext().getAuthentication() != null) { log.debug("JwtAuthenticationFilter: SecurityContext中已存在身份信息,用户名: '{}', 跳过JWT验证。", diff --git a/workload-server/src/main/java/cn/edu/gzgs/controller/ProjectController.java b/workload-server/src/main/java/cn/edu/gzgs/controller/ProjectController.java new file mode 100644 index 0000000000000000000000000000000000000000..3b09d0ea3b954e464384a1aea3a541b66fefc7b4 --- /dev/null +++ b/workload-server/src/main/java/cn/edu/gzgs/controller/ProjectController.java @@ -0,0 +1,162 @@ +package cn.edu.gzgs.controller; + +import cn.edu.gzgs.service.ProjectService; +import cn.edu.gzgs.service.impl.ProjectServicelmpl; +import cn.edu.gzgs.vo.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +@RestController +@RequestMapping("/api/project") +@Tag(name = "项目管理接口") +@Slf4j +public class ProjectController { + + // 自动装配 ProjectService + @Autowired + private ProjectService projectService; + + @GetMapping("/tree") + @Operation(summary = "获取项目-名单树") + public ResponseEntity>> getProjectTree() { + List tree = projectService.getProjectTree(); + return ResponseEntity.ok(new ApiResponse<>(200, "获取成功", tree)); + } + + @GetMapping("/list/{id}") + public ResponseEntity> getListDetail( + @PathVariable String id) { + + // 记录日志 + log.info("操作:获取名单明细 {}", id); + + // 校验ID格式 + if (!Pattern.matches("^[0-9]+\\.[0-9]+\\.[0-9]+$", id)) { + log.warn("名单ID格式不正确: {}", id); + return ResponseEntity.badRequest().body( + new ApiResponse<>(400, "名单ID格式不正确", null) + ); + } + + try { + ListDetailResponse response = projectService.getListDetail(id); + return ResponseEntity.ok( + new ApiResponse<>(200,"获取成功", response) + ); + } catch (ProjectServicelmpl.TableNotFoundException e) { + log.error("名单表不存在: {}", id); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body( + new ApiResponse<>(404, "名单表不存在", null) + ); + } catch (Exception e) { + log.error("获取名单明细失败: {}", e.getMessage()); + return ResponseEntity.internalServerError().body( + new ApiResponse<>(500, "服务器错误", null) + ); + } + } + + @GetMapping("/projects") + @Operation(summary = "获取所有项目列表") + public ResponseEntity>>> getAllProjects() { + log.info("操作:获取所有项目列表"); + + try { + List> projects = projectService.getAllProjects(); + return ResponseEntity.ok( + new ApiResponse<>(200, "获取成功", projects) + ); + } catch (Exception e) { + log.error("获取项目列表失败: {}", e.getMessage()); + return ResponseEntity.internalServerError().body( + new ApiResponse<>(500, "服务器错误", null) + ); + } + } + + @GetMapping("/meta") + @Operation(summary = "获取名单元数据列表") + public ResponseEntity>>> getListMeta( + @RequestParam(value = "project_id", required = false) String projectId, + @AuthenticationPrincipal UserDetails userDetails) { + + log.info("操作:获取名单元数据, project_id: {}", projectId); + + try { + // 从认证信息中获取用户ID + String username = userDetails.getUsername(); + + // 转换用户ID格式(例如 "U0002" -> "3002") + String leaderId = convertUserId(username); + + // 获取名单元数据 + List> data = projectService.getListMetaByLeader(leaderId, projectId); + return ResponseEntity.ok( + new ApiResponse<>(200, "获取成功", data) + ); + } catch (Exception e) { + log.error("获取名单元数据失败: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(new ApiResponse<>(500, "服务器错误", null)); + } + } + + // 用户ID转换方法 + private String convertUserId(String username) { + if (username.startsWith("U")) { + // 示例转换:U0002 -> 3002 + return String.valueOf(3000 + Integer.parseInt(username.substring(1))); + } + return username; // 默认处理 + } + + @PostMapping("/declare") + @Operation(summary = "申报名单表") + public ResponseEntity> declareList( + @RequestBody DeclareRequest request, + @AuthenticationPrincipal UserDetails userDetails) { + + log.info("操作:申报名单表, projectId: {}", request.getProjectId()); + + try { + // 验证参数 + if (request.getProjectId() == null || request.getTableName() == null || + request.getColumns() == null || request.getData() == null) { + return ResponseEntity.badRequest() + .body(new ApiResponse<>(400, "参数错误", null)); + } + + // 获取 leaderId + String leaderId = userDetails.getUsername(); + leaderId = convertUserId(leaderId); + + // 执行申报 + DeclareResponse response = projectService.declareList( + leaderId, + request.getProjectId(), + request.getTableName(), + request.getColumns(), + request.getData() + ); + + return ResponseEntity.ok( + new ApiResponse<>(200, "申报成功", response) + ); + } catch (Exception e) { + log.error("申报失败: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(new ApiResponse<>(500, "服务器错误", null)); + } + } +} \ No newline at end of file diff --git a/workload-server/src/main/java/cn/edu/gzgs/mapper/ListMetaMapper.java b/workload-server/src/main/java/cn/edu/gzgs/mapper/ListMetaMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..ec001dd7589381dcc34ca1e6a291d1dc003208ff --- /dev/null +++ b/workload-server/src/main/java/cn/edu/gzgs/mapper/ListMetaMapper.java @@ -0,0 +1,9 @@ +package cn.edu.gzgs.mapper; + +import cn.edu.gzgs.entity.ListMeta; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ListMetaMapper extends BaseMapper { +} \ No newline at end of file diff --git a/workload-server/src/main/java/cn/edu/gzgs/mapper/ProjectMapper.java b/workload-server/src/main/java/cn/edu/gzgs/mapper/ProjectMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..32a73bf48db653024f915388ca3eb14a56238979 --- /dev/null +++ b/workload-server/src/main/java/cn/edu/gzgs/mapper/ProjectMapper.java @@ -0,0 +1,39 @@ +package cn.edu.gzgs.mapper; + +import cn.edu.gzgs.entity.Project; +import cn.edu.gzgs.vo.ProjectTreeVO; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import lombok.Data; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface ProjectMapper extends BaseMapper { + + // 查询项目及其名单信息 + @Select("SELECT " + + "p.id AS projectId, " + + "p.name AS projectName, " + + "l.id AS listId, " + + "l.name AS listName " + + "FROM project p " + + "LEFT JOIN list_meta l ON l.project_id = p.id " + + "WHERE (l.status = 'approved' OR l.status IS NULL) " + + "ORDER BY p.id, l.id") + List getProjectTreeRaw(); + + @Select("SELECT id, name FROM project ORDER BY id") + List> getAllProjects(); + + // 原始数据结构 + @Data + public static class ProjectTreeRaw { + private String projectId; + private String projectName; + private String listId; + private String listName; + } +} \ No newline at end of file diff --git a/workload-server/src/main/java/cn/edu/gzgs/service/ProjectService.java b/workload-server/src/main/java/cn/edu/gzgs/service/ProjectService.java new file mode 100644 index 0000000000000000000000000000000000000000..8aa48f3fdd514f78ee881f78cd205880891e2028 --- /dev/null +++ b/workload-server/src/main/java/cn/edu/gzgs/service/ProjectService.java @@ -0,0 +1,26 @@ +package cn.edu.gzgs.service; + +import cn.edu.gzgs.entity.Project; +import cn.edu.gzgs.vo.DeclareResponse; +import cn.edu.gzgs.vo.ListDetailResponse; +import cn.edu.gzgs.vo.ProjectTreeVO; +import com.baomidou.mybatisplus.extension.service.IService; + +import java.util.List; +import java.util.Map; + + +public interface ProjectService extends IService { + + List getProjectTree(); + ListDetailResponse getListDetail(String listId) throws Exception; + List> getAllProjects(); + List> getListMetaByLeader(String leaderId, String projectId); + DeclareResponse declareList( + String leaderId, + String projectId, + String tableName, + List columns, + List> data + ) throws Exception; +} \ No newline at end of file diff --git a/workload-server/src/main/java/cn/edu/gzgs/service/impl/ProjectServicelmpl.java b/workload-server/src/main/java/cn/edu/gzgs/service/impl/ProjectServicelmpl.java new file mode 100644 index 0000000000000000000000000000000000000000..2b29e3e7cc1b78938ace7bb7d9637dd44806ed03 --- /dev/null +++ b/workload-server/src/main/java/cn/edu/gzgs/service/impl/ProjectServicelmpl.java @@ -0,0 +1,274 @@ +package cn.edu.gzgs.service.impl; + + +import cn.edu.gzgs.entity.ListMeta; +import cn.edu.gzgs.entity.Project; +import cn.edu.gzgs.mapper.ListMetaMapper; +import cn.edu.gzgs.mapper.ProjectMapper; +import cn.edu.gzgs.service.ProjectService; +import cn.edu.gzgs.vo.DeclareResponse; +import cn.edu.gzgs.vo.ListDetailResponse; +import cn.edu.gzgs.vo.ProjectTreeVO; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.BadSqlGrammarException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class ProjectServicelmpl extends ServiceImpl implements ProjectService { + + @Autowired + private ProjectMapper projectMapper; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private ListMetaMapper listMetaMapper; + + @Override + public List getProjectTree() { + // 1. 获取原始数据 + List rawData = projectMapper.getProjectTreeRaw(); + + // 2. 构建树形结构 + Map projectMap = new LinkedHashMap<>(); + List result = new ArrayList<>(); + + // 第一遍:创建项目节点 + for (ProjectMapper.ProjectTreeRaw item : rawData) { + String projectId = item.getProjectId(); + + if (!projectMap.containsKey(projectId)) { + ProjectTreeVO projectNode = new ProjectTreeVO(); + projectNode.setId(projectId); + projectNode.setName(item.getProjectName()); + projectNode.setType("project"); // 项目节点类型 + projectNode.setChildren(new ArrayList<>()); + + projectMap.put(projectId, projectNode); + result.add(projectNode); + } + } + + // 第二遍:添加名单作为子节点 + for (ProjectMapper.ProjectTreeRaw item : rawData) { + String projectId = item.getProjectId(); + ProjectTreeVO projectNode = projectMap.get(projectId); + + if (item.getListId() != null && projectNode != null) { + ProjectTreeVO listNode = new ProjectTreeVO(); + listNode.setId(item.getListId()); + listNode.setName(item.getListName()); + listNode.setType("list"); // 名单节点类型 + listNode.setChildren(new ArrayList<>()); // 空子节点 + + projectNode.getChildren().add(listNode); + } + } + + // 3. 记录日志 + log.info("构建项目树完成,共 {} 个项目,{} 个名单", + result.size(), + result.stream().mapToInt(p -> p.getChildren().size()).sum()); + return result; + } + + @Override + public ListDetailResponse getListDetail(String listId) throws Exception { + // 生成表名 + String tableName = "list_data_" + listId.replace(".", "_"); + + // 获取列信息 + List columns = getTableColumns(tableName); + + // 获取数据 + List> records = getTableData(tableName); + + // 返回响应 + return new ListDetailResponse(columns, records); + } + + private List getTableColumns(String tableName) { + String sql = "SHOW COLUMNS FROM `" + tableName + "`"; + + try { + List> columns = jdbcTemplate.queryForList(sql); + return columns.stream() + .map(col -> (String) col.get("Field")) + .filter(field -> !"id".equals(field)) // 排除自增主键 + .collect(Collectors.toList()); + } catch (BadSqlGrammarException e) { + throw new TableNotFoundException("表不存在: " + tableName); + } + } + + private List> getTableData(String tableName) { + String sql = "SELECT * FROM `" + tableName + "`"; + + List> rows = jdbcTemplate.queryForList(sql); + + // 移除自增主键列 + return rows.stream() + .map(row -> { + Map cleaned = new LinkedHashMap<>(row); + cleaned.remove("id"); + return cleaned; + }) + .collect(Collectors.toList()); + } + + @Override + public List> getAllProjects() { + // 查询项目表,只返回 id 和 name 字段 + return projectMapper.selectMaps( + new QueryWrapper() + .select("id", "name") + .orderByAsc("id") + ); + } + + @Override + public List> getListMetaByLeader(String leaderId, String projectId) { + // 构建查询条件 + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.select("id", "project_id", "name", "description", "status") + .eq("leader_id", leaderId); + + // 添加可选的项目ID条件 + if (projectId != null && !projectId.isEmpty()) { + queryWrapper.eq("project_id", projectId); + } + + // 执行查询 + return listMetaMapper.selectMaps(queryWrapper); + } + + @Override + public DeclareResponse declareList( + String leaderId, + String projectId, + String tableName, + List columns, + List> data + ) throws Exception { + // 步骤1:生成新的 list_id + String newListId = generateNewListId(projectId); + + // 步骤2:插入 list_meta + ListMeta listMeta = new ListMeta(); + listMeta.setId(newListId); + listMeta.setProjectId(projectId); + listMeta.setName(tableName); + listMeta.setStatus("pending"); + listMeta.setLeaderId(leaderId); + listMetaMapper.insert(listMeta); + + // 步骤3:创建数据表 + String tableNameDb = "list_data_" + newListId.replace(".", "_"); + createDynamicTable(tableNameDb, columns); + + // 步骤4:批量插入数据 + if (data != null && !data.isEmpty()) { + batchInsertData(tableNameDb, columns, data); + } + + return new DeclareResponse(newListId); + } + + /** + * 生成新的名单ID + */ + private String generateNewListId(String projectId) { + // 查询当前项目下最后一个名单ID + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.select("id") + .eq("project_id", projectId) + .orderByDesc("id") + .last("LIMIT 1"); + + ListMeta lastMeta = listMetaMapper.selectOne(queryWrapper); + + int seq = 1; + if (lastMeta != null) { + // 解析最后一个ID的序号部分 + String[] parts = lastMeta.getId().split("\\."); + if (parts.length > 0) { + String lastSeqStr = parts[parts.length - 1]; + try { + seq = Integer.parseInt(lastSeqStr) + 1; + } catch (NumberFormatException e) { + log.warn("无法解析序号: {}", lastSeqStr); + } + } + } + + // 格式化序号为3位数字 + String newSeqStr = String.format("%03d", seq); + return projectId + "." + newSeqStr; + } + + /** + * 创建动态表 + */ + private void createDynamicTable(String tableName, List columns) { + // 构建列定义 + StringBuilder sql = new StringBuilder("CREATE TABLE `") + .append(tableName) + .append("` (`id` INT NOT NULL AUTO_INCREMENT"); + + for (String column : columns) { + sql.append(", `").append(column).append("` VARCHAR(255) NULL"); + } + + sql.append(", PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + // 执行DDL + jdbcTemplate.execute(sql.toString()); + log.info("创建表成功: {}", tableName); + } + + /** + * 批量插入数据 + */ + private void batchInsertData(String tableName, List columns, List> data) { + // 构建列名部分 + String columnNames = columns.stream() + .map(col -> "`" + col + "`") + .collect(Collectors.joining(", ")); + + // 构建占位符 + String placeholders = columns.stream() + .map(col -> "?") + .collect(Collectors.joining(", ")); + + // 构建SQL + String sql = "INSERT INTO `" + tableName + "` (" + columnNames + ") VALUES (" + placeholders + ")"; + + // 准备参数 + List params = new ArrayList<>(); + for (Map row : data) { + Object[] rowValues = new Object[columns.size()]; + for (int i = 0; i < columns.size(); i++) { + rowValues[i] = row.getOrDefault(columns.get(i), null); + } + params.add(rowValues); + } + + // 批量执行 + jdbcTemplate.batchUpdate(sql, params); + log.info("插入 {} 条数据到表: {}", data.size(), tableName); + } + + // 自定义异常 + public static class TableNotFoundException extends RuntimeException { + public TableNotFoundException(String message) { + super(message); + } + } +} \ No newline at end of file diff --git a/workload-server/src/main/resources/application.yml b/workload-server/src/main/resources/application.yml index 18b05b118fb8035fa8363160677aba0cfe33cce3..62acb6e06fed2dc62beb41ea85957fac87a51cbe 100644 --- a/workload-server/src/main/resources/application.yml +++ b/workload-server/src/main/resources/application.yml @@ -47,6 +47,10 @@ application: limit: 5 # 同一用户账号每分钟允许的最大登录尝试次数 window-seconds: 60 # 用户账号限流的时间窗口(秒) + frontend: + # 可在环境变量 APPLICATION_FRONTEND_URL 覆盖此值 + url: ${APPLICATION_FRONTEND_URL:https://www.ppsnav.cn/dev/SciManage/login} + mybatis-plus: mapper-locations: classpath:mapper/*.xml # Un-commented and set for RoleMenuMapper.xml type-aliases-package: cn.edu.gzgs.entity # Corrected path from .pojo to .entity