diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/config/RabbitMQConfig.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/config/RabbitMQConfig.java index f100c04..2700d94 100644 --- a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/config/RabbitMQConfig.java +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/config/RabbitMQConfig.java @@ -28,6 +28,24 @@ public class RabbitMQConfig { return BindingBuilder.bind(alarmQueue()).to(alarmExchange()).with(YanyuanConstants.ROUTING_KEY); } + // ============================ 巡检报告上传推送 MQ ============================ + + @Bean + public TopicExchange reportExchange() { + return new TopicExchange(YanyuanConstants.REPORT_EXCHANGE, true, false); + } + + @Bean("reportUploadQueue") + public Queue reportUploadQueue() { + return new Queue(YanyuanConstants.REPORT_QUEUE, true); + } + + @Bean + public Binding reportUploadBinding() { + return BindingBuilder.bind(reportUploadQueue()).to(reportExchange()) + .with(YanyuanConstants.REPORT_ROUTING_KEY); + } + @Bean public MessageConverter messageConverter() { return new Jackson2JsonMessageConverter(); diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/constant/YanyuanConstants.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/constant/YanyuanConstants.java index ec7758e..67bb27e 100644 --- a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/constant/YanyuanConstants.java +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/constant/YanyuanConstants.java @@ -7,4 +7,9 @@ public class YanyuanConstants { public static final String EXCHANGE = "alarm.exchange"; public static final String QUEUE = "alarm.queue"; public static final String ROUTING_KEY = "alarm.routing-key"; + + /** 巡检报告上传推送(先上报MQ,再由消费者HTTP上传外部平台) */ + public static final String REPORT_EXCHANGE = "report.exchange"; + public static final String REPORT_QUEUE = "report.upload.queue"; + public static final String REPORT_ROUTING_KEY = "report.upload.routing-key"; } diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/domain/ReportPushMessage.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/domain/ReportPushMessage.java new file mode 100644 index 0000000..6361247 --- /dev/null +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/domain/ReportPushMessage.java @@ -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; +} diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportPushPublisher.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportPushPublisher.java new file mode 100644 index 0000000..79506d6 --- /dev/null +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportPushPublisher.java @@ -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); + } + } +} diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadConsumer.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadConsumer.java new file mode 100644 index 0000000..0bec7a2 --- /dev/null +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadConsumer.java @@ -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)。 + *

+ * 上传失败抛出异常使消息重投递(重试次数由消息 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(); + } +} diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java new file mode 100644 index 0000000..f34f5de --- /dev/null +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java @@ -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》) + *

