|
@@ -0,0 +1,371 @@
|
|
|
+package org.dromara.system.service.impl;
|
|
|
+
|
|
|
+import cn.hutool.json.JSONUtil;
|
|
|
+import cn.idev.excel.annotation.ExcelProperty;
|
|
|
+import jakarta.servlet.http.HttpServletResponse;
|
|
|
+import lombok.RequiredArgsConstructor;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import lombok.val;
|
|
|
+import org.dromara.system.domain.bo.GameTeamBo;
|
|
|
+import org.dromara.system.domain.vo.EnrollProjectVo;
|
|
|
+import org.dromara.system.service.IEnrollService;
|
|
|
+import org.dromara.system.service.IGameEventProjectService;
|
|
|
+import org.dromara.system.service.IGameTeamService;
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+
|
|
|
+import java.io.*;
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.net.URLEncoder;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.*;
|
|
|
+import java.util.concurrent.ConcurrentHashMap;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@RequiredArgsConstructor
|
|
|
+@Service
|
|
|
+public class IEnrollServiceImpl implements IEnrollService {
|
|
|
+
|
|
|
+ private final IGameEventProjectService gameEventProjectService;
|
|
|
+ private final IGameTeamService gameTeamService;
|
|
|
+ private final ConcurrentHashMap<Long, Class> classMap = new ConcurrentHashMap<>();
|
|
|
+ private final ConcurrentHashMap<Long, List<String>> projectsMap = new ConcurrentHashMap<>();
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 下载报名表模板
|
|
|
+ *
|
|
|
+ * @param response
|
|
|
+ * @param eventId
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public void downloadTemplate(HttpServletResponse response, Long eventId) {
|
|
|
+ // 1. 获取默认赛事动态项目(赛事项目)
|
|
|
+ Map<String, List<String>> projectMap = gameEventProjectService.mapProjectTypeAndProject(eventId);
|
|
|
+ List<String> projects = new ArrayList<>();
|
|
|
+ for (List<String> list : projectMap.values()) {
|
|
|
+ projects.addAll(list);
|
|
|
+ }
|
|
|
+ projectsMap.put(eventId, projects);
|
|
|
+
|
|
|
+ // 2. 构建表头行(CSV 用逗号分隔)
|
|
|
+ List<String> headers = new ArrayList<>();
|
|
|
+ // 反射获取属性的注解value值
|
|
|
+ Class<EnrollProjectVo> enrollClass = EnrollProjectVo.class;
|
|
|
+ Field[] declaredFields = enrollClass.getDeclaredFields();
|
|
|
+ for (Field field : declaredFields) {
|
|
|
+ field.setAccessible(true);
|
|
|
+ // 检查字段是否含有 ExcelProperty 注解
|
|
|
+ if (field.isAnnotationPresent(ExcelProperty.class)) {
|
|
|
+ ExcelProperty annotation = field.getAnnotation(ExcelProperty.class);
|
|
|
+ // 获取注解的 value 值
|
|
|
+ String[] value = annotation.value();
|
|
|
+ log.error("注解值:{}", Arrays.toString(value));
|
|
|
+ headers.add(value[0]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ classMap.put(eventId, enrollClass);
|
|
|
+ headers.addAll(projects);
|
|
|
+
|
|
|
+ // 3. 使用 UTF-8 编码的 CSV,兼容 Excel 打开
|
|
|
+ String fileName = URLEncoder.encode("报名导入模板", StandardCharsets.UTF_8) + ".csv";
|
|
|
+
|
|
|
+ response.setContentType("text/csv; charset=UTF-8");
|
|
|
+ response.setCharacterEncoding("UTF-8");
|
|
|
+ response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + fileName);
|
|
|
+
|
|
|
+ // 4. 使用原始 IO 流输出 CSV
|
|
|
+ try (OutputStream os = response.getOutputStream();
|
|
|
+ OutputStreamWriter writer = new OutputStreamWriter(os, StandardCharsets.UTF_8)) {
|
|
|
+
|
|
|
+ // 写入 BOM(可选):让 Excel 正确识别 UTF-8
|
|
|
+ writer.write('\uFEFF'); // UTF-8 BOM
|
|
|
+
|
|
|
+ // 写入表头(用逗号连接)
|
|
|
+ writer.write(String.join(",", headers));
|
|
|
+ writer.write("\n"); // 换行
|
|
|
+
|
|
|
+ // 可选:写入一行空数据作为示例
|
|
|
+ String emptyRow = String.join(",", Collections.nCopies(headers.size(), ""));
|
|
|
+ writer.write(emptyRow);
|
|
|
+ writer.write("\n");
|
|
|
+
|
|
|
+ // 强制刷新
|
|
|
+ writer.flush();
|
|
|
+ } catch (IOException e) {
|
|
|
+ e.printStackTrace();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 导入报名表
|
|
|
+ *
|
|
|
+ * @param file
|
|
|
+ * @param eventId
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public ResponseEntity<Map<String, Object>> importData(MultipartFile file, Long eventId) {
|
|
|
+
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+ List<String> errors = new ArrayList<>();
|
|
|
+
|
|
|
+ if (file.isEmpty()) {
|
|
|
+ errors.add("上传文件不能为空");
|
|
|
+ result.put("success", false);
|
|
|
+ result.put("errors", errors);
|
|
|
+ return ResponseEntity.badRequest().body(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ try (InputStream inputStream = file.getInputStream();
|
|
|
+ BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
|
|
+
|
|
|
+ // 1. 读取第一行:表头
|
|
|
+ String headerLine = reader.readLine();
|
|
|
+ if (headerLine == null) {
|
|
|
+ errors.add("文件为空");
|
|
|
+ result.put("success", false);
|
|
|
+ result.put("errors", errors);
|
|
|
+ return ResponseEntity.badRequest().body(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 去除 BOM(如果存在)
|
|
|
+ if (headerLine.startsWith("\uFEFF")) {
|
|
|
+ headerLine = headerLine.substring(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ String[] headers = headerLine.split(",", -1); // -1 保留空字段
|
|
|
+
|
|
|
+ // 添加调试日志,输出实际读取到的表头
|
|
|
+ log.info("实际读取到的表头: {}", Arrays.toString(headers));
|
|
|
+ log.info("表头行原始内容: '{}'", headerLine);
|
|
|
+
|
|
|
+ // 2. 获取动态项目列(从数据库或服务中)
|
|
|
+ Map<String, List<String>> projectMap = gameEventProjectService.mapProjectTypeAndProject(eventId);
|
|
|
+ List<String> validProjects = new ArrayList<>();
|
|
|
+ for (List<String> list : projectMap.values()) {
|
|
|
+ validProjects.addAll(list);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 验证表头是否合法
|
|
|
+ Set<String> expectedHeaders = new LinkedHashSet<>();
|
|
|
+ // 添加固定字段(来自 EnrollProjectVo)
|
|
|
+ Field[] fields = EnrollProjectVo.class.getDeclaredFields();
|
|
|
+ for (Field field : fields) {
|
|
|
+ if (field.isAnnotationPresent(ExcelProperty.class)) {
|
|
|
+ ExcelProperty prop = field.getAnnotation(ExcelProperty.class);
|
|
|
+ expectedHeaders.add(prop.value()[0]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ expectedHeaders.addAll(validProjects); // 加上动态项目
|
|
|
+
|
|
|
+ Set<String> actualHeaders = new LinkedHashSet<>(Arrays.asList(headers));
|
|
|
+
|
|
|
+ // 改进表头验证逻辑
|
|
|
+ if (!actualHeaders.equals(expectedHeaders)) {
|
|
|
+ log.error("表头不匹配,期望: {}, 实际: {}", expectedHeaders, actualHeaders);
|
|
|
+
|
|
|
+ // 检查是否完全不匹配(可能文件格式错误)
|
|
|
+ boolean hasAnyMatch = false;
|
|
|
+ for (String actualHeader : actualHeaders) {
|
|
|
+ if (expectedHeaders.contains(actualHeader)) {
|
|
|
+ hasAnyMatch = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!hasAnyMatch) {
|
|
|
+ errors.add("上传的文件格式不正确,请使用系统生成的导入模板");
|
|
|
+ errors.add("期望的表头: " + String.join(", ", expectedHeaders));
|
|
|
+ errors.add("实际的表头: " + String.join(", ", actualHeaders));
|
|
|
+ result.put("success", false);
|
|
|
+ result.put("errors", errors);
|
|
|
+ return ResponseEntity.badRequest().body(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果部分匹配,给出详细的差异信息
|
|
|
+ Set<String> missingHeaders = new LinkedHashSet<>(expectedHeaders);
|
|
|
+ missingHeaders.removeAll(actualHeaders);
|
|
|
+ Set<String> extraHeaders = new LinkedHashSet<>(actualHeaders);
|
|
|
+ extraHeaders.removeAll(expectedHeaders);
|
|
|
+
|
|
|
+ if (!missingHeaders.isEmpty()) {
|
|
|
+ errors.add("缺少必要的表头字段: " + String.join(", ", missingHeaders));
|
|
|
+ }
|
|
|
+ if (!extraHeaders.isEmpty()) {
|
|
|
+ log.warn("包含额外的表头字段: {}", String.join(", ", extraHeaders));
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!errors.isEmpty()) {
|
|
|
+ result.put("success", false);
|
|
|
+ result.put("errors", errors);
|
|
|
+ return ResponseEntity.badRequest().body(result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 逐行读取数据
|
|
|
+ String line;
|
|
|
+ int lineNumber = 2; // 数据从第2行开始(第1行是表头)
|
|
|
+ List<EnrollProjectVo> dataList = new ArrayList<>();
|
|
|
+
|
|
|
+ while ((line = reader.readLine()) != null) {
|
|
|
+ line = line.trim();
|
|
|
+ if (line.isEmpty()) continue;
|
|
|
+
|
|
|
+ String[] values = line.split(",", -1); // 保持空值
|
|
|
+ if (values.length != headers.length) {
|
|
|
+ errors.add("第" + lineNumber + "行数据列数不匹配,期望" + headers.length + "列,实际" + values.length + "列");
|
|
|
+ lineNumber++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 映射到 EnrollProjectVo
|
|
|
+ EnrollProjectVo vo = mapRowToVo(headers, values, validProjects);
|
|
|
+ if (vo != null) {
|
|
|
+ dataList.add(vo);
|
|
|
+ } else {
|
|
|
+ errors.add("第" + lineNumber + "行数据格式错误");
|
|
|
+ }
|
|
|
+
|
|
|
+ lineNumber++;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5. 数据校验 & 保存
|
|
|
+ if (!errors.isEmpty()) {
|
|
|
+ result.put("success", false);
|
|
|
+ result.put("errors", errors);
|
|
|
+ return ResponseEntity.badRequest().body(result);
|
|
|
+ }
|
|
|
+ log.info("读取到的数据:{}", dataList);
|
|
|
+ // 调用 Service 层保存数据
|
|
|
+ boolean saveSuccess = this.saveEnrollData(dataList, eventId);
|
|
|
+ if (!saveSuccess) {
|
|
|
+ result.put("success", false);
|
|
|
+ result.put("errors", Arrays.asList("保存数据失败,请重试"));
|
|
|
+ return ResponseEntity.status(500).body(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info("成功读取的数据条数: {}", dataList.size());
|
|
|
+
|
|
|
+ result.put("success", true);
|
|
|
+ result.put("message", "导入成功,共导入 " + dataList.size() + " 条记录");
|
|
|
+ result.put("data", dataList);
|
|
|
+ return ResponseEntity.ok(result);
|
|
|
+
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("导入数据时发生异常", e);
|
|
|
+ errors.add("文件解析失败: " + e.getMessage());
|
|
|
+ result.put("success", false);
|
|
|
+ result.put("errors", errors);
|
|
|
+ return ResponseEntity.status(500).body(result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 保存数据
|
|
|
+ *
|
|
|
+ * @param dataList
|
|
|
+ * @param eventId
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
+ public boolean saveEnrollData(List<EnrollProjectVo> dataList, Long eventId) {
|
|
|
+ // - 读取到的数据:[EnrollProjectVo(name=林啊啊啊, teamName=废物队,
|
|
|
+ // sex=男, age=20, leader=少爷, phone=34567890,
|
|
|
+ // ProjectSelections={4*400米接力=false, 4*100米接力=true, 跳高=true})]
|
|
|
+ // 1.根据队伍生成号码段 五个一组
|
|
|
+ // 1.2 根据队伍分类成Map
|
|
|
+ Map<String, List<EnrollProjectVo>> groupedByTeam = dataList.stream()
|
|
|
+ .collect(Collectors.groupingBy(EnrollProjectVo::getTeamName));
|
|
|
+ // 1.3 根据队伍生成号码段(生成一个map key:teamName value:队员数量)
|
|
|
+ Map<String, Long> teamCountMap = dataList.stream()
|
|
|
+ .collect(Collectors.groupingBy(
|
|
|
+ EnrollProjectVo::getTeamName,
|
|
|
+ Collectors.counting()
|
|
|
+ ));
|
|
|
+ // 2.校验是否存在项目(如果项目不存在就无视)
|
|
|
+ List<String> projects = projectsMap.get(eventId);
|
|
|
+ // 3.保存参赛队员
|
|
|
+ // 4.保存参赛队伍
|
|
|
+ for (Map.Entry<String, List<EnrollProjectVo>> entry : groupedByTeam.entrySet()) {
|
|
|
+ String key = entry.getKey();
|
|
|
+ List<EnrollProjectVo> value = entry.getValue();
|
|
|
+ GameTeamBo gameTeamBo = new GameTeamBo();
|
|
|
+ gameTeamBo.setEventId(eventId);
|
|
|
+ gameTeamBo.setTeamName(key);
|
|
|
+ // gameTeamBo.setTeamCode();
|
|
|
+ gameTeamBo.setLeader(value.get(0).getLeader());
|
|
|
+ gameTeamBo.setAthleteValue(JSONUtil.toJsonStr(value.stream().map(EnrollProjectVo::getName).collect(Collectors.toList())));
|
|
|
+ gameTeamBo.setAthleteNum(Long.valueOf(value.size()));
|
|
|
+ // gameTeamBo.setNumberRange();
|
|
|
+ gameTeamBo.setStatus("0");
|
|
|
+ gameTeamService.insertByBo(gameTeamBo);
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 映射
|
|
|
+ *
|
|
|
+ * @param headers
|
|
|
+ * @param values
|
|
|
+ * @param validProjects
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ private EnrollProjectVo mapRowToVo(String[] headers, String[] values, List<String> validProjects) {
|
|
|
+ EnrollProjectVo vo = new EnrollProjectVo();
|
|
|
+
|
|
|
+ try {
|
|
|
+ for (int i = 0; i < headers.length; i++) {
|
|
|
+ String header = headers[i];
|
|
|
+ String value = i < values.length ? values[i].trim() : "";
|
|
|
+
|
|
|
+ // 判断是否是固定字段
|
|
|
+ boolean matched = false;
|
|
|
+ Field[] fields = EnrollProjectVo.class.getDeclaredFields();
|
|
|
+ for (Field field : fields) {
|
|
|
+ if (field.isAnnotationPresent(ExcelProperty.class)) {
|
|
|
+ ExcelProperty prop = field.getAnnotation(ExcelProperty.class);
|
|
|
+ if (prop.value()[0].equals(header)) {
|
|
|
+ field.setAccessible(true);
|
|
|
+ // 简单类型转换(可扩展)
|
|
|
+ if (field.getType() == String.class) {
|
|
|
+ field.set(vo, value.isEmpty() ? null : value);
|
|
|
+ } else if (field.getType() == Integer.class || field.getType() == int.class) {
|
|
|
+ field.set(vo, value.isEmpty() ? null : Integer.valueOf(value));
|
|
|
+ } else if (field.getType() == Long.class || field.getType() == long.class) {
|
|
|
+ field.set(vo, value.isEmpty() ? null : Long.valueOf(value));
|
|
|
+ }
|
|
|
+ matched = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果不是固定字段,则视为“项目报名”字段
|
|
|
+ if (!matched && validProjects.contains(header)) {
|
|
|
+ // 假设 value 是“是/否”、“1/0”、“√”等
|
|
|
+ boolean enrolled = isTrueValue(value);
|
|
|
+ // 使用 map 或其他结构存储项目报名状态
|
|
|
+ if (vo.getProjectSelections() == null) {
|
|
|
+ vo.setProjectSelections(new HashMap<>());
|
|
|
+ }
|
|
|
+ vo.getProjectSelections().put(header, enrolled);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return vo;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("映射行数据失败", e);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ // 判断是否为“真值”
|
|
|
+ private boolean isTrueValue(String val) {
|
|
|
+ return Arrays.asList("是", "√", "1", "true", "报名", "已报").contains(val);
|
|
|
+ }
|
|
|
+}
|