|
@@ -1,6 +1,5 @@
|
|
|
package org.dromara.system.service.impl;
|
|
|
|
|
|
-import cn.hutool.core.collection.CollectionUtil;
|
|
|
import cn.hutool.core.img.FontUtil;
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
@@ -77,6 +76,10 @@ public class GameEventServiceImpl implements IGameEventService {
|
|
|
private IGameAthleteService gameAthleteService;
|
|
|
@Resource
|
|
|
private IGameEventProjectService gameEventProjectService;
|
|
|
+ @Resource
|
|
|
+ private IGameEventGroupService gameEventGroupService;
|
|
|
+ private static final ExecutorService PDF_GENERATION_EXECUTOR =
|
|
|
+ Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2);
|
|
|
|
|
|
/**
|
|
|
* 查询赛事基本信息
|
|
@@ -590,9 +593,11 @@ public class GameEventServiceImpl implements IGameEventService {
|
|
|
//3.查询赛事所有项目缓存
|
|
|
Map<Long, String> projectMap = gameEventProjectService.queryListByEventId(defaultEventId)
|
|
|
.stream().collect(Collectors.toMap(GameEventProjectVo::getProjectId, GameEventProjectVo::getProjectName));
|
|
|
- //4.根据参数生成号码布
|
|
|
+ //4.查询赛事组别
|
|
|
+ GameEventGroup gameEventGroup = gameEventGroupService.queryByEventId(defaultEventId);
|
|
|
+ //5.根据参数生成号码布
|
|
|
GameEventVo eventVo = baseMapper.selectVoById(defaultEventId);
|
|
|
- generateBib(response, bgImage, logo, eventVo.getEventName(), athleteVoList, teamNameMap, projectMap, bibParam);
|
|
|
+ generateBib(response, bgImage, logo, eventVo.getEventName(), gameEventGroup.getGroupName(), athleteVoList, teamNameMap, projectMap, bibParam);
|
|
|
}
|
|
|
|
|
|
/**
|
|
@@ -602,6 +607,7 @@ public class GameEventServiceImpl implements IGameEventService {
|
|
|
* @param backgroundImage 背景图 (MultipartFile)
|
|
|
* @param logo Logo 图片(可选)
|
|
|
* @param eventName 赛事名称
|
|
|
+ * @param groupName 组别名称
|
|
|
* @param athleteList 运动员列表
|
|
|
* @param teamNameMap 队伍id名称映射
|
|
|
* @param projectMap 项目id名称映射
|
|
@@ -612,12 +618,31 @@ public class GameEventServiceImpl implements IGameEventService {
|
|
|
MultipartFile backgroundImage,
|
|
|
MultipartFile logo,
|
|
|
String eventName,
|
|
|
+ String groupName,
|
|
|
List<GameAthleteVo> athleteList,
|
|
|
Map<Long, String> teamNameMap,
|
|
|
Map<Long, String> projectMap,
|
|
|
GenerateBibBo bibParam) {
|
|
|
+
|
|
|
+ byte[] backgroundImageBytes;
|
|
|
+ byte[] logoImageBytes = null;
|
|
|
+
|
|
|
+ //1.读取图片文件
|
|
|
+ try {
|
|
|
+ if (backgroundImage == null || backgroundImage.isEmpty()) {
|
|
|
+ throw new IllegalArgumentException("背景图不能为空");
|
|
|
+ }
|
|
|
+ backgroundImageBytes = backgroundImage.getBytes();
|
|
|
+
|
|
|
+ if (logo != null && !logo.isEmpty()) {
|
|
|
+ logoImageBytes = logo.getBytes();
|
|
|
+ }
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new RuntimeException("读取上传文件失败", e);
|
|
|
+ }
|
|
|
+
|
|
|
try {
|
|
|
- // 提取布局参数
|
|
|
+ // 2. 提取参数
|
|
|
Double logoX = bibParam.getLogoX();
|
|
|
Double logoY = bibParam.getLogoY();
|
|
|
Double qRCodeX = bibParam.getQRCodeX();
|
|
@@ -626,151 +651,166 @@ public class GameEventServiceImpl implements IGameEventService {
|
|
|
Integer fontSize = bibParam.getFontSize();
|
|
|
Integer fontColor = bibParam.getFontColor();
|
|
|
|
|
|
- // 设置默认值
|
|
|
+ // 2.1 设置默认值及获取字体及颜色
|
|
|
if (fontSize == null) fontSize = 14;
|
|
|
BaseColor textColor = parseColor(fontColor);
|
|
|
BaseFont baseFont = getChineseFont(fontName);
|
|
|
|
|
|
- // 读取背景图
|
|
|
- Image bgImage = Image.getInstance(backgroundImage.getBytes());
|
|
|
+ // 3. 读取背景图和 logo
|
|
|
+ Image bgImage = Image.getInstance(backgroundImageBytes);
|
|
|
float pageWidth = bgImage.getWidth();
|
|
|
float pageHeight = bgImage.getHeight();
|
|
|
|
|
|
- // 提前读取 logo 字节数组
|
|
|
- byte[] logoBytes = null;
|
|
|
- if (logo != null && !logo.isEmpty()) {
|
|
|
- logoBytes = logo.getBytes();
|
|
|
+ // 3.1 验证背景图片比例是否为3:2横屏
|
|
|
+ float ratio = pageWidth / pageHeight;
|
|
|
+ if (Math.abs(ratio - 1.5f) > 0.1f) { // 允许0.1的误差
|
|
|
+ throw new IllegalArgumentException("背景图片比例不是3:2横屏比例,当前比例: " + String.format("%.2f", ratio));
|
|
|
}
|
|
|
|
|
|
- try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(response.getOutputStream())) {
|
|
|
-
|
|
|
- for (GameAthleteVo athlete : athleteList) {
|
|
|
- // 每个运动员生成一个 PDF
|
|
|
- ByteArrayOutputStream pdfStream = new ByteArrayOutputStream();
|
|
|
- Document document = new Document(new Rectangle(pageWidth, pageHeight));
|
|
|
- PdfWriter writer = PdfWriter.getInstance(document, pdfStream);
|
|
|
- document.open();
|
|
|
-
|
|
|
- PdfContentByte cb = writer.getDirectContent();
|
|
|
-
|
|
|
- // 添加背景图
|
|
|
- bgImage.setAbsolutePosition(0, 0);
|
|
|
- document.add(bgImage);
|
|
|
-
|
|
|
- // 添加Logo - 更精确的位置调整
|
|
|
- if (logoBytes != null) {
|
|
|
- try {
|
|
|
- Image img = Image.getInstance(logoBytes);
|
|
|
+ Integer finalFontSize = fontSize;
|
|
|
+ // 4. 并行生成所有 PDF
|
|
|
+ byte[] finalLogoImageBytes = logoImageBytes;
|
|
|
+ List<CompletableFuture<PdfEntry>> pdfFutures = athleteList.parallelStream()
|
|
|
+ .map(athlete -> CompletableFuture.supplyAsync(() -> {
|
|
|
+ try {
|
|
|
+ ByteArrayOutputStream pdfStream = new ByteArrayOutputStream();
|
|
|
+ Document document = new Document(new Rectangle(pageWidth, pageHeight));
|
|
|
+ PdfWriter writer = PdfWriter.getInstance(document, pdfStream);
|
|
|
+ document.open();
|
|
|
+ PdfContentByte cb = writer.getDirectContent();
|
|
|
+
|
|
|
+ // 添加背景图
|
|
|
+ Image bg = Image.getInstance(backgroundImageBytes);
|
|
|
+ bg.setAbsolutePosition(0, 0);
|
|
|
+ document.add(bg);
|
|
|
+
|
|
|
+ // 添加 Logo(如果存在)
|
|
|
+ if (finalLogoImageBytes != null && logoX != null && logoY != null) {
|
|
|
+ Image img = Image.getInstance(finalLogoImageBytes);
|
|
|
img.scaleToFit(80, 80);
|
|
|
-
|
|
|
- // 直接使用前端传递的坐标值,更精确的位置调整
|
|
|
- float logoPositionX = logoX != null ? logoX.floatValue() : 50f;
|
|
|
- float logoPositionY = logoY != null ? logoY.floatValue() : pageHeight - 130f;
|
|
|
-
|
|
|
- // 更精确的位置调整
|
|
|
- logoPositionX += 10; // 向右偏移10pt
|
|
|
- logoPositionY -= 50; // 向下偏移50pt(与编号重合)
|
|
|
-
|
|
|
+ float logoPositionX = logoX.floatValue();
|
|
|
+ float logoPositionY = logoY.floatValue();
|
|
|
img.setAbsolutePosition(logoPositionX, logoPositionY);
|
|
|
cb.addImage(img);
|
|
|
- } catch (Exception e) {
|
|
|
- // 忽略或记录日志
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- // 计算文本宽度和高度
|
|
|
- float textWidth = baseFont.getWidthPoint(athlete.getAthleteCode(), fontSize);
|
|
|
- float textHeight = fontSize;
|
|
|
-
|
|
|
- // 确保文本垂直和水平都居中
|
|
|
- float textPositionX = (pageWidth - textWidth) / 2; // 水平居中
|
|
|
- float textPositionY = (pageHeight / 2) + (textHeight / 2); // 垂直居中
|
|
|
-
|
|
|
- addText(cb, baseFont, fontSize, textColor, textPositionX, textPositionY, athlete.getAthleteCode());
|
|
|
-
|
|
|
- // 添加赛事名称
|
|
|
- if (eventName != null && !eventName.trim().isEmpty()) {
|
|
|
+ // 添加号码(居中)
|
|
|
+ float textWidth = baseFont.getWidthPoint(athlete.getAthleteCode(), finalFontSize);
|
|
|
+ float textHeight = finalFontSize;
|
|
|
+ float textPositionX = (pageWidth - textWidth) / 2;
|
|
|
+ float textPositionY = (pageHeight / 2) + (textHeight / 2);
|
|
|
+ addText(cb, baseFont, finalFontSize, textColor, textPositionX, textPositionY, athlete.getAthleteCode());
|
|
|
+
|
|
|
+ // 添加赛事名称(在3:2横屏比例下调整位置)
|
|
|
+ if (eventName != null && !eventName.trim().isEmpty()) {
|
|
|
+ int eventNameFontSize = Math.min(64, (int) (pageHeight * 0.08)); // 根据页面高度调整字体大小
|
|
|
+ cb.beginText();
|
|
|
+ cb.setFontAndSize(baseFont, eventNameFontSize);
|
|
|
+ cb.setColorFill(BaseColor.BLACK);
|
|
|
+ float textWidth2 = baseFont.getWidthPoint(eventName, eventNameFontSize);
|
|
|
+ float textX = (pageWidth - textWidth2) / 2;
|
|
|
+ float textY = pageHeight - eventNameFontSize - 20; // 增加边距
|
|
|
+ cb.setTextMatrix(textX, textY);
|
|
|
+ cb.showText(eventName);
|
|
|
+ cb.endText();
|
|
|
+ }
|
|
|
|
|
|
- // 设置字体大小和颜色
|
|
|
- int eventNameFontSize = 64; // 确保与绘制文字时的字体大小一致
|
|
|
- cb.beginText();
|
|
|
- cb.setFontAndSize(baseFont, eventNameFontSize);
|
|
|
- cb.setColorFill(BaseColor.BLACK);
|
|
|
+ // 生成二维码(在3:2横屏比例下调整尺寸)
|
|
|
+ if (qRCodeX != null && qRCodeY != null) {
|
|
|
+ String qrDataStr = getQrDataStr(eventName, groupName, teamNameMap, projectMap, athlete);
|
|
|
+ int qrSize = Math.min(150, (int) (Math.min(pageWidth, pageHeight) * 0.15)); // 根据页面尺寸调整二维码大小
|
|
|
+ byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
|
|
|
+ Image qrImage = Image.getInstance(qrBytes);
|
|
|
+ float qrX = qRCodeX.floatValue();
|
|
|
+ float qrY = qRCodeY.floatValue();
|
|
|
+ qrImage.setAbsolutePosition(qrX, qrY);
|
|
|
+ cb.addImage(qrImage);
|
|
|
+ }
|
|
|
|
|
|
- // 计算文本宽度和X坐标以实现水平居中
|
|
|
- textWidth = baseFont.getWidthPoint(eventName, eventNameFontSize);
|
|
|
- float textX = (pageWidth - textWidth) / 2;
|
|
|
+ document.close();
|
|
|
+ byte[] pdfBytes = pdfStream.toByteArray();
|
|
|
|
|
|
- // 设置文本垂直位置使其位于页面顶部,留出一定间距
|
|
|
- float textY = pageHeight - eventNameFontSize - 10; // 调整这个值以改变顶部间距
|
|
|
+ // 构造安全的文件名(避免特殊字符)
|
|
|
+ String safeName = athlete.getName().replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5]", "_");
|
|
|
+ String fileName = String.format("%s_%s.pdf", athlete.getAthleteCode(), safeName);
|
|
|
+ return new PdfEntry(fileName, pdfBytes);
|
|
|
|
|
|
- cb.setTextMatrix(textX, textY);
|
|
|
- cb.showText(eventName);
|
|
|
- cb.endText();
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 建议记录日志
|
|
|
+ System.err.println("生成 PDF 失败: " + athlete.getName() + " - " + e.getMessage());
|
|
|
+ throw new RuntimeException("生成 PDF 失败: " + athlete.getName(), e);
|
|
|
}
|
|
|
- StringBuilder joinProject = new StringBuilder();
|
|
|
- StringJoiner joiner = new StringJoiner("、"); // 指定分隔符
|
|
|
- athlete.getProjectList().forEach(projectId -> {
|
|
|
- String projectName = projectMap.get(Long.valueOf(projectId));
|
|
|
- if (projectName != null) {
|
|
|
- joiner.add(projectName);
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- // 生成二维码 - 更精确的位置调整
|
|
|
- String qrData = String.format(
|
|
|
- """
|
|
|
- 赛事名称:%s,
|
|
|
- 运动员序号:%d,
|
|
|
- 运动员编号:%s,
|
|
|
- 参与项目:%s,
|
|
|
- 队伍id:%d,
|
|
|
- 队伍名称:%s,
|
|
|
- 运动员姓名:%s,
|
|
|
- 性别:%s,
|
|
|
- 年龄:%d
|
|
|
- """,
|
|
|
- eventName, athlete.getAthleteId(), athlete.getAthleteCode(),
|
|
|
- joinProject.toString(),
|
|
|
- athlete.getTeamId(), teamNameMap.get(athlete.getTeamId()), athlete.getName(), athlete.getGender(), athlete.getAge()
|
|
|
- );
|
|
|
-
|
|
|
- byte[] qrBytes = generateQRCode(qrData, 150, 150);
|
|
|
- Image qrImage = Image.getInstance(qrBytes);
|
|
|
-
|
|
|
- // 直接使用前端传递的坐标值,更精确的位置调整
|
|
|
- float qrX = qRCodeX != null ? qRCodeX.floatValue() : pageWidth - 170f;
|
|
|
- float qrY = qRCodeY != null ? qRCodeY.floatValue() : 50f;
|
|
|
-
|
|
|
- // 更精确的位置调整
|
|
|
- qrX += 15; // 向右偏移15pt
|
|
|
- qrY -= 55; // 向下偏移55pt(与编号重合)
|
|
|
-
|
|
|
- qrImage.setAbsolutePosition(qrX, qrY);
|
|
|
- cb.addImage(qrImage);
|
|
|
-
|
|
|
- document.close();
|
|
|
-
|
|
|
- // 写入 ZIP:文件名为 bib_号码_姓名.pdf
|
|
|
- String fileName = String.format("bib_%s_%s.pdf", athlete.getAthleteCode(), athlete.getName());
|
|
|
- zos.putNextEntry(new java.util.zip.ZipEntry(fileName));
|
|
|
- zos.write(pdfStream.toByteArray());
|
|
|
+ }, PDF_GENERATION_EXECUTOR))
|
|
|
+ .toList();
|
|
|
+
|
|
|
+ // 等待所有任务完成
|
|
|
+ List<PdfEntry> pdfEntries = pdfFutures.stream()
|
|
|
+ .map(CompletableFuture::join)
|
|
|
+ .toList();
|
|
|
+
|
|
|
+ // 4. 设置响应头,开始写 ZIP
|
|
|
+ response.setContentType("application/zip");
|
|
|
+ response.setHeader("Content-Disposition", "attachment; filename=\"athlete_bibs.zip\"");
|
|
|
+
|
|
|
+ // 5. 写入 ZIP 文件
|
|
|
+ try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) {
|
|
|
+ for (PdfEntry entry : pdfEntries) {
|
|
|
+ zos.putNextEntry(new ZipEntry(entry.getFileName()));
|
|
|
+ zos.write(entry.getPdfBytes());
|
|
|
zos.closeEntry();
|
|
|
}
|
|
|
-
|
|
|
- // 强制刷新输出流
|
|
|
zos.flush();
|
|
|
- response.flushBuffer();
|
|
|
-
|
|
|
- } catch (Exception e) {
|
|
|
- throw new RuntimeException("生成号码布 ZIP 失败", e);
|
|
|
}
|
|
|
+
|
|
|
} catch (Exception e) {
|
|
|
- throw new RuntimeException(e);
|
|
|
- } finally {
|
|
|
+ throw new RuntimeException("批量生成号码布失败", e);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 生成二维码数据
|
|
|
+ *
|
|
|
+ * @param eventName
|
|
|
+ * @param groupName
|
|
|
+ * @param teamNameMap
|
|
|
+ * @param projectMap
|
|
|
+ * @param athlete
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ @NotNull
|
|
|
+ private static String getQrDataStr(String eventName, String groupName, Map<Long, String> teamNameMap, Map<Long, String> projectMap, GameAthleteVo athlete) {
|
|
|
+ //处理参加项目
|
|
|
+ StringBuilder joinProject = new StringBuilder();
|
|
|
+ StringJoiner joiner = new StringJoiner("、"); // 指定分隔符
|
|
|
+ athlete.getProjectList().forEach(projectId -> {
|
|
|
+ String projectName = projectMap.get(Long.valueOf(projectId));
|
|
|
+ if (projectName != null) {
|
|
|
+ joiner.add(projectName);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 生成二维码
|
|
|
+ String qrData = String.format(
|
|
|
+ """
|
|
|
+ {
|
|
|
+ 赛事名称:%s,
|
|
|
+ 运动员序号:%d,
|
|
|
+ 运动员编号:%s,
|
|
|
+ 参与项目:%s,
|
|
|
+ 队伍名称:%s,
|
|
|
+ 运动员姓名:%s,
|
|
|
+ 性别:%s,
|
|
|
+ 年龄:%d
|
|
|
+ 组别:%s
|
|
|
+ }
|
|
|
+ """,
|
|
|
+ eventName, athlete.getAthleteId(), athlete.getAthleteCode(),
|
|
|
+ joinProject, teamNameMap.get(athlete.getTeamId()), athlete.getName(),
|
|
|
+ athlete.getGender(), athlete.getAge(), groupName
|
|
|
+ );
|
|
|
+ return qrData;
|
|
|
+ }
|
|
|
+
|
|
|
// 工具方法:添加文本
|
|
|
private static void addText(PdfContentByte cb, BaseFont font, int size, BaseColor color, float x, float y, String text) {
|
|
|
cb.beginText();
|