+ * 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 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(); + } +} diff --git a/inspect-main/inspect-main-start/src/main/resources/application.yml b/inspect-main/inspect-main-start/src/main/resources/application.yml index b46691d..8eab957 100644 --- a/inspect-main/inspect-main-start/src/main/resources/application.yml +++ b/inspect-main/inspect-main-start/src/main/resources/application.yml @@ -64,4 +64,43 @@ sftp: port: 21 username: ftpuser password: atia2018 - isImplicit: 1 \ No newline at end of file + isImplicit: 1 + +# 盐源外部平台认证/推送(复用 inspect-main-api 的 ExtAuthService / AlarmWsConnection) +# client-id/client-secret 为现场认证凭据,请按现场配置填写(可在nacos配置中心覆盖) +yanyuan: + auth: + base-url: http://192.168.129.2:32799/jb-gateway/jb-base + ws-url: ws://192.168.129.2:32799/jb-gateway/jb-base/jb-base-socket + client-id: "" + client-secret: "" + +# 任务巡检完成后自动归档 + 生成报告 + 推送(先上报 RabbitMQ,再由消费者 HTTP 上传外部平台) +# 见《盐源新能源智慧感知平台_巡检报告接口文档 v1.0.0》:POST /jb-nessp-external/api/uploadReport +inspect: + report-push: + # 是否启用自动归档+推送 + enabled: true + # 巡检报告上传接口完整地址 + upload-url: http://192.168.129.2:32799/jb-gateway/jb-nessp-external/api/uploadReport + # http 连接/读取超时(秒) + http-timeout-seconds: 60 + # 自动归档审核人(报告发起人 username 为空时的兜底) + check-person: admin + # 自动归档巡视结论 + task-result: 巡检完成,自动归档 + # 任务状态更新事务提交等待时间(毫秒) + after-commit-delay-millis: 2000 + # 归档防重锁过期时间(秒) + lock-expire-seconds: 86400 + # 上传报告的场站ID(为空时取 basedata_station.station_no) + station-id: "" + # 上传报告的巡检类型(风机巡检/光伏巡检/线路巡检/山火巡检/机器人巡检;为空时按任务预置位类型推断) + inspect-type: "" + # 上传报告的巡检设备名称(机库名称、机器人名称、摄像头名称,可选) + inspect-device-name: "" + # 报告文件(SFTP)下载重试次数/间隔(毫秒) + download-retry-times: 10 + download-retry-interval-millis: 1000 + # 上传失败 MQ 重投递最大次数(超过后丢弃,避免无限重试) + mq-max-retries: 3 \ No newline at end of file diff --git a/inspect-main/inspect-main-task/pom.xml b/inspect-main/inspect-main-task/pom.xml index a2f28dd..1a32223 100644 --- a/inspect-main/inspect-main-task/pom.xml +++ b/inspect-main/inspect-main-task/pom.xml @@ -13,6 +13,11 @@ inspect-main-task 3.4.0 + + UTF-8 + UTF-8 + + org.springframework.boot @@ -63,10 +68,13 @@ + org.apache.poi poi-ooxml - 5.2.3 + 4.1.2 org.redisson @@ -79,5 +87,11 @@ metadata-extractor 2.18.0 + + + com.inspect + inspect-main-api + 3.4.0 + \ No newline at end of file diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/insreport/domain/InspectionReport.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/insreport/domain/InspectionReport.java index 2ac2ce5..053aa21 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/insreport/domain/InspectionReport.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/insreport/domain/InspectionReport.java @@ -92,6 +92,8 @@ public class InspectionReport extends BaseEntity { private String filePath; private String taskId; private String taskResultId; + /** 位置(省份 + 地市拼接,如 四川省成都市) */ + private String location; private String areaName; private String areaType; private String deviceType; @@ -118,12 +120,12 @@ public class InspectionReport extends BaseEntity { if (this == object) return true; if (object == null || getClass() != object.getClass()) return false; InspectionReport that = (InspectionReport) object; - return Objects.equals(lineId, that.lineId) && Objects.equals(stationName, that.stationName) && Objects.equals(voltLevel, that.voltLevel) && Objects.equals(stationType, that.stationType) && Objects.equals(inspectionDate, that.inspectionDate) && Objects.equals(inspectionTaskName, that.inspectionTaskName) && Objects.equals(envInfo, that.envInfo) && Objects.equals(inspectionStartTime, that.inspectionStartTime) && Objects.equals(inspectionEndTime, that.inspectionEndTime) && Objects.equals(patrolStatistics, that.patrolStatistics) && Objects.equals(checkPerson, that.checkPerson) && Objects.equals(checkTime, that.checkTime) && Objects.equals(description, that.description) && Objects.equals(filePath, that.filePath) && Objects.equals(taskId, that.taskId) && Objects.equals(taskResultId, that.taskResultId) && Objects.equals(areaName, that.areaName) && Objects.equals(areaType, that.areaType) && Objects.equals(deviceType, that.deviceType) && Objects.equals(type, that.type) && Objects.equals(cycleStartTime, that.cycleStartTime) && Objects.equals(cycleEndTime, that.cycleEndTime) && Objects.equals(taskPatrolledId, that.taskPatrolledId) && Objects.equals(ReportDatalist, that.ReportDatalist) && Objects.equals(info, that.info); + return Objects.equals(lineId, that.lineId) && Objects.equals(stationName, that.stationName) && Objects.equals(voltLevel, that.voltLevel) && Objects.equals(stationType, that.stationType) && Objects.equals(inspectionDate, that.inspectionDate) && Objects.equals(inspectionTaskName, that.inspectionTaskName) && Objects.equals(envInfo, that.envInfo) && Objects.equals(inspectionStartTime, that.inspectionStartTime) && Objects.equals(inspectionEndTime, that.inspectionEndTime) && Objects.equals(patrolStatistics, that.patrolStatistics) && Objects.equals(checkPerson, that.checkPerson) && Objects.equals(checkTime, that.checkTime) && Objects.equals(description, that.description) && Objects.equals(filePath, that.filePath) && Objects.equals(taskId, that.taskId) && Objects.equals(taskResultId, that.taskResultId) && Objects.equals(location, that.location) && Objects.equals(areaName, that.areaName) && Objects.equals(areaType, that.areaType) && Objects.equals(deviceType, that.deviceType) && Objects.equals(type, that.type) && Objects.equals(cycleStartTime, that.cycleStartTime) && Objects.equals(cycleEndTime, that.cycleEndTime) && Objects.equals(taskPatrolledId, that.taskPatrolledId) && Objects.equals(ReportDatalist, that.ReportDatalist) && Objects.equals(info, that.info); } @Override public int hashCode() { - return Objects.hash(lineId, stationName, voltLevel, stationType, inspectionDate, inspectionTaskName, envInfo, inspectionStartTime, inspectionEndTime, patrolStatistics, checkPerson, checkTime, description, filePath, taskId, taskResultId, areaName, areaType, deviceType, type, cycleStartTime, cycleEndTime, taskPatrolledId, ReportDatalist, info); + return Objects.hash(lineId, stationName, voltLevel, stationType, inspectionDate, inspectionTaskName, envInfo, inspectionStartTime, inspectionEndTime, patrolStatistics, checkPerson, checkTime, description, filePath, taskId, taskResultId, location, areaName, areaType, deviceType, type, cycleStartTime, cycleEndTime, taskPatrolledId, ReportDatalist, info); } @Override @@ -145,6 +147,7 @@ public class InspectionReport extends BaseEntity { ", filePath='" + filePath + '\'' + ", taskId='" + taskId + '\'' + ", taskResultId='" + taskResultId + '\'' + + ", location='" + location + '\'' + ", areaName='" + areaName + '\'' + ", areaType='" + areaType + '\'' + ", deviceType='" + deviceType + '\'' + diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/impl/PatrolResultServiceImpl.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/impl/PatrolResultServiceImpl.java index fa697b2..fccc5b5 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/impl/PatrolResultServiceImpl.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/impl/PatrolResultServiceImpl.java @@ -528,6 +528,8 @@ public class PatrolResultServiceImpl implements IPatrolResultService { String stationName = stationMap.get("station_name"); String stationType = stationMap.get("station_type"); String voltLevel = stationMap.get("volt_level"); + String cityName = stationMap.get("city_name"); // 地市 + String provinceName = stationMap.get("province_name"); // 省份 startTime = PrintUtil.useTime("导出报告:查询站点信息", startTime); AjaxResult ajaxResult = feignBasedataAreaService.evnList(); @@ -778,6 +780,8 @@ public class PatrolResultServiceImpl implements IPatrolResultService { report.setVoltLevel(StringUtils.isEmpty(voltLevel) ? voltage : voltLevel); report.setFilter("0"); report.setStationType(stationType); + // 位置 = 省份 + 地市(如 四川省成都市) + report.setLocation(buildLocation(provinceName, cityName)); inspectionReportMapper.insertInspectionReport(report); // 为每个报告生成数据 batchInsertReportData_shaoxing(String.valueOf(report.getLineId()), patrolResultRefs, basePointAreaInfoList, lineIds); @@ -791,6 +795,18 @@ public class PatrolResultServiceImpl implements IPatrolResultService { return reportIds; } + /** 拼接报告位置:省份 + 地市(如 四川省成都市;缺省时只显示存在的部分) */ + private String buildLocation(String provinceName, String cityName) { + StringBuilder sb = new StringBuilder(); + if (StringUtils.isNotEmpty(provinceName)) { + sb.append(provinceName); + } + if (StringUtils.isNotEmpty(cityName)) { + sb.append(cityName); + } + return sb.toString(); + } + @Transactional public List saveReportV2(PatrolTaskResultMain resultMain, List resultList) { long startTime = System.currentTimeMillis(); diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportfont/ReportFontEmbedder.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportfont/ReportFontEmbedder.java new file mode 100644 index 0000000..bc6034b --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportfont/ReportFontEmbedder.java @@ -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 字体嵌入。 + *

