| @ -0,0 +1,26 @@ | |||||
| package com.inspect.api.domain; | |||||
| import lombok.Data; | |||||
| import java.io.Serializable; | |||||
| /** | |||||
| * 巡检报告上传推送消息(先上报 report.upload.queue,由消费者异步 HTTP 上传外部平台) | |||||
| */ | |||||
| @Data | |||||
| public class ReportPushMessage implements Serializable { | |||||
| private static final long serialVersionUID = 1L; | |||||
| /** 报告ID(inspection_report.line_id) */ | |||||
| private Long reportId; | |||||
| /** 报告文件路径(SFTP,docx) */ | |||||
| private String filePath; | |||||
| /** 报告详情(reportInfo JSON 字符串,发布时组装,消费者只负责下载文件并上传) */ | |||||
| private String reportInfo; | |||||
| /** 消费重试次数(上传失败后 MQ 重投递计数) */ | |||||
| private int retryCount; | |||||
| } | |||||
| @ -0,0 +1,34 @@ | |||||
| package com.inspect.api.service; | |||||
| import com.inspect.api.constant.YanyuanConstants; | |||||
| import com.inspect.api.domain.ReportPushMessage; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.amqp.rabbit.core.RabbitTemplate; | |||||
| import org.springframework.stereotype.Service; | |||||
| /** | |||||
| * 巡检报告上传消息发布者:报告生成后先发布到 report.exchange, | |||||
| * 由 {@link ReportUploadConsumer} 异步 HTTP 上传到外部平台。 | |||||
| */ | |||||
| @Slf4j | |||||
| @Service | |||||
| public class ReportPushPublisher { | |||||
| private final RabbitTemplate rabbitTemplate; | |||||
| public ReportPushPublisher(RabbitTemplate rabbitTemplate) { | |||||
| this.rabbitTemplate = rabbitTemplate; | |||||
| } | |||||
| public void publish(ReportPushMessage message) { | |||||
| try { | |||||
| rabbitTemplate.convertAndSend(YanyuanConstants.REPORT_EXCHANGE, | |||||
| YanyuanConstants.REPORT_ROUTING_KEY, message); | |||||
| log.info("[REPORT_PUSH_MQ] 报告上传消息已发布: reportId={}, filePath={}", | |||||
| message.getReportId(), message.getFilePath()); | |||||
| } catch (Exception e) { | |||||
| log.error("[REPORT_PUSH_MQ] 报告上传消息发布失败: reportId={}, err: {}", | |||||
| message.getReportId(), e.getMessage(), e); | |||||
| } | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,137 @@ | |||||
| package com.inspect.api.service; | |||||
| import com.alibaba.fastjson.JSONObject; | |||||
| import com.inspect.api.domain.ReportPushMessage; | |||||
| import com.inspect.base.core.sftp.SftpClient; | |||||
| import com.inspect.base.core.utils.StringUtils; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.amqp.rabbit.annotation.RabbitListener; | |||||
| import org.springframework.beans.factory.annotation.Value; | |||||
| import org.springframework.stereotype.Component; | |||||
| import java.io.ByteArrayOutputStream; | |||||
| import java.io.InputStream; | |||||
| /** | |||||
| * 巡检报告上传消费者:从 report.upload.queue 取消息,下载报告 docx, | |||||
| * 通过 {@link ReportUploadService} HTTP 上传到外部平台(POST /jb-nessp-external/api/uploadReport)。 | |||||
| * <p> | |||||
| * 上传失败抛出异常使消息重投递(重试次数由消息 retryCount 控制,超过 {@code mq-max-retries} 丢弃)。 | |||||
| */ | |||||
| @Slf4j | |||||
| @Component | |||||
| public class ReportUploadConsumer { | |||||
| /** 上传失败最大重试次数(超过后丢弃,避免无限重投) */ | |||||
| @Value("${inspect.report-push.mq-max-retries:3}") | |||||
| private int maxRetries; | |||||
| /** 报告文件(SFTP)下载重试次数/间隔(毫秒),防止异步上传尚未完成 */ | |||||
| @Value("${inspect.report-push.download-retry-times:3}") | |||||
| private int downloadRetryTimes; | |||||
| @Value("${inspect.report-push.download-retry-interval-millis:1000}") | |||||
| private long downloadRetryIntervalMillis; | |||||
| private final ReportUploadService reportUploadService; | |||||
| private final SftpClient sftpClient; | |||||
| public ReportUploadConsumer(ReportUploadService reportUploadService, SftpClient sftpClient) { | |||||
| this.reportUploadService = reportUploadService; | |||||
| this.sftpClient = sftpClient; | |||||
| } | |||||
| @RabbitListener(queues = "#{reportUploadQueue.name}") | |||||
| public void onReport(ReportPushMessage message) { | |||||
| if (message == null || message.getReportId() == null) { | |||||
| log.warn("[REPORT_PUSH_MQ] 报告上传消息为空,丢弃"); | |||||
| return; | |||||
| } | |||||
| try { | |||||
| boolean ok = upload(message); | |||||
| if (ok) { | |||||
| log.info("[REPORT_PUSH_MQ] 报告上传成功: reportId={}", message.getReportId()); | |||||
| } else { | |||||
| throw new RuntimeException("report upload failed, will be requeued"); | |||||
| } | |||||
| } catch (Exception e) { | |||||
| message.setRetryCount(message.getRetryCount() + 1); | |||||
| if (message.getRetryCount() > maxRetries) { | |||||
| log.error("[REPORT_PUSH_MQ] 报告上传超过最大重试次数({}), 丢弃: reportId={}, err: {}", | |||||
| maxRetries, message.getReportId(), e.getMessage()); | |||||
| return; | |||||
| } | |||||
| log.warn("[REPORT_PUSH_MQ] 报告上传失败(第{}次), 消息重投递: reportId={}, err: {}", | |||||
| message.getRetryCount(), message.getReportId(), e.getMessage()); | |||||
| throw new RuntimeException("report upload failed, will be requeued", e); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 下载报告 docx + HTTP 上传(reportInfo 由发布方组装好随消息传递) | |||||
| */ | |||||
| private boolean upload(ReportPushMessage message) throws Exception { | |||||
| String filePath = message.getFilePath(); | |||||
| if (StringUtils.isEmpty(filePath)) { | |||||
| log.warn("[REPORT_PUSH_MQ] 报告文件路径为空, 丢弃: reportId={}", message.getReportId()); | |||||
| return true; | |||||
| } | |||||
| byte[] fileBytes = downloadFromSftp(filePath); | |||||
| if (fileBytes == null || fileBytes.length == 0) { | |||||
| log.warn("[REPORT_PUSH_MQ] 下载报告文件失败: {}", filePath); | |||||
| return false; | |||||
| } | |||||
| JSONObject reportInfo = JSONObject.parseObject(message.getReportInfo()); | |||||
| if (reportInfo == null) { | |||||
| log.warn("[REPORT_PUSH_MQ] reportInfo 为空, 丢弃: reportId={}", message.getReportId()); | |||||
| return true; | |||||
| } | |||||
| String fileName = filePath.substring(filePath.lastIndexOf('/') + 1); | |||||
| boolean ok = reportUploadService.uploadReport(fileBytes, fileName, reportInfo); | |||||
| log.info("[REPORT_PUSH_MQ] 报告上传结果: {}, reportId={}, fileName={}", | |||||
| ok, message.getReportId(), fileName); | |||||
| return ok; | |||||
| } | |||||
| /** | |||||
| * 从 SFTP 下载报告文件(导出为异步上传,下载失败自动重试等待) | |||||
| */ | |||||
| private byte[] downloadFromSftp(String filePath) { | |||||
| for (int i = 0; i < Math.max(1, downloadRetryTimes); i++) { | |||||
| try { | |||||
| final byte[][] holder = new byte[1][]; | |||||
| sftpClient.downLoad(filePath, inputStream -> holder[0] = readAll(inputStream)); | |||||
| if (holder[0] != null && holder[0].length > 0) { | |||||
| return holder[0]; | |||||
| } | |||||
| log.warn("[REPORT_PUSH_MQ] 报告文件尚未就绪, 重试 {}/{}: {}", i + 1, downloadRetryTimes, filePath); | |||||
| } catch (Exception e) { | |||||
| log.warn("[REPORT_PUSH_MQ] 下载报告文件异常, 重试 {}/{}: {}, err: {}", | |||||
| i + 1, downloadRetryTimes, filePath, e.getMessage()); | |||||
| } | |||||
| if (i < Math.max(1, downloadRetryTimes) - 1 && downloadRetryIntervalMillis > 0) { | |||||
| try { | |||||
| Thread.sleep(downloadRetryIntervalMillis); | |||||
| } catch (InterruptedException ignore) { | |||||
| Thread.currentThread().interrupt(); | |||||
| break; | |||||
| } | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| private byte[] readAll(InputStream inputStream) throws Exception { | |||||
| ByteArrayOutputStream bos = new ByteArrayOutputStream(); | |||||
| byte[] buf = new byte[8192]; | |||||
| int n; | |||||
| try { | |||||
| while ((n = inputStream.read(buf)) != -1) { | |||||
| bos.write(buf, 0, n); | |||||
| } | |||||
| } finally { | |||||
| inputStream.close(); | |||||
| } | |||||
| return bos.toByteArray(); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,182 @@ | |||||
| package com.inspect.api.service; | |||||
| import com.alibaba.fastjson.JSONObject; | |||||
| import com.inspect.api.domain.ApiResult; | |||||
| import com.inspect.api.domain.AuthTokenData; | |||||
| import com.inspect.base.core.utils.StringUtils; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.beans.factory.annotation.Value; | |||||
| import org.springframework.stereotype.Service; | |||||
| import java.io.BufferedReader; | |||||
| import java.io.ByteArrayInputStream; | |||||
| import java.io.DataOutputStream; | |||||
| import java.io.InputStream; | |||||
| import java.io.InputStreamReader; | |||||
| import java.net.HttpURLConnection; | |||||
| import java.net.URL; | |||||
| /** | |||||
| * 巡检报告上传服务(外部平台《巡检报告接口文档 v1.0.0》) | |||||
| * <p> | |||||
| * 1. Joinbright-Token 复用 {@link ExtAuthService}(GET /ext_auth/getAuthToken,配置 yanyuan.auth.*) | |||||
| * 2. POST {upload-url} 上传巡检报告: | |||||
| * - 请求头携带 joinbright-token | |||||
| * - multipart/form-data:file(报告文件) + reportInfo(报告详情 JSON 字符串) | |||||
| */ | |||||
| @Slf4j | |||||
| @Service | |||||
| public class ReportUploadService { | |||||
| /** Token 请求头名称(外部平台固定) */ | |||||
| private static final String TOKEN_HEADER = "joinbright-token"; | |||||
| /** 是否启用报告上传推送 */ | |||||
| @Value("${inspect.report-push.enabled:true}") | |||||
| private boolean enabled; | |||||
| /** 巡检报告上传接口完整地址 */ | |||||
| @Value("${inspect.report-push.upload-url:http://192.168.129.2:32799/jb-gateway/jb-nessp-external/api/uploadReport}") | |||||
| private String uploadUrl; | |||||
| /** http 连接/读取超时(秒) */ | |||||
| @Value("${inspect.report-push.http-timeout-seconds:60}") | |||||
| private int httpTimeoutSeconds; | |||||
| private final ExtAuthService extAuthService; | |||||
| public ReportUploadService(ExtAuthService extAuthService) { | |||||
| this.extAuthService = extAuthService; | |||||
| } | |||||
| /** | |||||
| * 获取 Joinbright-Token | |||||
| */ | |||||
| public String getJoinbrightToken() { | |||||
| try { | |||||
| ApiResult<AuthTokenData> result = extAuthService.getAuthToken(); | |||||
| if (result == null || !result.isSuccess() || result.getData() == null) { | |||||
| log.error("[REPORT-PUSH] 获取 Joinbright-Token 失败: {}", result); | |||||
| return null; | |||||
| } | |||||
| String token = result.getData().getAccess_token(); | |||||
| log.info("[REPORT-PUSH] 获取 Joinbright-Token 成功: {}...", | |||||
| StringUtils.isEmpty(token) ? "" : token.substring(0, Math.min(12, token.length()))); | |||||
| return token; | |||||
| } catch (Exception e) { | |||||
| log.error("[REPORT-PUSH] 获取 Joinbright-Token 异常", e); | |||||
| return null; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 上传巡检报告文件及元数据到外部平台 | |||||
| * | |||||
| * @param fileBytes 报告文件字节(docx) | |||||
| * @param fileName 报告文件名(含扩展名) | |||||
| * @param reportInfo 报告详情对象(序列化为 JSON 字符串传递) | |||||
| * @return 是否上传成功 | |||||
| */ | |||||
| public boolean uploadReport(byte[] fileBytes, String fileName, JSONObject reportInfo) { | |||||
| if (!enabled) { | |||||
| log.warn("[REPORT-PUSH] 报告上传未启用(inspect.report-push.enabled=false)"); | |||||
| return false; | |||||
| } | |||||
| if (fileBytes == null || fileBytes.length == 0) { | |||||
| log.warn("[REPORT-PUSH] 报告文件为空, 无法上传"); | |||||
| return false; | |||||
| } | |||||
| if (reportInfo == null) { | |||||
| log.warn("[REPORT-PUSH] reportInfo 为空, 无法上传"); | |||||
| return false; | |||||
| } | |||||
| // 上传失败时(token可能失效/超时)重新获取token重试一次 | |||||
| String token = getJoinbrightToken(); | |||||
| boolean ok = doUpload(token, fileBytes, fileName, reportInfo); | |||||
| if (!ok && StringUtils.isNotEmpty(token)) { | |||||
| log.warn("[REPORT-PUSH] 首次上传失败, 重新获取token后重试"); | |||||
| ok = doUpload(getJoinbrightToken(), fileBytes, fileName, reportInfo); | |||||
| } | |||||
| return ok; | |||||
| } | |||||
| private boolean doUpload(String token, byte[] fileBytes, String fileName, JSONObject reportInfo) { | |||||
| if (StringUtils.isEmpty(token)) { | |||||
| log.error("[REPORT-PUSH] Joinbright-Token 为空, 无法上传"); | |||||
| return false; | |||||
| } | |||||
| String boundary = "----InspectReportBoundary" + System.currentTimeMillis(); | |||||
| HttpURLConnection conn = null; | |||||
| try { | |||||
| URL url = new URL(uploadUrl); | |||||
| conn = (HttpURLConnection) url.openConnection(); | |||||
| conn.setRequestMethod("POST"); | |||||
| conn.setDoOutput(true); | |||||
| conn.setDoInput(true); | |||||
| conn.setConnectTimeout(httpTimeoutSeconds * 1000); | |||||
| conn.setReadTimeout(httpTimeoutSeconds * 1000); | |||||
| conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); | |||||
| conn.setRequestProperty(TOKEN_HEADER, token); | |||||
| String reportInfoJson = reportInfo.toJSONString(); | |||||
| try (DataOutputStream out = new DataOutputStream(conn.getOutputStream())) { | |||||
| // reportInfo 字段 | |||||
| out.writeBytes("--" + boundary + "\r\n"); | |||||
| out.writeBytes("Content-Disposition: form-data; name=\"reportInfo\"\r\n"); | |||||
| out.writeBytes("Content-Type: text/plain; charset=UTF-8\r\n\r\n"); | |||||
| out.write(reportInfoJson.getBytes("UTF-8")); | |||||
| out.writeBytes("\r\n"); | |||||
| // file 字段 | |||||
| out.writeBytes("--" + boundary + "\r\n"); | |||||
| out.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n"); | |||||
| out.writeBytes("Content-Type: application/octet-stream\r\n\r\n"); | |||||
| try (InputStream in = new ByteArrayInputStream(fileBytes)) { | |||||
| byte[] buf = new byte[8192]; | |||||
| int n; | |||||
| while ((n = in.read(buf)) != -1) { | |||||
| out.write(buf, 0, n); | |||||
| } | |||||
| } | |||||
| out.writeBytes("\r\n"); | |||||
| out.writeBytes("--" + boundary + "--\r\n"); | |||||
| out.flush(); | |||||
| } | |||||
| int responseCode = conn.getResponseCode(); | |||||
| String response = readResponse(conn, responseCode); | |||||
| log.info("[REPORT-PUSH] 上传响应 code: {}, body: {}", responseCode, response); | |||||
| if (responseCode == 200) { | |||||
| JSONObject resp = JSONObject.parseObject(response); | |||||
| if (resp != null && Boolean.TRUE.equals(resp.getBoolean("success"))) { | |||||
| return true; | |||||
| } | |||||
| log.error("[REPORT-PUSH] 上传业务失败: {}", response); | |||||
| } else { | |||||
| log.error("[REPORT-PUSH] 上传HTTP失败: {}, body: {}", responseCode, response); | |||||
| } | |||||
| return false; | |||||
| } catch (Exception e) { | |||||
| log.error("[REPORT-PUSH] 报告上传异常, url: {}", uploadUrl, e); | |||||
| return false; | |||||
| } finally { | |||||
| if (conn != null) { | |||||
| conn.disconnect(); | |||||
| } | |||||
| } | |||||
| } | |||||
| private String readResponse(HttpURLConnection conn, int responseCode) throws Exception { | |||||
| InputStream stream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream(); | |||||
| if (stream == null) { | |||||
| return ""; | |||||
| } | |||||
| StringBuilder result = new StringBuilder(); | |||||
| try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "UTF-8"))) { | |||||
| String line; | |||||
| while ((line = reader.readLine()) != null) { | |||||
| result.append(line); | |||||
| } | |||||
| } | |||||
| return result.toString(); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,281 @@ | |||||
| package com.inspect.reportfont; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.apache.poi.ooxml.POIXMLDocumentPart; | |||||
| import org.apache.poi.openxml4j.opc.OPCPackage; | |||||
| import org.apache.poi.openxml4j.opc.PackagePart; | |||||
| import org.apache.poi.openxml4j.opc.PackageRelationship; | |||||
| import org.apache.poi.openxml4j.opc.PackagingURIHelper; | |||||
| import org.apache.poi.openxml4j.opc.TargetMode; | |||||
| import org.apache.poi.xwpf.usermodel.XWPFDocument; | |||||
| import org.apache.poi.xwpf.usermodel.XWPFSettings; | |||||
| import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTLanguage; | |||||
| import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSettings; | |||||
| import org.springframework.beans.factory.annotation.Value; | |||||
| import org.springframework.stereotype.Component; | |||||
| import java.io.ByteArrayOutputStream; | |||||
| import java.io.InputStream; | |||||
| import java.io.OutputStream; | |||||
| import java.nio.file.Files; | |||||
| import java.nio.file.Path; | |||||
| import java.nio.file.Paths; | |||||
| import java.util.Arrays; | |||||
| import java.util.List; | |||||
| /** | |||||
| * 动环巡检报告 Word 字体嵌入。 | |||||
| * <p> | |||||
| * 原理(Word 的"嵌入字体"机制,让查看方电脑无需安装字体): | |||||
| * <ol> | |||||
| * <li>docx 包内的字体"注册表"是 {@code word/fontTable.xml}:按字体名(w:name)列出字体, | |||||
| * 每个条目通过 {@code embedRegular/embedBold} 属性(r:id)引用一个字体部件;</li> | |||||
| * <li>字体部件({@code word/fonts/*.odttf})是混淆后的字体数据——按 OOXML §17.8.1, | |||||
| * 字体前 32 字节与混淆 key 异或,key 由条目中的 {@code fontKey}(GUID)推导; | |||||
| * Word 打开文档时按同样算法还原后使用;</li> | |||||
| * <li>正文 run 只写字体名(w:eastAsia),Word 按名字查 fontTable 命中嵌入字体。</li> | |||||
| * </ol> | |||||
| * 实现要点: | |||||
| * <ul> | |||||
| * <li>{@code fontTable.xml} 直接使用 Word 自身生成的模板(fontKey 固定), | |||||
| * 兼容性最稳(实测动态生成的 fontTable Word 不识别嵌入字体);</li> | |||||
| * <li>字体部件按模板固定的 font1..font5 命名,混淆 key 与模板条目一一对应, | |||||
| * 且按序 addRelationship 保证 rId 映射正确。</li> | |||||
| * </ul> | |||||
| */ | |||||
| @Slf4j | |||||
| @Component | |||||
| public class ReportFontEmbedder { | |||||
| // ============================ 部件/关系类型常量 ============================ | |||||
| private static final String REL_FONT = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font"; | |||||
| private static final String REL_FONT_TABLE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable"; | |||||
| private static final String REL_STYLES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"; | |||||
| private static final String REL_THEME = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"; | |||||
| private static final String REL_WEB_SETTINGS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings"; | |||||
| private static final String CT_FONT_TABLE = "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"; | |||||
| private static final String CT_FONT = "application/vnd.openxmlformats-officedocument.obfuscatedFont"; | |||||
| private static final String CT_STYLES = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"; | |||||
| private static final String CT_THEME = "application/vnd.openxmlformats-officedocument.theme+xml"; | |||||
| private static final String CT_WEB_SETTINGS = "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"; | |||||
| /** 模板文件所在 classpath 目录 */ | |||||
| private static final String TEMPLATE_DIR = "report/wordparts/"; | |||||
| /** | |||||
| * 报告用到的 5 个嵌入字体部件(与 fontTable 模板的条目一一对应): | |||||
| * 部件名 -> {字体文件, 混淆 key}。字体文件位于 classpath fonts/ 或外部目录。 | |||||
| * 注意:黑体在模板中有 regular/bold/italic 三个样式条目,均引用同一份黑体; | |||||
| * 方正小标宋、仿宋各只嵌 bold(模板如此,标题/表头均加粗使用)。 | |||||
| */ | |||||
| private static final List<EmbeddedFont> EMBEDDED_FONTS = Arrays.asList( | |||||
| new EmbeddedFont("font1", "simhei.ttf", "DAF739B1-18F1-4906-820A-C106608C2B73"), | |||||
| new EmbeddedFont("font2", "simhei.ttf", "3039BACA-5140-4C4C-8AFB-F7B61E374105"), | |||||
| new EmbeddedFont("font3", "simhei.ttf", "73410ACE-E9FA-46ED-BA3D-9ECB6C9FEF4E"), | |||||
| new EmbeddedFont("font4", "方正小标宋简体.ttf", "4D5F1B72-2D28-435A-B7BC-848B95F30EBB"), | |||||
| new EmbeddedFont("font5", "simfang.ttf", "818E8C25-A7E2-4610-9234-360D3B3511E5")); | |||||
| /** | |||||
| * 外部字体目录({@code report.donghuan.fonts-dir}), | |||||
| * 留空则从 classpath {@code fonts/} 加载(随 jar 打包)。 | |||||
| */ | |||||
| @Value("${report.donghuan.fonts-dir:}") | |||||
| private String fontsDir; | |||||
| /** 一个待嵌入字体:部件名 + 字体文件 + 混淆 key(fontKey) */ | |||||
| private static final class EmbeddedFont { | |||||
| final String partName; | |||||
| final String fileName; | |||||
| final String fontKey; | |||||
| EmbeddedFont(String partName, String fileName, String fontKey) { | |||||
| this.partName = partName; | |||||
| this.fileName = fileName; | |||||
| this.fontKey = fontKey; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 把报告所需字体嵌入 docx(在生成正文内容前调用)。 | |||||
| * 字体缺失时不阻断归档,报告退化为不带嵌入字体。 | |||||
| */ | |||||
| public void embed(XWPFDocument doc) { | |||||
| try { | |||||
| OPCPackage pkg = doc.getPackage(); | |||||
| PackagePart documentPart = pkg.getPart(PackagingURIHelper.createPartName("/word/document.xml")); | |||||
| // 1. fontTable.xml(静态模板,含字体注册信息) | |||||
| addPart(pkg, "/word/fontTable.xml", CT_FONT_TABLE, "fontTable.xml"); | |||||
| // 2. 字体部件 font1..font5.odttf,关系挂到 fontTable 上(rId 按序分配,与模板一致) | |||||
| embedFontParts(pkg, pkg.getPart(PackagingURIHelper.createPartName("/word/fontTable.xml"))); | |||||
| // 3. 补齐 Word 常规部件(styles/theme/webSettings,缺了 Word 不解析嵌入字体) | |||||
| addPart(pkg, "/word/styles.xml", CT_STYLES, "styles.xml"); | |||||
| addPart(pkg, "/word/theme/theme1.xml", CT_THEME, "theme1.xml"); | |||||
| addPart(pkg, "/word/webSettings.xml", CT_WEB_SETTINGS, "webSettings.xml"); | |||||
| // 4. document.xml 引用 fontTable/styles/theme/webSettings | |||||
| addDocumentRelations(documentPart, pkg); | |||||
| // 5. settings.xml 写入嵌入字体标志 | |||||
| addSettingsFlags(doc); | |||||
| log.info("[FONT_EMBED] 报告字体嵌入完成"); | |||||
| } catch (Exception e) { | |||||
| log.error("[FONT_EMBED] 字体嵌入失败,本次报告不带嵌入字体", e); | |||||
| } | |||||
| } | |||||
| /** 从模板目录创建部件并写入内容,同时挂到 document.xml */ | |||||
| private PackagePart addPart(OPCPackage pkg, String partName, String contentType, String templateName) throws Exception { | |||||
| PackagePart part = pkg.createPart(PackagingURIHelper.createPartName(partName), contentType); | |||||
| byte[] bytes = readTemplate(templateName); | |||||
| if (bytes != null) { | |||||
| try (OutputStream os = part.getOutputStream()) { | |||||
| os.write(bytes); | |||||
| } | |||||
| } | |||||
| log.info("[FONT_EMBED] 已创建部件: {} ({} bytes)", partName, bytes == null ? 0 : bytes.length); | |||||
| return part; | |||||
| } | |||||
| /** 把 document.xml 与 fontTable/styles/theme/webSettings 建立关系 */ | |||||
| private void addDocumentRelations(PackagePart documentPart, OPCPackage pkg) throws Exception { | |||||
| documentPart.addRelationship( | |||||
| PackagingURIHelper.createPartName("/word/fontTable.xml"), TargetMode.INTERNAL, REL_FONT_TABLE); | |||||
| documentPart.addRelationship( | |||||
| PackagingURIHelper.createPartName("/word/styles.xml"), TargetMode.INTERNAL, REL_STYLES); | |||||
| documentPart.addRelationship( | |||||
| PackagingURIHelper.createPartName("/word/theme/theme1.xml"), TargetMode.INTERNAL, REL_THEME); | |||||
| documentPart.addRelationship( | |||||
| PackagingURIHelper.createPartName("/word/webSettings.xml"), TargetMode.INTERNAL, REL_WEB_SETTINGS); | |||||
| } | |||||
| /** | |||||
| * 依次创建 font1..font5.odttf 部件(完整字体按固定 key 混淆), | |||||
| * 并把 font 关系挂到 fontTable 部件(addRelationship 按序生成 rId1..rId5, | |||||
| * 与静态模板中的引用一一对应)。 | |||||
| */ | |||||
| private void embedFontParts(OPCPackage pkg, PackagePart fontTablePart) throws Exception { | |||||
| for (EmbeddedFont font : EMBEDDED_FONTS) { | |||||
| byte[] fontBytes = loadFontBytes(font.fileName); | |||||
| if (fontBytes == null) { | |||||
| log.warn("[FONT_EMBED] 字体文件缺失,跳过部件 {}: {}", font.partName, font.fileName); | |||||
| continue; | |||||
| } | |||||
| PackagePart fontPart = pkg.createPart( | |||||
| PackagingURIHelper.createPartName("/word/fonts/" + font.partName + ".odttf"), CT_FONT); | |||||
| try (OutputStream os = fontPart.getOutputStream()) { | |||||
| os.write(obfuscateFont(fontBytes, font.fontKey)); | |||||
| } | |||||
| fontTablePart.addRelationship(fontPart.getPartName(), TargetMode.INTERNAL, REL_FONT); | |||||
| log.info("[FONT_EMBED] 已嵌入字体部件: {} <- {} ({} bytes)", font.partName, font.fileName, fontBytes.length); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 在 settings.xml 写入嵌入字体标志,Word 靠它识别"文档带嵌入字体": | |||||
| * {@code <w:embedTrueTypeFonts/>}、{@code <w:saveSubsetFonts/>}、 | |||||
| * {@code <w:themeFontLang w:val="en-US" w:eastAsia="zh-CN"/>}。 | |||||
| * <p> | |||||
| * 注意:POI 4.1.2 保存 settings 时从内部缓存的 CTSettings 写回(直接改部件内容会被覆盖), | |||||
| * 且不能整体替换缓存对象(类型不兼容),因此反射取出缓存的 CTSettings 用其 API 添加标志。 | |||||
| */ | |||||
| private void addSettingsFlags(XWPFDocument doc) { | |||||
| try { | |||||
| for (POIXMLDocumentPart part : doc.getRelations()) { | |||||
| if (part instanceof XWPFSettings) { | |||||
| java.lang.reflect.Field ctField = XWPFSettings.class.getDeclaredField("ctSettings"); | |||||
| ctField.setAccessible(true); | |||||
| CTSettings ctSettings = (CTSettings) ctField.get(part); | |||||
| if (!ctSettings.isSetEmbedTrueTypeFonts()) { | |||||
| ctSettings.addNewEmbedTrueTypeFonts(); | |||||
| } | |||||
| if (!ctSettings.isSetSaveSubsetFonts()) { | |||||
| ctSettings.addNewSaveSubsetFonts(); | |||||
| } | |||||
| if (!ctSettings.isSetThemeFontLang()) { | |||||
| CTLanguage lang = ctSettings.addNewThemeFontLang(); | |||||
| lang.setVal("en-US"); | |||||
| lang.setEastAsia("zh-CN"); | |||||
| } | |||||
| log.info("[FONT_EMBED] settings.xml 已写入嵌入字体标志"); | |||||
| return; | |||||
| } | |||||
| } | |||||
| log.warn("[FONT_EMBED] 未找到 settings 部件,跳过嵌入字体标志"); | |||||
| } catch (Exception e) { | |||||
| log.warn("[FONT_EMBED] 写入 settings.xml 嵌入字体标志失败", e); | |||||
| } | |||||
| } | |||||
| /** 读取 classpath 模板内容(report/wordparts/) */ | |||||
| private byte[] readTemplate(String name) { | |||||
| try (InputStream is = getClass().getClassLoader().getResourceAsStream(TEMPLATE_DIR + name)) { | |||||
| if (is == null) { | |||||
| log.warn("[FONT_EMBED] 模板缺失: {}", TEMPLATE_DIR + name); | |||||
| return null; | |||||
| } | |||||
| return readAll(is); | |||||
| } catch (Exception e) { | |||||
| log.warn("[FONT_EMBED] 读取模板失败: {}", name, e); | |||||
| return null; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 加载字体文件:优先外部目录(report.donghuan.fonts-dir), | |||||
| * 其次 classpath {@code fonts/}(随 jar 打包)。 | |||||
| */ | |||||
| private byte[] loadFontBytes(String fileName) { | |||||
| try { | |||||
| if (fontsDir != null && !fontsDir.isEmpty()) { | |||||
| Path fontPath = Paths.get(fontsDir, fileName); | |||||
| if (Files.exists(fontPath)) { | |||||
| return Files.readAllBytes(fontPath); | |||||
| } | |||||
| } | |||||
| try (InputStream is = getClass().getClassLoader().getResourceAsStream("fonts/" + fileName)) { | |||||
| if (is != null) { | |||||
| return readAll(is); | |||||
| } | |||||
| } | |||||
| log.warn("[FONT_EMBED] 字体文件不存在: {} (fontsDir={}, classpath=fonts/)", fileName, fontsDir); | |||||
| } catch (Exception e) { | |||||
| log.warn("[FONT_EMBED] 读取字体文件失败: {}", fileName, e); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| /** | |||||
| * OOXML §17.8.1 字体混淆(ODTTF):字体前 32 字节与混淆 key 异或。 | |||||
| * key 由 fontKey(GUID 去掉横线、32 位十六进制)每 2 字符转 1 字节后整体反转得到。 | |||||
| * Word 打开文档时按同样算法还原字体。 | |||||
| */ | |||||
| private byte[] obfuscateFont(byte[] fontBytes, String fontKey) { | |||||
| byte[] out = fontBytes.clone(); | |||||
| byte[] key = buildObfuscationKey(fontKey); | |||||
| int len = Math.min(32, out.length); | |||||
| for (int i = 0; i < len; i++) { | |||||
| out[i] ^= key[i % 16]; | |||||
| } | |||||
| return out; | |||||
| } | |||||
| /** 由 fontKey(GUID)生成 16 字节混淆 key:十六进制转字节后整体反转 */ | |||||
| private byte[] buildObfuscationKey(String fontKey) { | |||||
| String hex = fontKey.replace("-", ""); | |||||
| byte[] key = new byte[16]; | |||||
| for (int i = 0; i < 16; i++) { | |||||
| key[i] = (byte) Integer.parseInt(hex.substring((15 - i) * 2, (16 - i) * 2), 16); | |||||
| } | |||||
| return key; | |||||
| } | |||||
| private byte[] readAll(InputStream is) throws java.io.IOException { | |||||
| ByteArrayOutputStream bos = new ByteArrayOutputStream(); | |||||
| byte[] buf = new byte[8192]; | |||||
| int n; | |||||
| while ((n = is.read(buf)) != -1) { | |||||
| bos.write(buf, 0, n); | |||||
| } | |||||
| return bos.toByteArray(); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,64 @@ | |||||
| package com.inspect.reportpush.controller; | |||||
| import com.alibaba.fastjson.JSONObject; | |||||
| import com.inspect.api.service.ReportUploadService; | |||||
| import com.inspect.base.core.utils.DateUtils; | |||||
| import com.inspect.base.core.utils.StringUtils; | |||||
| import com.inspect.base.core.web.controller.BaseController; | |||||
| import com.inspect.base.core.web.domain.AjaxResult; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.beans.factory.annotation.Autowired; | |||||
| import org.springframework.web.bind.annotation.GetMapping; | |||||
| import org.springframework.web.bind.annotation.RequestMapping; | |||||
| import org.springframework.web.bind.annotation.RestController; | |||||
| import java.nio.charset.StandardCharsets; | |||||
| import java.util.Date; | |||||
| /** | |||||
| * 报告推送调试接口(对接外部平台-盐源新能源智慧感知平台《巡检报告接口文档》) | |||||
| * <ul> | |||||
| * <li>GET /reportpush/getToken 获取 Joinbright-Token(复用 ExtAuthService,验证认证连通性)</li> | |||||
| * <li>GET /reportpush/test 上传一条测试报告(验证 /uploadReport 上传接口连通性)</li> | |||||
| * </ul> | |||||
| */ | |||||
| @Slf4j | |||||
| @RestController | |||||
| @RequestMapping("/reportpush") | |||||
| public class ReportPushController extends BaseController { | |||||
| @Autowired | |||||
| private ReportUploadService reportUploadService; | |||||
| /** | |||||
| * 获取 Joinbright-Token(调试用) | |||||
| */ | |||||
| @GetMapping("/getToken") | |||||
| public AjaxResult getToken() { | |||||
| String token = reportUploadService.getJoinbrightToken(); | |||||
| return StringUtils.isNotEmpty(token) ? AjaxResult.success(token) : AjaxResult.error("获取Joinbright-Token失败"); | |||||
| } | |||||
| /** | |||||
| * 上传测试报告(调试用) | |||||
| */ | |||||
| @GetMapping("/test") | |||||
| public AjaxResult test(String message) { | |||||
| byte[] dummyFile = "inspect report upload test".getBytes(StandardCharsets.UTF_8); | |||||
| String now = DateUtils.parseDateToStr(DateUtils.yyyyMMddHHmmss2, new Date()); | |||||
| JSONObject reportInfo = new JSONObject(); | |||||
| reportInfo.put("id", "TEST-" + System.currentTimeMillis()); | |||||
| reportInfo.put("name", "测试报告"); | |||||
| reportInfo.put("fileType", "docx"); | |||||
| reportInfo.put("stationId", ""); | |||||
| reportInfo.put("stationName", "测试电站"); | |||||
| reportInfo.put("inspectTime", now); | |||||
| reportInfo.put("createTime", now); | |||||
| reportInfo.put("inspectType", "机器人巡检"); | |||||
| reportInfo.put("inspectContent", StringUtils.isEmpty(message) ? "巡检报告上传接口联调测试" : message); | |||||
| reportInfo.put("taskId", "TEST"); | |||||
| reportInfo.put("taskName", "测试任务"); | |||||
| boolean ok = reportUploadService.uploadReport(dummyFile, "test_report.docx", reportInfo); | |||||
| return ok ? AjaxResult.success("上传成功") : AjaxResult.error("上传失败, 请查看日志"); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,2 @@ | |||||
| <?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |||||
| <w:fonts xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" xmlns:w16du="http://schemas.microsoft.com/office/word/2023/wordml/word16du" xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" xmlns:w16sdtfl="http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock" xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du"><w:font w:name="等线"><w:altName w:val="DengXian"/><w:panose1 w:val="02010600030101010101"/><w:charset w:val="86"/><w:family w:val="auto"/><w:pitch w:val="variable"/><w:sig w:usb0="A00002BF" w:usb1="38CF7CFA" w:usb2="00000016" w:usb3="00000000" w:csb0="0004000F" w:csb1="00000000"/></w:font><w:font w:name="Times New Roman"><w:panose1 w:val="02020603050405020304"/><w:charset w:val="00"/><w:family w:val="roman"/><w:pitch w:val="variable"/><w:sig w:usb0="E0002EFF" w:usb1="C000785B" w:usb2="00000009" w:usb3="00000000" w:csb0="000001FF" w:csb1="00000000"/></w:font><w:font w:name="仿宋"><w:panose1 w:val="02010609060101010101"/><w:charset w:val="86"/><w:family w:val="modern"/><w:pitch w:val="fixed"/><w:sig w:usb0="800002BF" w:usb1="38CF7CFA" w:usb2="00000016" w:usb3="00000000" w:csb0="00040001" w:csb1="00000000"/><w:embedRegular r:id="rId1" w:subsetted="1" w:fontKey="{DAF739B1-18F1-4906-820A-C106608C2B73}"/><w:embedBold r:id="rId2" w:subsetted="1" w:fontKey="{3039BACA-5140-4C4C-8AFB-F7B61E374105}"/><w:embedItalic r:id="rId3" w:subsetted="1" w:fontKey="{73410ACE-E9FA-46ED-BA3D-9ECB6C9FEF4E}"/></w:font><w:font w:name="方正小标宋简体"><w:panose1 w:val="02000000000000000000"/><w:charset w:val="86"/><w:family w:val="auto"/><w:pitch w:val="variable"/><w:sig w:usb0="A00002BF" w:usb1="184F6CFA" w:usb2="00000012" w:usb3="00000000" w:csb0="00040001" w:csb1="00000000"/><w:embedBold r:id="rId4" w:subsetted="1" w:fontKey="{4D5F1B72-2D28-435A-B7BC-848B95F30EBB}"/></w:font><w:font w:name="黑体"><w:altName w:val="SimHei"/><w:panose1 w:val="02010609060101010101"/><w:charset w:val="86"/><w:family w:val="modern"/><w:pitch w:val="fixed"/><w:sig w:usb0="800002BF" w:usb1="38CF7CFA" w:usb2="00000016" w:usb3="00000000" w:csb0="00040001" w:csb1="00000000"/><w:embedBold r:id="rId5" w:subsetted="1" w:fontKey="{818E8C25-A7E2-4610-9234-360D3B3511E5}"/></w:font><w:font w:name="等线 Light"><w:panose1 w:val="02010600030101010101"/><w:charset w:val="86"/><w:family w:val="auto"/><w:pitch w:val="variable"/><w:sig w:usb0="A00002BF" w:usb1="38CF7CFA" w:usb2="00000016" w:usb3="00000000" w:csb0="0004000F" w:csb1="00000000"/></w:font></w:fonts> | |||||
| @ -0,0 +1,2 @@ | |||||
| <?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |||||
| <w:webSettings xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" xmlns:w16du="http://schemas.microsoft.com/office/word/2023/wordml/word16du" xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" xmlns:w16sdtfl="http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock" xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du"/> | |||||