package com.rongyichuang.review.service; import com.rongyichuang.activity.entity.Activity; import com.rongyichuang.activity.repository.ActivityRepository; import com.rongyichuang.player.repository.ActivityPlayerRepository; import com.rongyichuang.review.dto.response.ReviewExportResponse; import com.rongyichuang.judge.service.CosService; import com.rongyichuang.player.entity.ActivityPlayer; import com.rongyichuang.activity.repository.ActivityPlayerRatingRepository; import com.rongyichuang.activity.repository.ActivityPlayerRatingItemRepository; import com.rongyichuang.rating.repository.RatingItemRepository; import com.rongyichuang.rating.entity.RatingItem; import com.rongyichuang.judge.repository.JudgeRepository; import com.rongyichuang.judge.entity.Judge; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.io.File; import java.io.ByteArrayOutputStream; import java.io.FileOutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.ArrayList; import java.util.Map; import java.util.HashMap; import java.util.Optional; import java.util.UUID; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import org.apache.poi.xwpf.usermodel.ParagraphAlignment; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; import org.apache.poi.xwpf.usermodel.XWPFTable; import org.apache.poi.xwpf.usermodel.XWPFTableRow; import org.apache.poi.xwpf.usermodel.XWPFTableCell; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTbl; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblWidth; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGrid; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGridCol; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblBorders; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder; import java.math.BigInteger; @Service public class ReviewExportService { private static final Logger log = LoggerFactory.getLogger(ReviewExportService.class); private final ActivityRepository activityRepository; private final ActivityPlayerRepository activityPlayerRepository; private final ActivityPlayerRatingRepository activityPlayerRatingRepository; private final ActivityPlayerRatingItemRepository activityPlayerRatingItemRepository; private final RatingItemRepository ratingItemRepository; private final JudgeRepository judgeRepository; private final CosService cosService; public ReviewExportService(ActivityRepository activityRepository, ActivityPlayerRepository activityPlayerRepository, ActivityPlayerRatingRepository activityPlayerRatingRepository, ActivityPlayerRatingItemRepository activityPlayerRatingItemRepository, RatingItemRepository ratingItemRepository, JudgeRepository judgeRepository, CosService cosService) { this.activityRepository = activityRepository; this.activityPlayerRepository = activityPlayerRepository; this.activityPlayerRatingRepository = activityPlayerRatingRepository; this.activityPlayerRatingItemRepository = activityPlayerRatingItemRepository; this.ratingItemRepository = ratingItemRepository; this.judgeRepository = judgeRepository; this.cosService = cosService; } /** * 导出评审结果ZIP(占位实现:生成简单的README汇总并上传,返回URL) */ public ReviewExportResponse exportReviewZip(Long activityId, List stageIds) { try { // 1) 校验活动 Optional activityOpt = activityRepository.findById(activityId); if (activityOpt.isEmpty()) { return new ReviewExportResponse(false, null, "活动不存在: " + activityId); } Activity activity = activityOpt.get(); // 2) 组织导出文件名 // 仅保留字母、数字、连字符,以及所有Unicode字母字符(涵盖中文等);其他替换为下划线 String safeName = activity.getName() != null ? activity.getName().replaceAll("[^\\p{L}\\p{N}-]", "_") : "activity"; String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); String fileName = String.format("review-%s-%s.zip", safeName, ts); // 3) 构建临时ZIP File zipFile = new File(System.getProperty("java.io.tmpdir"), "ryc-export-" + UUID.randomUUID() + ".zip"); try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) { // README(避免在此处关闭ZipOutputStream) ZipEntry readme = new ZipEntry("README.txt"); zos.putNextEntry(readme); StringBuilder readmeSb = new StringBuilder(); readmeSb.append("蓉易创评审导出\n"); readmeSb.append("活动: ").append(activity.getName()).append(" (ID=").append(activity.getId()).append(")\n"); readmeSb.append("导出时间: ").append(LocalDateTime.now()).append("\n"); if (stageIds != null && !stageIds.isEmpty()) { readmeSb.append("阶段ID: ").append(stageIds).append("\n"); // 简要统计每个阶段报名人数(占位统计) for (Long stageId : stageIds) { int playerCount = activityPlayerRepository.findByStageId(stageId).size(); readmeSb.append("阶段 ").append(stageId).append(" 报名人数: ").append(playerCount).append("\n"); } } else { // 主活动报名人数(不区分阶段) int playerCount = activityPlayerRepository.findByActivityId(activityId).size(); readmeSb.append("活动报名人数: ").append(playerCount).append("\n"); } byte[] readmeBytes = readmeSb.toString().getBytes(StandardCharsets.UTF_8); zos.write(readmeBytes); zos.closeEntry(); // 生成每个选手的DOCX评分表,打包到ZIP中(基础实现) if (stageIds != null && !stageIds.isEmpty()) { for (Long stageId : stageIds) { Activity stage = activityRepository.findById(stageId).orElse(null); String stageName = stage != null ? stage.getName() : ("阶段" + stageId); List players = activityPlayerRepository.findByStageIdAndStateWithPlayerOrderByCreateTimeDesc(stageId, 1); for (ActivityPlayer ap : players) { addPlayerDocToZip(zos, activity, stageName, ap); } } } else { List players = activityPlayerRepository.findByActivityIdWithPlayerAndRegion(activityId); for (ActivityPlayer ap : players) { String stageName = ap.getStageId() != null ? activityRepository.findById(ap.getStageId()).map(Activity::getName).orElse("阶段" + ap.getStageId()) : "主活动"; addPlayerDocToZip(zos, activity, stageName, ap); } } } // 4) 上传到COS String key = cosService.uploadLocalFile(zipFile, fileName); String url = cosService.getFileUrl(key); log.info("评审导出上传完成,COS Key: {}, URL: {}", key, url); // 5) 写入 t_activity.review_export_url if (stageIds != null && !stageIds.isEmpty()) { for (Long stageId : stageIds) { Activity stage = activityRepository.findById(stageId).orElse(null); if (stage != null) { stage.setReviewExportUrl(url); activityRepository.save(stage); } } } else { activity.setReviewExportUrl(url); activityRepository.save(activity); } // 删除临时文件 try { zipFile.delete(); } catch (Exception ignore) {} return new ReviewExportResponse(true, url, "导出成功"); } catch (Exception ex) { log.error("导出评审ZIP失败", ex); return new ReviewExportResponse(false, null, "导出失败: " + ex.getMessage()); } } private void addPlayerDocToZip(ZipOutputStream zos, Activity activity, String stageName, ActivityPlayer ap) throws Exception { String projectName = ap.getProjectName() != null ? ap.getProjectName() : "未命名项目"; String playerName = ap.getPlayer() != null && ap.getPlayer().getName() != null ? ap.getPlayer().getName() : ("选手" + ap.getPlayerId()); // 生成DOCX内容 XWPFDocument doc = new XWPFDocument(); // 设置页面为A4并应用合理页边距,保证打印版式 applyPageSettings(doc); // 标题 XWPFParagraph title = doc.createParagraph(); title.setAlignment(ParagraphAlignment.CENTER); XWPFRun tr = title.createRun(); tr.setText("评审评分表"); tr.setBold(true); tr.setFontSize(18); // 基本信息 XWPFParagraph p1 = doc.createParagraph(); XWPFRun r1 = p1.createRun(); r1.setText("活动:" + safeText(activity.getName()) + " 阶段:" + safeText(stageName)); XWPFParagraph p2 = doc.createParagraph(); XWPFRun r2 = p2.createRun(); r2.setText("项目:" + safeText(projectName) + " 选手:" + safeText(playerName)); // 查询所有评委评分 List ratings = activityPlayerRatingRepository.findByActivityPlayerId(ap.getId()); if (ratings == null || ratings.isEmpty()) { // 无评分时,仍然生成评分项模板表格,便于打印或线下评分 XWPFParagraph pEmpty = doc.createParagraph(); XWPFRun re = pEmpty.createRun(); re.setText("尚无评分(以下为评分项模板)"); re.setItalic(true); // 使用活动的评分方案生成空表格(设置表格宽度和列宽,避免表格挤压) XWPFTable table = doc.createTable(); applyTableLayout(table, new int[]{6072, 1500, 1500}); XWPFTableRow header = table.getRow(0); ensureCells(header, 3); setCellText(header.getCell(0), "评分项"); setCellText(header.getCell(1), "得分"); setCellText(header.getCell(2), "满分"); // 优先使用阶段的评分方案;如果选手没有阶段或阶段不存在,则回退到主活动评分方案 Long schemeIdToUse = null; if (ap.getStageId() != null) { try { Activity stageEntity = activityRepository.findById(ap.getStageId()).orElse(null); if (stageEntity != null && stageEntity.getRatingSchemeId() != null) { schemeIdToUse = stageEntity.getRatingSchemeId(); } } catch (Exception ignore) {} } if (schemeIdToUse == null) { schemeIdToUse = activity.getRatingSchemeId(); } List items = schemeIdToUse != null ? ratingItemRepository.findBySchemeIdOrderByOrderNo(schemeIdToUse) : new ArrayList<>(); for (RatingItem item : items) { XWPFTableRow row = table.createRow(); ensureCells(row, 3); setCellText(row.getCell(0), safeText(item.getName())); setCellText(row.getCell(1), "-"); setCellText(row.getCell(2), item.getMaxScore() != null ? String.valueOf(item.getMaxScore()) : "-"); } } else { for (com.rongyichuang.activity.entity.ActivityPlayerRating rating : ratings) { XWPFParagraph judgeTitle = doc.createParagraph(); XWPFRun jr = judgeTitle.createRun(); String judgeName = rating.getJudgeId() != null ? judgeRepository.findById(rating.getJudgeId()).map(Judge::getName).orElse("评委" + rating.getJudgeId()) : "未知评委"; jr.setText("评委:" + safeText(judgeName) + " 总分:" + (rating.getTotalScore() != null ? rating.getTotalScore() : "-") ); jr.setBold(true); // 构建评分项表格 XWPFTable table = doc.createTable(); applyTableLayout(table, new int[]{6072, 1500, 1500}); XWPFTableRow header = table.getRow(0); ensureCells(header, 3); setCellText(header.getCell(0), "评分项"); setCellText(header.getCell(1), "得分"); setCellText(header.getCell(2), "满分"); List items = rating.getRatingSchemeId() != null ? ratingItemRepository.findBySchemeIdOrderByOrderNo(rating.getRatingSchemeId()) : new ArrayList<>(); // 查询评分项得分 List ratingItems = activityPlayerRatingItemRepository.findByActivityPlayerRatingId(rating.getId()); Map scoreMap = new HashMap<>(); for (com.rongyichuang.activity.entity.ActivityPlayerRatingItem ri : ratingItems) { scoreMap.put(ri.getRatingItemId(), ri.getScore()); } for (RatingItem item : items) { XWPFTableRow row = table.createRow(); ensureCells(row, 3); setCellText(row.getCell(0), safeText(item.getName())); java.math.BigDecimal s = scoreMap.get(item.getId()); setCellText(row.getCell(1), s != null ? s.toPlainString() : "-"); setCellText(row.getCell(2), item.getMaxScore() != null ? String.valueOf(item.getMaxScore()) : "-"); } } } // 在文档底部添加专家评审签字区域(参考模板格式) addSignatureSection(doc); // 写入到ZIP try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { doc.write(baos); String fileBase = sanitizeFileName(stageName) + "/" + sanitizeFileName(projectName + "-" + playerName) + ".docx"; String entryName = "players/" + fileBase; ZipEntry entry = new ZipEntry(entryName); zos.putNextEntry(entry); zos.write(baos.toByteArray()); zos.closeEntry(); } doc.close(); } private String sanitizeFileName(String name) { return name == null ? "unknown" : name.replaceAll("[^\\p{L}\\p{N}-]", "_"); } private String safeText(String text) { return text == null ? "-" : text; } /** * 设置表格的总宽度和列宽,避免出现列宽过窄导致内容挤在一起 * @param table 表格 * @param colWidthsTwips 每列宽度(单位:twips),示例:{6072, 1500, 1500} */ private void applyTableLayout(XWPFTable table, int[] colWidthsTwips) { // 设置表格总宽度(页面标准宽度约 9072 twips) CTTbl ctTbl = table.getCTTbl(); CTTblPr tblPr = ctTbl.getTblPr() == null ? ctTbl.addNewTblPr() : ctTbl.getTblPr(); CTTblWidth tblW = tblPr.isSetTblW() ? tblPr.getTblW() : tblPr.addNewTblW(); tblW.setType(STTblWidth.DXA); int total = 0; for (int w : colWidthsTwips) total += w; tblW.setW(BigInteger.valueOf(total)); // 设置列网格(列宽) CTTblGrid grid = ctTbl.getTblGrid() == null ? ctTbl.addNewTblGrid() : ctTbl.getTblGrid(); // 清理已有列定义(如果有) while (grid.sizeOfGridColArray() > 0) { grid.removeGridCol(0); } for (int w : colWidthsTwips) { CTTblGridCol col = grid.addNewGridCol(); col.setW(BigInteger.valueOf(w)); } // 设置表格边框,保证打印效果清晰 CTTblBorders borders = tblPr.isSetTblBorders() ? tblPr.getTblBorders() : tblPr.addNewTblBorders(); CTBorder border = borders.isSetInsideH() ? borders.getInsideH() : borders.addNewInsideH(); border.setVal(STBorder.SINGLE); border.setSz(BigInteger.valueOf(8)); // 8八分之一点 = 1pt border.setColor("000000"); border = borders.isSetInsideV() ? borders.getInsideV() : borders.addNewInsideV(); border.setVal(STBorder.SINGLE); border.setSz(BigInteger.valueOf(8)); border.setColor("000000"); border = borders.isSetTop() ? borders.getTop() : borders.addNewTop(); border.setVal(STBorder.SINGLE); border.setSz(BigInteger.valueOf(8)); border.setColor("000000"); border = borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom(); border.setVal(STBorder.SINGLE); border.setSz(BigInteger.valueOf(8)); border.setColor("000000"); border = borders.isSetLeft() ? borders.getLeft() : borders.addNewLeft(); border.setVal(STBorder.SINGLE); border.setSz(BigInteger.valueOf(8)); border.setColor("000000"); // 修复右边框未设置粗细的问题,确保四周边框一致 border = borders.isSetRight() ? borders.getRight() : borders.addNewRight(); border.setVal(STBorder.SINGLE); border.setSz(BigInteger.valueOf(8)); border.setColor("000000"); } /** * 确保一行有指定数量的单元格 */ private void ensureCells(XWPFTableRow row, int cellCount) { int existing = row.getTableCells().size(); for (int i = existing; i < cellCount; i++) { row.addNewTableCell(); } } /** * 设置单元格文本并为该单元格设置宽度(与网格匹配) */ private void setCellText(XWPFTableCell cell, String text) { // 直接设置文本 try { // 清空默认段落,避免重复(某些情况下单元格可能没有默认段落) if (cell.getParagraphs().size() > 0) { cell.removeParagraph(0); } } catch (Exception ignore) {} XWPFParagraph p = cell.addParagraph(); p.setAlignment(ParagraphAlignment.LEFT); XWPFRun run = p.createRun(); run.setFontSize(12); // 使用中文字体,保证中文显示效果更接近模板 try { run.setFontFamily("宋体"); } catch (Exception ignore) {} run.setText(text); // 为单元格设置宽度(DXA)以防在某些Word版本中未应用表格网格宽度 CTTcPr tcPr = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); CTTblWidth w = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW(); w.setType(STTblWidth.DXA); // 宽度值由所在列决定,这里不重复设置具体数值,避免与表格网格冲突 } /** * 添加专家评审签字区域(两列:签字、日期) */ private void addSignatureSection(XWPFDocument doc) { // 留出一定的上边距 XWPFParagraph spacer = doc.createParagraph(); spacer.setSpacingBefore(200); XWPFTable signTable = doc.createTable(1, 2); applyTableLayout(signTable, new int[]{6500, 2572}); XWPFTableRow row = signTable.getRow(0); ensureCells(row, 2); setCellText(row.getCell(0), "专家评审签字:_______________"); setCellText(row.getCell(1), "签字日期:__________"); } /** * 设置页面大小与页边距(A4,默认边距约1英寸) */ private void applyPageSettings(XWPFDocument doc) { try { // A4 尺寸:宽 11907 twips(21cm),高 16840 twips(29.7cm) var body = doc.getDocument().getBody(); var sectPr = body.isSetSectPr() ? body.getSectPr() : body.addNewSectPr(); var pgSz = sectPr.isSetPgSz() ? sectPr.getPgSz() : sectPr.addNewPgSz(); pgSz.setW(BigInteger.valueOf(11907)); pgSz.setH(BigInteger.valueOf(16840)); // 页边距:约 1 英寸(1440 twips),可根据需要微调 var pgMar = sectPr.isSetPgMar() ? sectPr.getPgMar() : sectPr.addNewPgMar(); pgMar.setLeft(BigInteger.valueOf(1440)); pgMar.setRight(BigInteger.valueOf(1440)); pgMar.setTop(BigInteger.valueOf(1440)); pgMar.setBottom(BigInteger.valueOf(1440)); } catch (Exception ignore) { // 兼容性考虑:不影响文档生成 } } }