+ * 原理(Word 的"嵌入字体"机制,让查看方电脑无需安装字体): + *

    + *
  1. docx 包内的字体"注册表"是 {@code word/fontTable.xml}:按字体名(w:name)列出字体, + * 每个条目通过 {@code embedRegular/embedBold} 属性(r:id)引用一个字体部件;
  2. + *
  3. 字体部件({@code word/fonts/*.odttf})是混淆后的字体数据——按 OOXML §17.8.1, + * 字体前 32 字节与混淆 key 异或,key 由条目中的 {@code fontKey}(GUID)推导; + * Word 打开文档时按同样算法还原后使用;
  4. + *
  5. 正文 run 只写字体名(w:eastAsia),Word 按名字查 fontTable 命中嵌入字体。
  6. + *
+ * 实现要点: + * + */ +@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 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 }、{@code }、 + * {@code }。 + *

+ * 注意: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(); + } +} diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/config/ReportPushProperties.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/config/ReportPushProperties.java index 2198d18..c250944 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/config/ReportPushProperties.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/config/ReportPushProperties.java @@ -20,38 +20,24 @@ public class ReportPushProperties { /** 是否启用自动归档 + 推送 */ private boolean enabled = true; -// /** 外部平台网关基础路径(/ext_auth/getAuthToken 获取 Joinbright-Token) */ -// private String baseUrl; -// -// /** 外部平台 websocket 地址 */ -// private String wsUrl; -// -// /** 认证凭据(现场提供) */ -// private String clientId; -// -// private String clientSecret; -// -// /** token 缓存时长(秒) */ -// private long tokenCacheSeconds = 7200; -// -// /** websocket 连接超时(秒) */ -// private int connectTimeoutSeconds = 10; -// -// /** 发送后等待时间(毫秒) */ -// private long sendWaitMillis = 500; - /** 自动归档审核人 */ private String checkPerson = "admin"; /** 自动归档巡视结论 */ private String taskResult = "巡检完成,自动归档"; - /** 无缺陷数据时是否仍归档(false: 与手动归档一致,无缺陷不归档) */ - private boolean archiveWithoutDefect = false; - /** 任务状态更新事务提交等待时间(毫秒) */ private long afterCommitDelayMillis = 2000; /** 归档防重锁过期时间(秒) */ private long lockExpireSeconds = 86400; + + /** 上传报告的场站ID(为空时取 basedata_station.station_no) */ + private String stationId = ""; + + /** 上传报告的巡检类型(风机巡检/光伏巡检/线路巡检/山火巡检/机器人巡检) */ + private String inspectType = "动环智能巡检"; + + /** 上传报告的巡检设备名称(机库名称、机器人名称、摄像头名称,可选) */ + private String inspectDeviceName = ""; } diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/controller/ReportPushController.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/controller/ReportPushController.java new file mode 100644 index 0000000..6cd7215 --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/controller/ReportPushController.java @@ -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; + +/** + * 报告推送调试接口(对接外部平台-盐源新能源智慧感知平台《巡检报告接口文档》) + *

    + *
  • GET /reportpush/getToken 获取 Joinbright-Token(复用 ExtAuthService,验证认证连通性)
  • + *
  • GET /reportpush/test 上传一条测试报告(验证 /uploadReport 上传接口连通性)
  • + *
+ */ +@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("上传失败, 请查看日志"); + } +} diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportAutoArchiveService.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportAutoArchiveService.java index ae48e7e..b58b19e 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportAutoArchiveService.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportAutoArchiveService.java @@ -141,13 +141,6 @@ public class ReportAutoArchiveService { } } - // 无缺陷不归档(与手动归档一致) -// if (!properties.isArchiveWithoutDefect() && !hasDefect(taskPatrolledIds)) { -// log.info("[AUTO_ARCHIVE] 任务 {} 无缺陷数据,不归档(archive-without-defect=false)", archiveTaskPatrolledId); -// releaseLock(lockKey); -// return; -// } - // 触发归档(动环 Word 报告),报告生成后由 exportDonghuanReport 回调推送 PatrolTaskResultMain resultMain = new PatrolTaskResultMain(); resultMain.setTaskPatrolledIds(taskPatrolledIds); diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportPushService.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportPushService.java index ce4ea65..75d741e 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportPushService.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportPushService.java @@ -1,31 +1,28 @@ package com.inspect.reportpush.service; import com.alibaba.fastjson.JSONObject; -import com.inspect.api.domain.AlarmMessage; -import com.inspect.api.enums.MessageType; -import com.inspect.api.enums.SystemCode; -import com.inspect.api.service.AlarmPublisher; +import com.inspect.api.domain.ReportPushMessage; +import com.inspect.api.service.ReportPushPublisher; +import com.inspect.base.core.utils.DateUtils; +import com.inspect.base.core.utils.StringUtils; import com.inspect.insreport.domain.InspectionReport; -import com.inspect.insreportdata.domain.InspectionReportData; -import com.inspect.message.MessageUtils; +import com.inspect.partrolresult.mapper.PatrolResultMapper; import com.inspect.reportpush.config.ReportPushProperties; +import com.inspect.task.mapper.PatrolTaskMapper; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import org.springframework.util.CollectionUtils; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.UUID; +import java.util.Date; +import java.util.Map; /** - * 巡检报告 websocket 推送 + * 巡检报告推送服务(先上报 RabbitMQ,由 inspect-main-api 的 + * {@link com.inspect.api.service.ReportUploadConsumer} 异步 HTTP 上传到外部平台, + * 见《盐源新能源智慧感知平台_巡检报告接口文档 v1.0.0》)。 *

