peng
2025-11-06 c4938f6f4e839890b032c75c7a57333a6a9157a9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
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<Long> stageIds) {
        try {
            // 1) 校验活动
            Optional<Activity> 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<ActivityPlayer> players = activityPlayerRepository.findByStageIdAndStateWithPlayerOrderByCreateTimeDesc(stageId, 1);
                        for (ActivityPlayer ap : players) {
                            addPlayerDocToZip(zos, activity, stageName, ap);
                        }
                    }
                } else {
                    List<ActivityPlayer> 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<com.rongyichuang.activity.entity.ActivityPlayerRating> 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<RatingItem> 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<RatingItem> items = rating.getRatingSchemeId() != null ? ratingItemRepository.findBySchemeIdOrderByOrderNo(rating.getRatingSchemeId()) : new ArrayList<>();
                // 查询评分项得分
                List<com.rongyichuang.activity.entity.ActivityPlayerRatingItem> ratingItems = activityPlayerRatingItemRepository.findByActivityPlayerRatingId(rating.getId());
                Map<Long, java.math.BigDecimal> 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) {
            // 兼容性考虑:不影响文档生成
        }
    }
}