- * 报告归档(Word)生成完毕后,组装 report_push 消息(沿用 AlarmMessage 结构), - * 通过 {@link AlarmPublisher} 发布到 alarm.exchange, - * 由 inspect-main-api 的 AlarmConsumer → {@code AlarmWsConnection} 推送到外部平台 - * (ws://…/jb-base-socket?Joinbright-Token=…)。 + * 报告归档(Word)生成完毕后,组装 reportInfo 并发布 report.upload.queue 消息, + * 消费者下载报告 docx 后 POST /jb-nessp-external/api/uploadReport 上传 + * (multipart/form-data:file + reportInfo,请求头携带 joinbright-token)。 * * @author inspect */ @@ -33,20 +30,19 @@ import java.util.UUID; @Service public class ReportPushService { - private static final DateTimeFormatter TIME_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - private final ReportPushProperties properties; - private final AlarmPublisher alarmPublisher; - private final MessageUtils messageUtils; + private final ReportPushPublisher reportPushPublisher; + private final PatrolTaskMapper patrolTaskMapper; + private final PatrolResultMapper patrolResultMapper; - @Autowired public ReportPushService(ReportPushProperties properties, - AlarmPublisher alarmPublisher, - MessageUtils messageUtils) { + ReportPushPublisher reportPushPublisher, + PatrolTaskMapper patrolTaskMapper, + PatrolResultMapper patrolResultMapper) { this.properties = properties; - this.alarmPublisher = alarmPublisher; - this.messageUtils = messageUtils; + this.reportPushPublisher = reportPushPublisher; + this.patrolTaskMapper = patrolTaskMapper; + this.patrolResultMapper = patrolResultMapper; } /** @@ -55,6 +51,16 @@ public class ReportPushService { * @param report 已保存 filePath 的巡检报告 */ public void pushReport(InspectionReport report) { + pushReport(report, null); + } + + /** + * 报告生成并上传完成后推送(带发起人)。 + * + * @param report 已保存 filePath 的巡检报告 + * @param username 报告发起人(当前登录账号,可为空) + */ + public void pushReport(InspectionReport report, String username) { if (report == null) { return; } @@ -62,69 +68,81 @@ public class ReportPushService { log.info("[REPORT_PUSH] 未启用 report-push 配置,跳过报告推送: reportId={}", report.getLineId()); return; } + // 报告文件路径(动环导出保存的是 .docx,兼容 .xls 结尾路径) + String filePath = report.getFilePath(); + if (StringUtils.isEmpty(filePath)) { + log.warn("[REPORT_PUSH] 报告文件路径为空, 无法推送: reportId={}", report.getLineId()); + return; + } + String fileName = filePath.substring(filePath.lastIndexOf('/') + 1); + // 报告元数据(《盐源新能源智慧感知平台_巡检报告接口文档_v1.0.0(2)》reportInfo 字段,发布时组装) + JSONObject reportInfo = new JSONObject(); + reportInfo.put("id", report.getLineId() == null + ? String.valueOf(System.currentTimeMillis()) : String.valueOf(report.getLineId())); + reportInfo.put("name", StringUtils.isNotEmpty(fileName) + ? fileName : "巡检报告"); + reportInfo.put("fileType", "docx"); + reportInfo.put("stationId", resolveStationId()); + reportInfo.put("stationName", resolveStationName(report)); + reportInfo.put("inspectTime", report.getInspectionStartTime() == null ? null + : DateUtils.parseDateToStr(DateUtils.yyyyMMddHHmmss2, report.getInspectionStartTime())); + reportInfo.put("createTime", DateUtils.parseDateToStr(DateUtils.yyyyMMddHHmmss2, new Date())); + reportInfo.put("inspectType", resolveInspectType()); + reportInfo.put("inspectDeviceName", properties.getInspectDeviceName()); + reportInfo.put("inspectContent", StringUtils.isNotEmpty(report.getPatrolStatistics()) + ? report.getPatrolStatistics() : report.getInspectionTaskName()); + reportInfo.put("taskId", StringUtils.isNotEmpty(report.getTaskId()) ? report.getTaskId() : ""); + reportInfo.put("taskName", report.getInspectionTaskName()); + // 报告发起人:归档入口捕获的当前登录账号,为空时取配置审核人 + reportInfo.put("username", StringUtils.isNotEmpty(username) ? username : properties.getCheckPerson()); + + ReportPushMessage message = new ReportPushMessage(); + message.setReportId(report.getLineId()); + message.setFilePath(filePath); + message.setReportInfo(reportInfo.toJSONString()); + reportPushPublisher.publish(message); + log.info("[REPORT_PUSH] 报告推送消息已发布: reportId={}, filePath={}, username={}", + report.getLineId(), filePath, username); + } + + /** + * 场站ID:配置优先,其次取 basedata_station.station_no + */ + private String resolveStationId() { + if (StringUtils.isNotEmpty(properties.getStationId())) { + return properties.getStationId(); + } try { - AlarmMessage message = AlarmMessage.builder() - .messageId(UUID.randomUUID().toString().replace("-", "")) - .messageType(MessageType.REPORT.getCode()) - .timestamp(LocalDateTime.now().format(TIME_FORMATTER)) - .systemCode(SystemCode.PATROL.getCode()) - .data(buildReportData(report)) - .build(); - alarmPublisher.publish(message); - log.info("[REPORT_PUSH] 报告推送消息已发布: reportId={}, taskName={}, filePath={}", - report.getLineId(), report.getInspectionTaskName(), report.getFilePath()); + String stationNo = patrolTaskMapper.selectStationNo(); + return stationNo == null ? "" : stationNo; } catch (Exception e) { - log.error("[REPORT_PUSH] 报告推送失败: reportId={}", report.getLineId(), e); + log.warn("[REPORT_PUSH] 查询场站编号失败: {}", e.getMessage()); + return ""; } } /** - * 组装推送 data 载荷:报告基本信息 + 状态统计 + * 电站名称:报告记录优先,其次 basedata_station.station_name */ - private JSONObject buildReportData(InspectionReport report) { - JSONObject data = new JSONObject(); - data.put("reportId", report.getLineId()); - data.put("reportName", report.getInspectionTaskName()); - data.put("filePath", report.getFilePath()); - data.put("taskId", report.getTaskId()); - data.put("taskPatrolledId", report.getTaskPatrolledId()); - data.put("stationName", report.getStationName()); - data.put("areaName", report.getAreaName()); - data.put("checkPerson", report.getCheckPerson()); - data.put("description", report.getDescription()); - if (report.getInspectionStartTime() != null) { - data.put("inspectionStartTime", TIME_FORMATTER.format( - LocalDateTime.ofInstant(report.getInspectionStartTime().toInstant(), - java.time.ZoneId.systemDefault()))); + private String resolveStationName(InspectionReport report) { + if (report != null && StringUtils.isNotEmpty(report.getStationName())) { + return report.getStationName(); } - if (report.getInspectionEndTime() != null) { - data.put("inspectionEndTime", TIME_FORMATTER.format( - LocalDateTime.ofInstant(report.getInspectionEndTime().toInstant(), - java.time.ZoneId.systemDefault()))); - } - // 状态统计 - String defect = messageUtils.get("缺陷"); - String abnormal = messageUtils.get("异常"); - String normal = messageUtils.get("正常"); - int defectCount = 0, abnormalCount = 0, normalCount = 0, total = 0; - List dataList = report.getReportDatalist(); - if (!CollectionUtils.isEmpty(dataList)) { - for (InspectionReportData item : dataList) { - total++; - String status = item.getPointStatus(); - if (defect.equals(status)) { - defectCount++; - } else if (abnormal.equals(status)) { - abnormalCount++; - } else if (normal.equals(status)) { - normalCount++; - } + try { + Map stationMap = patrolResultMapper.selectBasedataStation(); + if (stationMap != null && stationMap.get("station_name") != null) { + return stationMap.get("station_name"); } + } catch (Exception e) { + log.warn("[REPORT_PUSH] 查询电站名称失败: {}", e.getMessage()); } - data.put("totalCount", total); - data.put("defectCount", defectCount); - data.put("abnormalCount", abnormalCount); - data.put("normalCount", normalCount); - return data; + return ""; + } + + /** + * 巡检类型:配置优先,默认动环智能巡检 + */ + private String resolveInspectType() { + return StringUtils.isNotEmpty(properties.getInspectType()) ? properties.getInspectType() : "动环智能巡检"; } } diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java index c7ac2c5..96786bd 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java @@ -6,6 +6,7 @@ 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 com.inspect.common.security.utils.SecurityUtils; import com.inspect.img.domain.InspectionReportImg; import com.inspect.img.service.IInspectionReportImgService; import com.inspect.insreport.domain.InspectionReport; @@ -16,6 +17,7 @@ import com.inspect.message.MessageUtils; import com.inspect.partrolresult.domain.PatrolResult; import com.inspect.partrolresult.service.IPatrolResultService; import com.inspect.partrolresult.util.PrintUtil; +import com.inspect.reportfont.ReportFontEmbedder; import com.inspect.reportpush.service.ReportPushService; import com.inspect.resultmain.domain.PatrolTaskResultMain; import com.inspect.resultmain.service.IPatrolTaskResultMainService; @@ -122,6 +124,7 @@ public class DonghuanReportExportController extends BaseController { private final IInspectionReportService inspectionReportService; private final SftpClient sftpClient; private final ReportPushService reportPushService; + private final ReportFontEmbedder reportFontEmbedder; private static final ExecutorService executor = Executors.newFixedThreadPool(10); @@ -134,7 +137,8 @@ public class DonghuanReportExportController extends BaseController { IInspectionReportImgService inspectionReportImgService, IInspectionReportService inspectionReportService, SftpClient sftpClient, - ReportPushService reportPushService) { + ReportPushService reportPushService, + ReportFontEmbedder reportFontEmbedder) { this.patrolTaskResultMainService = patrolTaskResultMainService; this.patrolResultService = patrolResultService; this.inspectionReportDataService = inspectionReportDataService; @@ -142,6 +146,7 @@ public class DonghuanReportExportController extends BaseController { this.inspectionReportService = inspectionReportService; this.sftpClient = sftpClient; this.reportPushService = reportPushService; + this.reportFontEmbedder = reportFontEmbedder; } // ============================================================================ @@ -150,6 +155,9 @@ public class DonghuanReportExportController extends BaseController { @PostMapping({"/updateInfo_shaoxing"}) public AjaxResult updateInfo_donghuan(@RequestBody PatrolTaskResultMain resultMain) { long startTime = System.currentTimeMillis(); + // 报告发起人:当前登录账号 + final String username = SecurityUtils.getUsername(); + logger.info("[ARCHIVE] 动环归档触发, 发起人: {}", username); CompletableFuture> saveReportFuture = CompletableFuture.>supplyAsync(() -> { List taskPatrolledIds = resultMain.getTaskPatrolledIds(); logger.info("-----------patrolTaskResultMains taskPatrolledIds: {}", taskPatrolledIds); @@ -187,7 +195,7 @@ public class DonghuanReportExportController extends BaseController { logger.warn("No reports to export"); return; } - CompletableFuture.runAsync(() -> exportDonghuanReport(reportIds), executor) + CompletableFuture.runAsync(() -> exportDonghuanReport(reportIds, username), executor) .whenComplete((v, ex) -> { if (ex != null) { logger.error("Async export failed for reportIds: {}", reportIds, ex); @@ -203,7 +211,7 @@ public class DonghuanReportExportController extends BaseController { // ============================================================================ // 二、导出:动环 Word + 图片 ZIP // ============================================================================ - public void exportDonghuanReport(List reportIds) { + public void exportDonghuanReport(List reportIds, String username) { // 合并报告,所有报告内容一致,取第一个导出即可 Long lineId = reportIds.get(0); List tempFiles = new ArrayList<>(); @@ -365,8 +373,8 @@ public class DonghuanReportExportController extends BaseController { inspectionReport.setFilePath(basePath + reportName + ".docx"); inspectionReport.setLineIds(reportIds); inspectionReportService.updateInspectionReportFilePath(inspectionReport); - // 报告生成并上传完成后,通过 websocket 推送到外部平台(自动归档与手动归档均推送) - reportPushService.pushReport(inspectionReport); + // 报告生成并上传完成后,发布消息到 RabbitMQ,由消费者异步 HTTP 上传外部平台(自动归档与手动归档均推送) + reportPushService.pushReport(inspectionReport, username); } finally { closeResources(finalWordDocument, tempFiles); } @@ -383,6 +391,8 @@ public class DonghuanReportExportController extends BaseController { private XWPFDocument getXWPFDocumentDonghuan(InspectionReport report, Map tempFileMap) { XWPFDocument doc = new XWPFDocument(); + // 嵌入报告字体(仿宋/黑体/方正小标宋),docx 自带字体,查看方无需安装 + reportFontEmbedder.embed(doc); setupPage(doc); buildCoverPage1(doc); // 第1页:42pt 大标题 + 分页 buildCoverPage2(doc, report); // 第2页:logo + 报告标题 + 副标题 + 签署表 @@ -509,13 +519,12 @@ public class DonghuanReportExportController extends BaseController { COLOR_WHITE, true, ParagraphAlignment.CENTER, 0, 0, 9061, 4, 0, CELL_MAR, 0, CELL_MAR); - // R1 电站名称 | 白杨坪 | 位置 | 四川省/... - // TODO 归档时调整:位置字段来源(InspectionReport 无独立字段,暂用 areaName) + // R1 电站名称 | 白杨坪 | 位置 | 四川省成都市 setRow(t.getRow(1), ROW_H, false); bodyCell(t.getRow(1).getCell(0), "电站名称", 1827); bodyCell(t.getRow(1).getCell(1), val(report.getStationName()), 2655); bodyCell(t.getRow(1).getCell(2), "位置", 1920); - bodyCell(t.getRow(1).getCell(3), val(report.getAreaName()), 2659); + bodyCell(t.getRow(1).getCell(3), val(report.getLocation()), 2659); // ==================== R2:巡视任务基本信息(新增表头) ==================== setRow(t.getRow(2), ROW_H, false); @@ -790,6 +799,8 @@ public class DonghuanReportExportController extends BaseController { CTFonts fonts = rPr.isSetRFonts() ? rPr.getRFonts() : rPr.addNewRFonts(); fonts.setEastAsia(font); fonts.setCs(font); + // 注意:嵌入字体的引用写在 fontTable.xml(w:font w:name + w:embedRegular), + // run 这里只需写字体名,Word 打开时按名字查 fontTable 使用嵌入字体 } CTHpsMeasure sz = rPr.isSetSz() ? rPr.getSz() : rPr.addNewSz(); sz.setVal(BigInteger.valueOf(halfPoints)); diff --git a/inspect-main/inspect-main-task/src/main/resources/fonts/simfang.ttf b/inspect-main/inspect-main-task/src/main/resources/fonts/simfang.ttf new file mode 100644 index 0000000..68334e2 Binary files /dev/null and b/inspect-main/inspect-main-task/src/main/resources/fonts/simfang.ttf differ diff --git a/inspect-main/inspect-main-task/src/main/resources/fonts/simhei.ttf b/inspect-main/inspect-main-task/src/main/resources/fonts/simhei.ttf new file mode 100644 index 0000000..5bd4687 Binary files /dev/null and b/inspect-main/inspect-main-task/src/main/resources/fonts/simhei.ttf differ diff --git a/inspect-main/inspect-main-task/src/main/resources/fonts/方正小标宋简体.ttf b/inspect-main/inspect-main-task/src/main/resources/fonts/方正小标宋简体.ttf new file mode 100644 index 0000000..0161fcc Binary files /dev/null and b/inspect-main/inspect-main-task/src/main/resources/fonts/方正小标宋简体.ttf differ diff --git a/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportMapper.xml b/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportMapper.xml index 19fd2bf..5569dd8 100644 --- a/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportMapper.xml +++ b/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportMapper.xml @@ -23,6 +23,7 @@ + @@ -34,6 +35,7 @@ station_name, volt_level, station_type, + location, inspection_date, inspection_task_name, env_info, @@ -113,6 +115,7 @@ station_name, volt_level, station_type, + location, inspection_date, inspection_task_name, env_info, @@ -132,6 +135,7 @@ #{stationName}, #{voltLevel}, #{stationType}, + #{location}, #{inspectionDate}, #{inspectionTaskName}, #{envInfo}, @@ -155,6 +159,7 @@ station_name = #{stationName}, volt_level = #{voltLevel}, station_type = #{stationType}, + location = #{location}, inspection_date = #{inspectionDate}, inspection_task_name = #{inspectionTaskName}, env_info = #{envInfo}, diff --git a/inspect-main/inspect-main-task/src/main/resources/report/wordparts/fontTable.xml b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/fontTable.xml new file mode 100644 index 0000000..637d657 --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/fontTable.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inspect-main/inspect-main-task/src/main/resources/report/wordparts/styles.xml b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/styles.xml new file mode 100644 index 0000000..3eb86c3 --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/styles.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inspect-main/inspect-main-task/src/main/resources/report/wordparts/theme1.xml b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/theme1.xml new file mode 100644 index 0000000..67f3205 --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/theme1.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inspect-main/inspect-main-task/src/main/resources/report/wordparts/webSettings.xml b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/webSettings.xml new file mode 100644 index 0000000..b5d1b1e --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/webSettings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index dcbc265..1e4c753 100644 --- a/pom.xml +++ b/pom.xml @@ -390,6 +390,26 @@ + + + org.apache.maven.plugins + maven-resources-plugin + + + ttf + ttc + otf + png + jpg + jpeg + docx + doc + pdf + zip + + + \ No newline at end of file