| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
952d3b4290 | fix: 推送合并任务报告推送多个任务id用逗号拼接 | 2 days ago |
|
|
3db44ddabc | feat: 表计meter算法支持对读数“正常”的解析 | 2 days ago |
|
|
c25a8d0354 | fix: 修复报告上传消息无限重投递,超过 3 次直接丢弃 | 2 days ago |
|
|
d5b7c89d98 | Merge branch 'yanyuan' of http://git.ht-atia.cn/qinyanlei/inspect into yanyuan | 3 days ago |
|
|
8b7b8c340f | feat: 缺陷记录添加算法筛选,按算法名精确匹配 | 3 days ago |
|
|
5667eaea71 | feat: 新增分析流程完结字段completed用于数据上送 | 4 days ago |
|
|
ff5dbf20b0 | feat: 缺陷记录添加下拉筛选,按缺陷描述精确匹配 | 4 days ago |
|
|
2fde923bf6 | temp: 临时屏蔽 RequiresPermissions 注解 | 4 days ago |
|
|
86055b2281 | feat: 新增通道查询返回台账名称 | 6 days ago |
|
|
c7e0677b81 | feat: 新增接口用于明文用户密码获取token | 1 week ago |
|
|
f7d4b63c2e | Merge branch 'yanyuan' of http://git.ht-atia.cn/qinyanlei/inspect into yanyuan | 1 week ago |
|
|
40854cc240 | fix: 修复修改用户信息时会将用户密码同步修改 | 1 week ago |
|
|
166ccb8b2b | feat: 缺陷记录添加筛选条件 | 1 week ago |
|
|
b5ec64abe0 | feat: 点位数据分析完成时通过WS上送 | 1 week ago |
|
|
6313f95ded |
fix(redis): 按分钟执行的间隔任务不再生成窗口外的策略 key
上一提交(移除 KEYS 全库扫描)把"清理窗口外策略 key"的动作从"每个执行时刻一次"提到 "每个任务一次"。但按分钟执行的间隔任务,其 exeTimes 由 getExecTimesByMinute 生成到 次日 00:00,会超出任务配置的 intervalEndTime:原先每次存 key 前的清理会把窗口外的 key 删掉(除最后一次外),提取到循环外之后这些窗口外的 key 会全部留存,导致调度器在配置 窗口之外仍然执行任务。 本提交在按分钟执行的循环内补上守卫:晚于 intervalEndTime 的时刻直接跳过,既不清理也不 生成策略 key,与"只保留 finalDate 之内的策略 key"的原意保持一致(按小时执行的 exeTimes 本来就以 intervalEndTime 为界,不受影响)。 |
2 weeks ago |
|
|
de77ada07a |
fix(redis): 生产路径禁止使用 KEYS,改用 SCAN,修复 Redis 阻塞导致巡检服务不可用
现象:巡检服务(dliip-patrol)容器连续约 9 小时 unhealthy;Tomcat 200 个请求线程 全部阻塞在 Lettuce(AsyncCommand.await 无超时,永不失败),容器 CPU 在 2%~500% 间跳变, 健康检查(curl 127.0.0.1:9901,10s 超时)连续 284 次超时。 根因:inspect-job 定时调用 /exec/makeCurrentDayTask,该接口遍历所有启用任务,对每个 "间隔执行"任务的每个执行时刻调用一次 parseTaskToRedis,而 parseTaskToRedis 内部用 redisService.keys(TASK_CODE@taskCode@*) 做全库扫描来清理策略 key。于是一次调用会对 Redis 发起成百上千次 KEYS:Redis 单线程,单次 KEYS 约 13ms 且返回体巨大(实测输出 8MB/s,单个客户端连接积压 321 条回复 / 5.8MB 输出缓冲),KEYS 执行期间其它所有客户端 的命令全部排队,实测 Redis 响应延迟 avg 287ms / max 1365ms,进而导致上游业务请求堆积、 Tomcat 线程池被占满、健康检查排不上队而超时。 修复: 1. RedisService 新增 scan(pattern[, count]),基于 SCAN 游标分批读取,不阻塞其它客户端; 原 keys() 标记 @Deprecated 并注明生产环境禁用。 2. 全仓库 11 处 redisService.keys() 调用点改为 scan()。 3. PatrolTaskExecController:抽出 cleanTaskKeysAfter(),策略 key 清理由"每个执行时刻一次" 改为"每个任务一次",一次 makeCurrentDayTask 的 KEYS 次数从成百上千次降到与任务数同量级。 4. PatrolTaskController:批量删除任务时原为 N 个 taskId 扫描 N 次全库,改为只扫描一次。 影响面:仅改变"按 pattern 查找 key"的实现方式(KEYS→SCAN,语义等价;SCAN 可能重复返回 同一个 key,对删除/遍历场景无影响),未改动任何业务判定逻辑。 |
2 weeks ago |
|
|
0aef3b2e15 | feat: 取消数据查询权限限制 | 2 weeks ago |
|
|
f37f7bbecc | fix: 动环巡检报告目录调整 | 2 weeks ago |
|
|
fef4796256 | fix: 动环巡检报告排版对齐机器人巡检报告模板 | 2 weeks ago |
|
|
8a19df5709 | refactor: 优化盐源ws推送,支持任意消息格式 | 2 weeks ago |
|
|
aa54054e76 | feat: 临时屏蔽接口所需权限 | 2 weeks ago |
|
|
c81de07f7a | fix:巡视报告格式,字体修改 | 2 weeks ago |
|
|
443d30bf02 | fix:动环报告合并及归档推送逻辑修改,新增了inspection_report_task表 | 3 weeks ago |
|
|
a2649203c3 | fix:报告推送任务id修改,新增自动归档开关与推送开关 | 3 weeks ago |
|
|
b88c984f1c | feat:雨刮和框选3D定位 | 3 weeks ago |
|
|
c10550aaef | Merge branch 'yanyuan' of https://git.ht-atia.cn/qinyanlei/inspect into yanyuan | 3 weeks ago |
|
|
5ac0de509c | fix: 修复因计划任务过多判断,导致任务错过下发时间未执行 | 3 weeks ago |
|
|
e8a501d488 | feat:巡视报告标题字体黑体 4号,标题段落首行缩进1字符 | 3 weeks ago |
|
|
d07dda28b5 | feat:缺陷汇总接口,相机通道接口 | 3 weeks ago |
|
|
c87a45671c | feat:盐源新版巡视报告字体嵌入,及http推送外部接口 | 4 weeks ago |
|
|
57e78d5c3f | feat:盐源新版巡视报告,及自动归档,websocket推送 | 1 month ago |
|
|
7bc91a250c | feat: 新增getToken接口,用于第三方调用接口鉴权 | 1 month ago |
|
|
6aaae83f4f | feat: 新增盐源ws接口(通过AlarmPublisher发送ws) | 1 month ago |
|
|
91706e3913 | fix:巡视报告异常捕获 | 1 month ago |
|
|
57c6724f4d | feat:视频预览,预置位操作,云台控制,抓拍,录像下载 | 1 month ago |
|
|
c9a56d778a | feat:1.历史回放接口,2.台账接口修改 | 1 month ago |
|
|
7403bcd61f | 第一次提交:1.新增inspect-main-api模块,用于开发盐源新特性接口;2.启动全局异常捕获器,对服务异常统一封装 | 2 months ago |
| @ -0,0 +1,20 @@ | |||
| package com.inspect.base.core.enums; | |||
| import lombok.Getter; | |||
| @Getter | |||
| public enum DefectLevel { | |||
| // 0=无, 1=紧急, 2=严重, 3=一般 | |||
| NONE(0, "无"), | |||
| URGENT(1, ""), | |||
| SERIOUS(2, ""), | |||
| NORMAL(3, ""); | |||
| private final Integer code; | |||
| private final String info; | |||
| DefectLevel(Integer code, String info) { | |||
| this.code = code; | |||
| this.info = info; | |||
| } | |||
| } | |||
| @ -0,0 +1,102 @@ | |||
| package com.inspect.ivs.service; | |||
| import com.alibaba.fastjson.JSONObject; | |||
| import com.inspect.ivs.util.WebClientUtils; | |||
| import org.slf4j.Logger; | |||
| import org.slf4j.LoggerFactory; | |||
| import org.springframework.beans.factory.annotation.Value; | |||
| import org.springframework.http.HttpStatus; | |||
| import org.springframework.http.MediaType; | |||
| import org.springframework.http.ResponseEntity; | |||
| import org.springframework.stereotype.Component; | |||
| import org.springframework.web.reactive.function.client.WebClient; | |||
| import org.springframework.web.reactive.function.client.WebClientException; | |||
| @Component | |||
| public class HlsPlaybackComponent { | |||
| private static final Logger log = LoggerFactory.getLogger(HlsPlaybackComponent.class); | |||
| @Value("${ivs.nvr.hls.url:http://192.168.1.86:28081}") | |||
| private String url; | |||
| private final WebClient webClient = WebClientUtils.create(); | |||
| public ResponseEntity<JSONObject> get(String uri) { | |||
| log.info("[NVR HLS] GET url: {}", url.concat(uri)); | |||
| return exchange(webClient.get().uri(url.concat(uri))); | |||
| } | |||
| public ResponseEntity<JSONObject> post(String uri, Object body) { | |||
| log.info("[NVR HLS] POST url: {}, body: {}", url.concat(uri), JSONObject.toJSONString(body)); | |||
| return exchange(webClient.post().uri(url.concat(uri)) | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .bodyValue(body)); | |||
| } | |||
| public ResponseEntity<JSONObject> delete(String uri) { | |||
| log.info("[NVR HLS] DELETE url: {}", url.concat(uri)); | |||
| return exchange(webClient.delete().uri(url.concat(uri))); | |||
| } | |||
| public ResponseEntity<byte[]> getBinary(String uri) { | |||
| log.info("[NVR HLS] GET binary url: {}", url.concat(uri)); | |||
| return exchangeBinary(webClient.get().uri(url.concat(uri))); | |||
| } | |||
| public ResponseEntity<byte[]> postBinary(String uri, Object body) { | |||
| log.info("[NVR HLS] POST binary url: {}, body: {}", url.concat(uri), JSONObject.toJSONString(body)); | |||
| return exchangeBinary(webClient.post().uri(url.concat(uri)) | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .bodyValue(body)); | |||
| } | |||
| private ResponseEntity<JSONObject> exchange(WebClient.RequestHeadersSpec<?> spec) { | |||
| assert webClient != null; | |||
| try { | |||
| return spec.exchangeToMono(response -> | |||
| response.bodyToMono(String.class) | |||
| .defaultIfEmpty("") | |||
| .map(body -> { | |||
| int status = response.statusCode().value(); | |||
| ResponseEntity<JSONObject> entity; | |||
| if (body == null || body.trim().isEmpty()) { | |||
| entity = new ResponseEntity<>((JSONObject) null, HttpStatus.valueOf(status)); | |||
| } else { | |||
| entity = ResponseEntity.status(status) | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .body(JSONObject.parseObject(body)); | |||
| } | |||
| return entity; | |||
| })) | |||
| .block(); | |||
| } catch (WebClientException e) { | |||
| log.error("[NVR HLS] request failed, url: {}", url, e); | |||
| JSONObject error = new JSONObject(); | |||
| error.put("code", HttpStatus.BAD_GATEWAY.value()); | |||
| error.put("message", "NVR HLS服务不可用"); | |||
| return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(error); | |||
| } | |||
| } | |||
| private ResponseEntity<byte[]> exchangeBinary(WebClient.RequestHeadersSpec<?> spec) { | |||
| assert webClient != null; | |||
| try { | |||
| return spec.exchangeToMono(response -> | |||
| response.bodyToMono(byte[].class) | |||
| .defaultIfEmpty(new byte[0]) | |||
| .map(bytes -> { | |||
| MediaType mediaType = response.headers().contentType().orElse(MediaType.APPLICATION_OCTET_STREAM); | |||
| return ResponseEntity.status(response.statusCode().value()) | |||
| .contentType(mediaType) | |||
| .body(bytes); | |||
| })) | |||
| .block(); | |||
| } catch (WebClientException e) { | |||
| log.error("[NVR HLS] request failed, url: {}", url, e); | |||
| return ResponseEntity.status(HttpStatus.BAD_GATEWAY) | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .body(new byte[0]); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,26 @@ | |||
| package com.inspect.job.client; | |||
| import com.inspect.base.core.web.domain.AjaxResult; | |||
| import org.springframework.cloud.openfeign.FeignClient; | |||
| import org.springframework.web.bind.annotation.PostMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| /** | |||
| * 巡检报告定时归档客户端(调用 inspect-main 的动环归档按时间窗口接口)。 | |||
| * | |||
| * @author inspect | |||
| */ | |||
| @FeignClient(name = "inspect-main", contextId = "inspect-report-archive") | |||
| public interface ReportArchiveClient { | |||
| /** | |||
| * 归档 [startTime, endTime) 窗口内“已完成且未归档”的巡检执行: | |||
| * 合并生成一份动环报告,报告生成上传后自动推送外部平台;无待归档任务时不生成不推送。 | |||
| * | |||
| * @param startTime 窗口开始时间 yyyy-MM-dd HH:mm:ss(含) | |||
| * @param endTime 窗口结束时间 yyyy-MM-dd HH:mm:ss(不含) | |||
| */ | |||
| @PostMapping({"/resultmain/archiveByTime"}) | |||
| AjaxResult archiveByTime(@RequestParam("startTime") String startTime, | |||
| @RequestParam("endTime") String endTime); | |||
| } | |||
| @ -0,0 +1,81 @@ | |||
| package com.inspect.job.task; | |||
| import com.inspect.base.core.web.domain.AjaxResult; | |||
| import com.inspect.job.client.ReportArchiveClient; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| import org.springframework.stereotype.Component; | |||
| import java.time.LocalDate; | |||
| import java.time.LocalDateTime; | |||
| import java.time.LocalTime; | |||
| import java.time.format.DateTimeFormatter; | |||
| /** | |||
| * 巡检报告定时归档任务(sys_job 在数据库灵活控制)。 | |||
| * <p> | |||
| * 由 sql/sys_job_report_archive.sql 预置两条 sys_job 调用: | |||
| * <ul> | |||
| * <li>archiveMorning:每日 07:00,归档 前一日16:00 ~ 当日07:00 数据并推送平台;</li> | |||
| * <li>archiveEvening:每日 16:00,归档 当日07:00 ~ 当日16:00 数据并推送平台。</li> | |||
| * </ul> | |||
| * 窗口截止点(07:00 / 16:00)即为报告批次语义,若后续确认新的生成时间点, | |||
| * 需同时调整 sys_job 的 cron_expression 与下方 {@link #MORNING_END}/{@link #EVENING_END} 截止点。 | |||
| * | |||
| * @author inspect | |||
| */ | |||
| @Slf4j | |||
| @Component("reportArchiveScheduleTask") | |||
| public class ReportArchiveScheduleTask { | |||
| /** 数据窗口截止时间:早晨批次 = 当日 07:00 */ | |||
| private static final LocalTime MORNING_END = LocalTime.of(7, 0, 0); | |||
| /** 数据窗口截止时间:下午批次 = 当日 16:00 */ | |||
| private static final LocalTime EVENING_END = LocalTime.of(16, 0, 0); | |||
| private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); | |||
| private final ReportArchiveClient reportArchiveClient; | |||
| public ReportArchiveScheduleTask(ReportArchiveClient reportArchiveClient) { | |||
| this.reportArchiveClient = reportArchiveClient; | |||
| } | |||
| /** | |||
| * sys_job:每日 07:00 归档 前一日16:00 ~ 当日07:00 的巡检数据。 | |||
| */ | |||
| public void archiveMorning() { | |||
| LocalDate today = LocalDate.now(); | |||
| // 前一日 16:00 | |||
| LocalDateTime start = LocalDateTime.of(today.minusDays(1), EVENING_END); | |||
| // 当日 07:00 | |||
| LocalDateTime end = LocalDateTime.of(today, MORNING_END); | |||
| archiveByWindow("早晨批次(前一日16:00~当日07:00)", start, end); | |||
| } | |||
| /** | |||
| * sys_job:每日 16:00 归档 当日07:00 ~ 当日16:00 的巡检数据。 | |||
| */ | |||
| public void archiveEvening() { | |||
| LocalDate today = LocalDate.now(); | |||
| LocalDateTime start = LocalDateTime.of(today, MORNING_END); | |||
| LocalDateTime end = LocalDateTime.of(today, EVENING_END); | |||
| archiveByWindow("下午批次(当日07:00~当日16:00)", start, end); | |||
| } | |||
| /** | |||
| * 按窗口触发 inspect-main 定时归档;无待归档任务时主服务会跳过(不生成不推送)。 | |||
| * 调用失败直接抛出异常,由 sys_job_log 记录失败。 | |||
| */ | |||
| private void archiveByWindow(String batchName, LocalDateTime start, LocalDateTime end) { | |||
| String startTime = start.format(DATE_TIME); | |||
| String endTime = end.format(DATE_TIME); | |||
| log.info("[定时归档] {} 开始, 数据窗口: {} ~ {}", batchName, startTime, endTime); | |||
| AjaxResult result = reportArchiveClient.archiveByTime(startTime, endTime); | |||
| Object code = result == null ? null : result.get(AjaxResult.CODE_TAG); | |||
| Object msg = result == null ? null : result.get(AjaxResult.MSG_TAG); | |||
| log.info("[定时归档] {} 完成, code={}, msg={}", batchName, code, msg); | |||
| if (result == null || !(code instanceof Number) || ((Number) code).intValue() != 200) { | |||
| throw new RuntimeException("定时归档调用失败: " + (msg == null ? "无响应" : msg)); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,64 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |||
| xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | |||
| <modelVersion>4.0.0</modelVersion> | |||
| <parent> | |||
| <groupId>com.inspect</groupId> | |||
| <artifactId>inspect-main</artifactId> | |||
| <version>3.4.0</version> | |||
| </parent> | |||
| <artifactId>inspect-main-api</artifactId> | |||
| <version>3.4.0</version> | |||
| <name>inspect-main-api</name> | |||
| <description>inspect-main-api</description> | |||
| <properties> | |||
| <java.version>1.8</java.version> | |||
| <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | |||
| <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> | |||
| <spring-boot.version>2.6.13</spring-boot.version> | |||
| </properties> | |||
| <dependencies> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-test</artifactId> | |||
| <scope>test</scope> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-web</artifactId> | |||
| </dependency> | |||
| <!-- WebSocket + STOMP --> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-websocket</artifactId> | |||
| </dependency> | |||
| <!-- RabbitMQ --> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-amqp</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>com.inspect</groupId> | |||
| <artifactId>inspect-base-core</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>com.inspect</groupId> | |||
| <artifactId>inspect-base-redis</artifactId> | |||
| </dependency> | |||
| </dependencies> | |||
| <dependencyManagement> | |||
| <dependencies> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-dependencies</artifactId> | |||
| <version>2.6.6</version> | |||
| <type>pom</type> | |||
| <scope>import</scope> | |||
| </dependency> | |||
| </dependencies> | |||
| </dependencyManagement> | |||
| </project> | |||
| @ -0,0 +1,98 @@ | |||
| package com.inspect.api.config; | |||
| import com.inspect.api.constant.YanyuanConstants; | |||
| import org.springframework.amqp.core.Binding; | |||
| import org.springframework.amqp.core.BindingBuilder; | |||
| import org.springframework.amqp.core.Queue; | |||
| import org.springframework.amqp.core.QueueBuilder; | |||
| import org.springframework.amqp.core.TopicExchange; | |||
| import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; | |||
| import org.springframework.amqp.support.converter.MessageConverter; | |||
| import org.springframework.context.annotation.Bean; | |||
| import org.springframework.context.annotation.Configuration; | |||
| @Configuration | |||
| public class RabbitMQConfig { | |||
| /** 告警重试延迟(毫秒),重试队列消息的 TTL */ | |||
| private static final int ALARM_RETRY_TTL_MS = 30_000; | |||
| @Bean | |||
| public TopicExchange alarmExchange() { | |||
| return new TopicExchange(YanyuanConstants.EXCHANGE, true, false); | |||
| } | |||
| @Bean("alarmQueue") | |||
| public Queue alarmQueue() { | |||
| return new Queue(YanyuanConstants.QUEUE, true); | |||
| } | |||
| @Bean | |||
| public Binding alarmBinding() { | |||
| return BindingBuilder.bind(alarmQueue()).to(alarmExchange()).with(YanyuanConstants.ROUTING_KEY); | |||
| } | |||
| // ============================ 告警重试/死信 ============================ | |||
| @Bean | |||
| public TopicExchange alarmRetryExchange() { | |||
| return new TopicExchange(YanyuanConstants.ALARM_RETRY_EXCHANGE, true, false); | |||
| } | |||
| /** 重试队列:消息 TTL 到期后经死信交换机自动投回主队列 */ | |||
| @Bean("alarmRetryQueue") | |||
| public Queue alarmRetryQueue() { | |||
| return QueueBuilder.durable(YanyuanConstants.ALARM_RETRY_QUEUE) | |||
| .ttl(ALARM_RETRY_TTL_MS) | |||
| .deadLetterExchange(YanyuanConstants.EXCHANGE) | |||
| .deadLetterRoutingKey(YanyuanConstants.ROUTING_KEY) | |||
| .build(); | |||
| } | |||
| @Bean | |||
| public Binding alarmRetryBinding() { | |||
| return BindingBuilder.bind(alarmRetryQueue()) | |||
| .to(alarmRetryExchange()) | |||
| .with(YanyuanConstants.ALARM_RETRY_ROUTING_KEY); | |||
| } | |||
| @Bean | |||
| public TopicExchange alarmDeadExchange() { | |||
| return new TopicExchange(YanyuanConstants.ALARM_DEAD_EXCHANGE, true, false); | |||
| } | |||
| @Bean("alarmDeadQueue") | |||
| public Queue alarmDeadQueue() { | |||
| return QueueBuilder.durable(YanyuanConstants.ALARM_DEAD_QUEUE).build(); | |||
| } | |||
| @Bean | |||
| public Binding alarmDeadBinding() { | |||
| return BindingBuilder.bind(alarmDeadQueue()) | |||
| .to(alarmDeadExchange()) | |||
| .with(YanyuanConstants.ALARM_DEAD_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(); | |||
| } | |||
| } | |||
| @ -0,0 +1,25 @@ | |||
| package com.inspect.api.constant; | |||
| public class YanyuanConstants { | |||
| private YanyuanConstants() {} | |||
| public static final String EXCHANGE = "alarm.exchange"; | |||
| public static final String QUEUE = "alarm.queue"; | |||
| public static final String ROUTING_KEY = "alarm.routing-key"; | |||
| /** 告警重试(WS发送失败后延迟重试) */ | |||
| public static final String ALARM_RETRY_EXCHANGE = "alarm.retry.exchange"; | |||
| public static final String ALARM_RETRY_QUEUE = "alarm.retry.queue"; | |||
| public static final String ALARM_RETRY_ROUTING_KEY = "alarm.retry.routing-key"; | |||
| /** 告警死信(重试耗尽后兜底) */ | |||
| public static final String ALARM_DEAD_EXCHANGE = "alarm.dead.exchange"; | |||
| public static final String ALARM_DEAD_QUEUE = "alarm.dead.queue"; | |||
| public static final String ALARM_DEAD_ROUTING_KEY = "alarm.dead.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"; | |||
| } | |||
| @ -0,0 +1,63 @@ | |||
| package com.inspect.api.controller; | |||
| import com.inspect.api.domain.AlarmMessage; | |||
| import com.inspect.api.domain.ApiResult; | |||
| import com.inspect.api.domain.AuthTokenData; | |||
| import com.inspect.api.enums.AlarmCategory; | |||
| import com.inspect.api.enums.MessageType; | |||
| import com.inspect.api.enums.SystemCode; | |||
| import com.inspect.api.service.AlarmPublisher; | |||
| import com.inspect.api.service.ExtAuthService; | |||
| import com.inspect.base.core.web.controller.BaseController; | |||
| import com.inspect.base.core.web.domain.AjaxResult; | |||
| import com.inspect.base.core.web.page.TableDataInfo; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| import org.springframework.web.bind.annotation.*; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| @Slf4j | |||
| @RestController | |||
| @RequestMapping("/v1/api/alarm") | |||
| public class AlarmController extends BaseController { | |||
| private final ExtAuthService extAuthService; | |||
| private final AlarmPublisher alarmPublisher; | |||
| public AlarmController(ExtAuthService extAuthService, AlarmPublisher alarmPublisher) { | |||
| this.extAuthService = extAuthService; | |||
| this.alarmPublisher = alarmPublisher; | |||
| } | |||
| @PostMapping("/history") | |||
| @ResponseBody | |||
| public TableDataInfo list(AlarmMessage alarmMessage) { | |||
| startPage(); | |||
| List<AlarmMessage> list = new ArrayList<>(); | |||
| list.add(AlarmMessage.builder() | |||
| .messageType(MessageType.ALARM.getCode()) | |||
| .alarmCategory(AlarmCategory.APPEARANCE_DEFECT.getCode()) | |||
| .systemCode(SystemCode.PATROL.getCode()) | |||
| .build()); | |||
| // List<AlarmMessage> list = jobLogService.selectJobLogList(jobLog); | |||
| return getDataTable(list); | |||
| } | |||
| @GetMapping("/getToken") | |||
| public AjaxResult getToken(String clientId, String clientSecret) { | |||
| ApiResult<AuthTokenData> result = extAuthService.getAuthToken(clientId, clientSecret); | |||
| log.info("Result={}", result); | |||
| return AjaxResult.success(result.getData()); | |||
| } | |||
| @PostMapping("/push") | |||
| @ResponseBody | |||
| public AjaxResult push(@RequestBody Map<String, Object> msg) { | |||
| if (msg != null) { | |||
| alarmPublisher.publish(msg); | |||
| return AjaxResult.success(msg); | |||
| } | |||
| return AjaxResult.error(); | |||
| } | |||
| } | |||
| @ -0,0 +1,19 @@ | |||
| package com.inspect.api.controller; | |||
| import com.inspect.base.core.web.controller.BaseController; | |||
| import com.inspect.base.core.web.domain.AjaxResult; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| @Slf4j | |||
| @RestController | |||
| @RequestMapping("/v1/api/patrol") | |||
| public class PatrolController extends BaseController { | |||
| @GetMapping("/test") | |||
| public AjaxResult test() { | |||
| log.info("Test success!"); | |||
| return AjaxResult.success("OK"); | |||
| } | |||
| } | |||
| @ -0,0 +1,19 @@ | |||
| package com.inspect.api.domain; | |||
| import com.inspect.base.core.web.domain.BaseEntity; | |||
| import lombok.*; | |||
| @ToString | |||
| @Data | |||
| @Builder | |||
| @NoArgsConstructor | |||
| @AllArgsConstructor | |||
| public class AlarmMessage extends BaseEntity { | |||
| private String messageId; | |||
| private String messageType; | |||
| // 告警类型: messageType = alarm_push时传入 | |||
| private String alarmCategory; | |||
| private String timestamp; | |||
| private String systemCode; | |||
| private Object data; | |||
| } | |||
| @ -0,0 +1,12 @@ | |||
| package com.inspect.api.domain; | |||
| import lombok.Data; | |||
| @Data | |||
| public class ApiResult<T> { | |||
| private boolean success; | |||
| private T data; | |||
| private String hintMessage; | |||
| private String type; | |||
| private int code; | |||
| } | |||
| @ -0,0 +1,12 @@ | |||
| package com.inspect.api.domain; | |||
| import lombok.Data; | |||
| @Data | |||
| public class AuthTokenData { | |||
| private String access_token; | |||
| private String token_type; | |||
| private String scope; | |||
| private String userId; | |||
| private String jti; | |||
| } | |||
| @ -0,0 +1,23 @@ | |||
| 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; | |||
| } | |||
| @ -0,0 +1,17 @@ | |||
| package com.inspect.api.enums; | |||
| import lombok.AllArgsConstructor; | |||
| import lombok.Getter; | |||
| @Getter | |||
| @AllArgsConstructor | |||
| public enum AlarmCategory { | |||
| FIRE_SMOKE("fire_smoke", "烟火报警"), | |||
| NO_HELMET("no_helmet", "未佩戴安全帽报警"), | |||
| NO_UNIFORM("no_uniform", "未穿工装报警"), | |||
| METER_OVERRUN("meter_overrun", "红外温度超限报警"), | |||
| APPEARANCE_DEFECT("appearance_defect", "外观缺陷告警"); | |||
| final private String code; | |||
| final private String value; | |||
| } | |||
| @ -0,0 +1,14 @@ | |||
| package com.inspect.api.enums; | |||
| import lombok.AllArgsConstructor; | |||
| import lombok.Getter; | |||
| @Getter | |||
| @AllArgsConstructor | |||
| public enum MessageType { | |||
| ALARM("alarm_push", "告警推送"), | |||
| REPORT("report_push", "报告推送"); | |||
| final private String code; | |||
| final private String value; | |||
| } | |||
| @ -0,0 +1,15 @@ | |||
| package com.inspect.api.enums; | |||
| import lombok.AllArgsConstructor; | |||
| import lombok.Getter; | |||
| @Getter | |||
| @AllArgsConstructor | |||
| public enum SystemCode { | |||
| PE("power&environment", "动环"), | |||
| FIREFIGHTING("firefighting", "消防"), | |||
| PATROL("patrol", "巡检"); | |||
| final private String code; | |||
| final private String value; | |||
| } | |||
| @ -0,0 +1,15 @@ | |||
| package com.inspect.api.props; | |||
| import lombok.Data; | |||
| import org.springframework.boot.context.properties.ConfigurationProperties; | |||
| import org.springframework.stereotype.Component; | |||
| @Data | |||
| @Component | |||
| @ConfigurationProperties(prefix = "yanyuan.auth") | |||
| public class AppProperties { | |||
| private String baseUrl; | |||
| private String wsUrl; | |||
| private String clientId; | |||
| private String clientSecret; | |||
| } | |||
| @ -0,0 +1,78 @@ | |||
| package com.inspect.api.service; | |||
| import com.fasterxml.jackson.core.type.TypeReference; | |||
| import com.fasterxml.jackson.databind.ObjectMapper; | |||
| import com.inspect.api.constant.YanyuanConstants; | |||
| import com.inspect.api.websocket.AlarmWsConnection; | |||
| import com.inspect.base.core.utils.StringUtils; | |||
| import com.inspect.base.redis.service.RedisService; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| import org.springframework.amqp.core.Message; | |||
| import org.springframework.amqp.rabbit.annotation.RabbitListener; | |||
| import org.springframework.amqp.rabbit.core.RabbitTemplate; | |||
| import org.springframework.stereotype.Component; | |||
| import java.util.Map; | |||
| @Slf4j | |||
| @Component | |||
| public class AlarmConsumer { | |||
| /** 最多重试次数(总尝试次数 = MAX_RETRY + 1) */ | |||
| private static final int MAX_RETRY = 2; | |||
| private static final String RETRY_COUNT_HEADER = "alarm-retry-count"; | |||
| private final AlarmWsConnection wsConnection; | |||
| private final ObjectMapper objectMapper; | |||
| private final RedisService redisService; | |||
| private final RabbitTemplate rabbitTemplate; | |||
| public AlarmConsumer(AlarmWsConnection wsConnection, ObjectMapper objectMapper, | |||
| RedisService redisService, RabbitTemplate rabbitTemplate) { | |||
| this.wsConnection = wsConnection; | |||
| this.objectMapper = objectMapper; | |||
| this.redisService = redisService; | |||
| this.rabbitTemplate = rabbitTemplate; | |||
| } | |||
| @RabbitListener(queues = "#{alarmQueue.name}") | |||
| public void onAlarm(Message message) { | |||
| try { | |||
| Map<String, Object> msg = objectMapper.readValue(message.getBody(), | |||
| new TypeReference<Map<String, Object>>() {}); | |||
| String staticCode = (String) redisService.redisTemplate.opsForValue().get("sys_config:STATION_CODE"); | |||
| if (StringUtils.isNotEmpty(staticCode)) { | |||
| msg.put("stationCode", staticCode); | |||
| } | |||
| String json = objectMapper.writeValueAsString(msg); | |||
| boolean sent = wsConnection.send(json); | |||
| if (sent) { | |||
| log.info("Alarm sent to WS: msg={}", msg); | |||
| } else { | |||
| retryOrDeadLetter(message); | |||
| } | |||
| } catch (Exception e) { | |||
| log.error("Alarm consumer error: {}", e.getMessage(), e); | |||
| retryOrDeadLetter(message); | |||
| } | |||
| } | |||
| private void retryOrDeadLetter(Message message) { | |||
| int retryCount = getRetryCount(message); | |||
| if (retryCount < MAX_RETRY) { | |||
| message.getMessageProperties().setHeader(RETRY_COUNT_HEADER, retryCount + 1); | |||
| rabbitTemplate.send(YanyuanConstants.ALARM_RETRY_EXCHANGE, | |||
| YanyuanConstants.ALARM_RETRY_ROUTING_KEY, message); | |||
| log.warn("Alarm WS send failed, retry {}/{} scheduled", retryCount + 1, MAX_RETRY); | |||
| } else { | |||
| rabbitTemplate.send(YanyuanConstants.ALARM_DEAD_EXCHANGE, | |||
| YanyuanConstants.ALARM_DEAD_ROUTING_KEY, message); | |||
| log.error("Alarm WS send failed after {} retries, moved to dead letter queue", MAX_RETRY); | |||
| } | |||
| } | |||
| private int getRetryCount(Message message) { | |||
| Object count = message.getMessageProperties().getHeader(RETRY_COUNT_HEADER); | |||
| return count instanceof Number ? ((Number) count).intValue() : 0; | |||
| } | |||
| } | |||
| @ -0,0 +1,25 @@ | |||
| package com.inspect.api.service; | |||
| import com.inspect.api.constant.YanyuanConstants; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| import org.springframework.amqp.rabbit.core.RabbitTemplate; | |||
| import org.springframework.stereotype.Service; | |||
| @Slf4j | |||
| @Service | |||
| public class AlarmPublisher { | |||
| private final RabbitTemplate rabbitTemplate; | |||
| public AlarmPublisher(RabbitTemplate rabbitTemplate) { | |||
| this.rabbitTemplate = rabbitTemplate; | |||
| } | |||
| public void publish(Object msg) { | |||
| try { | |||
| log.info("Alert published: msg={}", msg); | |||
| rabbitTemplate.convertAndSend(YanyuanConstants.EXCHANGE, YanyuanConstants.ROUTING_KEY, msg); | |||
| } catch (Exception e) { | |||
| log.error("Failed to publish alert: {}", e.getMessage(), e); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,41 @@ | |||
| package com.inspect.api.service; | |||
| import com.fasterxml.jackson.core.type.TypeReference; | |||
| import com.fasterxml.jackson.databind.ObjectMapper; | |||
| import com.inspect.api.domain.ApiResult; | |||
| import com.inspect.api.domain.AuthTokenData; | |||
| import com.inspect.api.props.AppProperties; | |||
| import com.inspect.base.core.utils.HttpClientUtils; | |||
| import org.springframework.stereotype.Service; | |||
| @Service | |||
| public class ExtAuthService { | |||
| private static final String TOKEN_HEADER = "joinbright-token"; | |||
| private final ObjectMapper objectMapper; | |||
| private final AppProperties appProperties; | |||
| public ExtAuthService(ObjectMapper objectMapper, AppProperties appProperties) { | |||
| this.objectMapper = objectMapper; | |||
| this.appProperties = appProperties; | |||
| } | |||
| public ApiResult<AuthTokenData> getAuthToken() { | |||
| return getAuthToken(appProperties.getClientId(), appProperties.getClientSecret()); | |||
| } | |||
| public ApiResult<AuthTokenData> getAuthToken(String clientId, String clientSecret) { | |||
| String url = appProperties.getBaseUrl() + "/ext_auth/getAuthToken"; | |||
| String param = "clientId=" + clientId + "&clientSecret=" + clientSecret; | |||
| return executeGet(url, param, new TypeReference<ApiResult<AuthTokenData>>() {}); | |||
| } | |||
| private <T> ApiResult<T> executeGet(String url, String param, TypeReference<ApiResult<T>> typeRef) { | |||
| try { | |||
| String json = HttpClientUtils.get(url, param); | |||
| return objectMapper.readValue(json, typeRef); | |||
| } catch (Exception e) { | |||
| throw new RuntimeException("HTTP GET failed: " + url, e); | |||
| } | |||
| } | |||
| } | |||
| @ -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,150 @@ | |||
| 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> | |||
| * 重试策略:上传失败就在当前消费里重试,最多 {@code inspect.report-push.mq-max-retries} 次(含首次); | |||
| * 仍然失败则正常返回(消息 ack 出队 = 丢弃),<b>不抛异常</b>,避免 MQ 自动重投递造成无限重投递。 | |||
| */ | |||
| @Slf4j | |||
| @Component | |||
| public class ReportUploadConsumer { | |||
| /** 两次尝试之间的间隔(毫秒) */ | |||
| private static final long RETRY_INTERVAL_MILLIS = 3000; | |||
| /** 上传失败最大尝试次数(含首次),超过后直接丢弃消息 */ | |||
| @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; | |||
| } | |||
| int maxAttempts = Math.max(1, maxRetries); | |||
| for (int attempt = 1; attempt <= maxAttempts; attempt++) { | |||
| try { | |||
| if (upload(message)) { | |||
| log.info("[REPORT_PUSH_MQ] 报告上传成功: reportId={}, 第{}/{}次尝试", | |||
| message.getReportId(), attempt, maxAttempts); | |||
| return; | |||
| } | |||
| log.warn("[REPORT_PUSH_MQ] 报告上传失败, 第{}/{}次尝试: reportId={}", | |||
| attempt, maxAttempts, message.getReportId()); | |||
| } catch (Exception e) { | |||
| log.warn("[REPORT_PUSH_MQ] 报告上传异常, 第{}/{}次尝试: reportId={}, err={}", | |||
| attempt, maxAttempts, message.getReportId(), e.getMessage()); | |||
| } | |||
| if (attempt < maxAttempts) { | |||
| try { | |||
| Thread.sleep(RETRY_INTERVAL_MILLIS); | |||
| } catch (InterruptedException ie) { | |||
| // 应用停机等中断场景:抛出让消息重回队列,避免丢消息 | |||
| Thread.currentThread().interrupt(); | |||
| throw new RuntimeException("report upload retry interrupted", ie); | |||
| } | |||
| } | |||
| } | |||
| // 超过最大尝试次数:正常返回即 ack 出队,消息被丢弃,不会再被投递 | |||
| log.error("[REPORT_PUSH_MQ] 报告上传超过最大尝试次数({}), 丢弃消息: reportId={}, filePath={}", | |||
| maxAttempts, message.getReportId(), message.getFilePath()); | |||
| } | |||
| /** | |||
| * 下载报告 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,184 @@ | |||
| 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; | |||
| import java.nio.charset.StandardCharsets; | |||
| /** | |||
| * 巡检报告上传服务(外部平台《巡检报告接口文档 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.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); | |||
| String fileHeader = "Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n"; | |||
| out.write(fileHeader.getBytes(StandardCharsets.UTF_8)); | |||
| 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,133 @@ | |||
| package com.inspect.api.websocket; | |||
| import com.inspect.api.domain.ApiResult; | |||
| import com.inspect.api.domain.AuthTokenData; | |||
| import com.inspect.api.props.AppProperties; | |||
| import com.inspect.api.service.ExtAuthService; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| import org.springframework.scheduling.annotation.Scheduled; | |||
| import org.springframework.stereotype.Component; | |||
| import org.springframework.web.socket.CloseStatus; | |||
| import org.springframework.web.socket.TextMessage; | |||
| import org.springframework.web.socket.WebSocketSession; | |||
| import org.springframework.web.socket.client.standard.StandardWebSocketClient; | |||
| import org.springframework.web.socket.handler.TextWebSocketHandler; | |||
| import java.util.concurrent.atomic.AtomicInteger; | |||
| import java.util.concurrent.atomic.AtomicReference; | |||
| @Slf4j | |||
| @Component | |||
| public class AlarmWsConnection { | |||
| private final ExtAuthService extAuthService; | |||
| private final AppProperties appProperties; | |||
| private final StandardWebSocketClient wsClient; | |||
| private final AtomicReference<WebSocketSession> sessionRef = new AtomicReference<>(); | |||
| private final AtomicInteger reconnectBackoff = new AtomicInteger(5); | |||
| private volatile boolean connecting = false; | |||
| public AlarmWsConnection(ExtAuthService extAuthService, AppProperties appProperties) { | |||
| this.extAuthService = extAuthService; | |||
| this.appProperties = appProperties; | |||
| this.wsClient = new StandardWebSocketClient(); | |||
| } | |||
| public void connect() { | |||
| if (connecting) { | |||
| return; | |||
| } | |||
| connecting = true; | |||
| try { | |||
| ApiResult<AuthTokenData> result = extAuthService.getAuthToken(); | |||
| if (!result.isSuccess() || result.getData() == null) { | |||
| log.error("Alarm WS: failed to get auth token, retry in {}s", reconnectBackoff.get()); | |||
| connecting = false; | |||
| return; | |||
| } | |||
| String token = result.getData().getAccess_token(); | |||
| String wsUrl = appProperties.getWsUrl() + "?Joinbright-Token=" + token; | |||
| wsClient.doHandshake(new AlarmWsHandler(), wsUrl) | |||
| .addCallback( | |||
| session -> { | |||
| sessionRef.set(session); | |||
| reconnectBackoff.set(5); | |||
| connecting = false; | |||
| log.info("Alarm WS connected: {}", session.getId()); | |||
| }, | |||
| ex -> { | |||
| connecting = false; | |||
| log.error("Alarm WS handshake failed: {}", ex.getMessage()); | |||
| } | |||
| ); | |||
| } catch (Exception e) { | |||
| connecting = false; | |||
| log.error("Alarm WS connect error: {}", e.getMessage()); | |||
| } | |||
| } | |||
| @Scheduled(fixedDelay = 1000) | |||
| public void reconnect() { | |||
| WebSocketSession session = sessionRef.get(); | |||
| if (session != null && session.isOpen()) { | |||
| return; | |||
| } | |||
| if (connecting) { | |||
| return; | |||
| } | |||
| int delay = reconnectBackoff.get(); | |||
| log.info("Alarm WS reconnecting in {}s", delay); | |||
| try { | |||
| Thread.sleep(delay * 1000L); | |||
| } catch (InterruptedException ignored) { | |||
| Thread.currentThread().interrupt(); | |||
| return; | |||
| } | |||
| int nextDelay = Math.min(delay * 2, 60); | |||
| reconnectBackoff.set(nextDelay); | |||
| connect(); | |||
| } | |||
| public boolean send(String message) { | |||
| WebSocketSession session = sessionRef.get(); | |||
| if (session == null || !session.isOpen()) { | |||
| log.warn("Alarm WS not connected, message dropped: {}", message); | |||
| return false; | |||
| } | |||
| try { | |||
| synchronized (session) { | |||
| session.sendMessage(new TextMessage(message)); | |||
| } | |||
| return true; | |||
| } catch (Exception e) { | |||
| log.error("Alarm WS send failed: {}", e.getMessage()); | |||
| return false; | |||
| } | |||
| } | |||
| public boolean isConnected() { | |||
| WebSocketSession session = sessionRef.get(); | |||
| return session != null && session.isOpen(); | |||
| } | |||
| private class AlarmWsHandler extends TextWebSocketHandler { | |||
| @Override | |||
| public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { | |||
| log.warn("Alarm WS closed: {}", status); | |||
| sessionRef.compareAndSet(session, null); | |||
| } | |||
| @Override | |||
| protected void handleTextMessage(WebSocketSession session, TextMessage message) { | |||
| log.info("Alarm WS received: {}", message.getPayload()); | |||
| } | |||
| @Override | |||
| public void handleTransportError(WebSocketSession session, Throwable ex) { | |||
| log.error("Alarm WS transport error: {}", ex.getMessage()); | |||
| sessionRef.compareAndSet(session, null); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,54 @@ | |||
| package com.inspect.analysis.domain; | |||
| import lombok.Getter; | |||
| import lombok.Setter; | |||
| import lombok.ToString; | |||
| import java.util.List; | |||
| /** | |||
| * 缺陷列表查询条件。 | |||
| */ | |||
| @Getter | |||
| @Setter | |||
| @ToString | |||
| public class DefectListQuery { | |||
| /** 巡视任务执行 ID。 */ | |||
| private String taskPatrolledId; | |||
| /** 服务层根据主任务展开后的任务执行 ID。 */ | |||
| private List<String> taskPatrolIds; | |||
| /** 结果来源:0-大模型,1-初筛。 */ | |||
| private String filter; | |||
| /** 算法类型。 */ | |||
| private String algType; | |||
| /** 算法名称(前端下拉筛选,精确匹配聚合记录的 algName),选项见 /defect/algNameOptions。 */ | |||
| private String algName; | |||
| /** 区域 ID。 */ | |||
| private String areaId; | |||
| /** 主设备 ID。 */ | |||
| private String deviceId; | |||
| /** 点位 ID。 */ | |||
| private String pointId; | |||
| /** 任务编码。 */ | |||
| private String taskCode; | |||
| /** 点位名称。 */ | |||
| private String pointName; | |||
| /** 任务名称。 */ | |||
| private String taskName; | |||
| /** 主设备名称。 */ | |||
| private String deviceName; | |||
| /** 相机/巡检设备名称。 */ | |||
| private String channelName; | |||
| /** 系统/设备来源。 */ | |||
| private String deviceSource; | |||
| /** 点位状态。 */ | |||
| private String pointStatus; | |||
| /** 缺陷描述关键字。 */ | |||
| private String desc; | |||
| /** 开始日期,格式 yyyy-MM-dd。 */ | |||
| private String beginTime; | |||
| /** 结束日期,格式 yyyy-MM-dd。 */ | |||
| private String endTime; | |||
| /** 预置位类型:0-视频,其他-机器人/无人机;未传时按视频处理。 */ | |||
| private String posType = "0"; | |||
| } | |||
| @ -0,0 +1,206 @@ | |||
| package com.inspect.analysis.service; | |||
| import com.inspect.analysis.domain.AlgValue; | |||
| import com.inspect.base.core.constant.AlgConstants; | |||
| import com.inspect.base.core.utils.StringUtils; | |||
| import com.inspect.partrolresult.domain.AlgInfo; | |||
| import com.inspect.task.domain.PatrolData; | |||
| import java.util.ArrayList; | |||
| import java.util.Collections; | |||
| import java.util.Comparator; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.LinkedHashSet; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.Objects; | |||
| import java.util.Set; | |||
| import java.util.function.Function; | |||
| import java.util.stream.Collectors; | |||
| /** | |||
| * 将缺陷分析明细按任务、对象和算法聚合为列表展示记录。 | |||
| */ | |||
| public final class DefectListAggregator { | |||
| private DefectListAggregator() { | |||
| } | |||
| public static List<PatrolData> aggregate(List<PatrolData> source, List<AlgInfo> algInfos) { | |||
| if (source == null || source.isEmpty()) { | |||
| return new ArrayList<>(); | |||
| } | |||
| Map<String, String> algNameMap = algInfos == null ? Collections.emptyMap() : algInfos.stream() | |||
| .filter(Objects::nonNull) | |||
| .filter(item -> StringUtils.isNotEmpty(item.getAlgSubtypeCode())) | |||
| .collect(Collectors.toMap(AlgInfo::getAlgSubtypeCode, AlgInfo::getAlgSubtypeName, | |||
| (first, ignored) -> first)); | |||
| Map<GroupKey, List<PatrolData>> groups = new LinkedHashMap<>(); | |||
| for (PatrolData item : source) { | |||
| if (item == null || !"0".equals(item.getWarnStatus()) | |||
| || AlgConstants.CORRECTION.equals(item.getAlgType())) { | |||
| continue; | |||
| } | |||
| item.setAlgName(algNameMap.getOrDefault(item.getAlgType(), "")); | |||
| prepareItem(item); | |||
| GroupKey key = new GroupKey(item.getTaskPatrolId(), item.getObjectId(), item.getAlgType()); | |||
| groups.computeIfAbsent(key, ignored -> new ArrayList<>()).add(item); | |||
| } | |||
| List<PatrolData> result = new ArrayList<>(); | |||
| for (Map.Entry<GroupKey, List<PatrolData>> entry : groups.entrySet()) { | |||
| result.add(merge(entry.getKey(), entry.getValue())); | |||
| } | |||
| result.sort(Comparator.comparingInt(item -> statusRank(item.getResStatus()))); | |||
| return result; | |||
| } | |||
| public static List<PatrolData> page(List<PatrolData> source, Integer pageNum, Integer pageSize) { | |||
| if (source == null || source.isEmpty()) { | |||
| return new ArrayList<>(); | |||
| } | |||
| int safePageNum = pageNum == null || pageNum < 1 ? 1 : pageNum; | |||
| int safePageSize = pageSize == null || pageSize < 1 ? 10 : pageSize; | |||
| long from = (long) (safePageNum - 1) * safePageSize; | |||
| if (from >= source.size()) { | |||
| return new ArrayList<>(); | |||
| } | |||
| int fromIndex = (int) from; | |||
| long to = Math.min((long) source.size(), from + safePageSize); | |||
| int toIndex = (int) to; | |||
| return source.subList(fromIndex, toIndex); | |||
| } | |||
| private static void prepareItem(PatrolData item) { | |||
| if ("1".equals(item.getFilter()) && StringUtils.isNotEmpty(item.getImageNormalUrlPath())) { | |||
| item.setImg(item.getImageNormalUrlPath()); | |||
| } | |||
| if (item.getResValue() != null && "meter".equals(item.getAlgType())) { | |||
| String value = new AlgValue().formatValue(item.getResValue()); | |||
| if ("-1".equals(value)) { | |||
| item.setResValue("空值"); | |||
| } | |||
| } | |||
| } | |||
| private static PatrolData merge(GroupKey key, List<PatrolData> values) { | |||
| PatrolData result = new PatrolData(); | |||
| result.setTaskPatrolId(key.taskPatrolId); | |||
| result.setObjectId(key.objectId); | |||
| result.setAlgType(key.algType); | |||
| result.setDesc(joinDistinct(values, PatrolData::getDesc)); | |||
| for (PatrolData item : values) { | |||
| if ("1".equals(item.getFilter())) { | |||
| // if (StringUtils.isEmpty(result.getImgAnalyse())) { | |||
| // result.setImgAnalyse(item.getImgAnalyse()); | |||
| // } | |||
| if (StringUtils.isEmpty(result.getImg())) { | |||
| result.setImg(item.getImg()); | |||
| } | |||
| } | |||
| if ("0".equals(item.getFilter())) { | |||
| if (StringUtils.isNotEmpty(item.getImgAnalyse())) { | |||
| result.setImgAnalyse(item.getImgAnalyse()); | |||
| } | |||
| // if (StringUtils.isNotEmpty(item.getImg())) { | |||
| // result.setImg(item.getImg()); | |||
| // } | |||
| } | |||
| // if (StringUtils.isEmpty(result.getImg())) { | |||
| // if (StringUtils.isNotEmpty(item.getImgAnalyse())) { | |||
| // result.setImgAnalyse(item.getImgAnalyse()); | |||
| // } | |||
| // if (StringUtils.isNotEmpty(item.getImg())) { | |||
| // result.setImg(item.getImg()); | |||
| // } | |||
| // } | |||
| } | |||
| result.setImageNormalUrlPath(firstNonEmpty(values, PatrolData::getImageNormalUrlPath)); | |||
| result.setFilePath(firstNonEmpty(values, PatrolData::getFilePath)); | |||
| result.setAlgName(firstNonEmpty(values, PatrolData::getAlgName)); | |||
| result.setAreaName(firstNonEmpty(values, PatrolData::getAreaName)); | |||
| // result.setBrightDesc(firstNonEmpty(values, PatrolData::getBrightDesc)); | |||
| // result.setBrightImgAnalyse(firstNonEmpty(values, PatrolData::getBrightImgAnalyse)); | |||
| // result.setBrightResStatus(firstNonEmpty(values, PatrolData::getBrightResStatus)); | |||
| result.setChannelName(firstNonEmpty(values, PatrolData::getChannelName)); | |||
| result.setDataType(firstNonEmpty(values, PatrolData::getDataType)); | |||
| result.setDeviceName(firstNonEmpty(values, PatrolData::getDeviceName)); | |||
| result.setDeviceSource(firstNonEmpty(values, PatrolData::getDeviceSource)); | |||
| result.setImgType(firstNonEmpty(values, PatrolData::getImgType)); | |||
| result.setLineId(joinDistinct(values, PatrolData::getLineId)); | |||
| result.setPatrolTime(firstNonEmpty(values, PatrolData::getPatrolTime)); | |||
| result.setPointId(firstNonEmpty(values, PatrolData::getPointId)); | |||
| result.setPointName(firstNonEmpty(values, PatrolData::getPointName)); | |||
| result.setPointStatus(firstNonEmpty(values, PatrolData::getPointStatus)); | |||
| result.setRequestId(firstNonEmpty(values, PatrolData::getRequestId)); | |||
| result.setResValue(firstNonEmpty(values, PatrolData::getResValue)); | |||
| result.setResStatus(firstNonEmpty(values, PatrolData::getResStatus)); | |||
| result.setSuggestion(firstNonEmpty(values, PatrolData::getSuggestion)); | |||
| result.setWarnStatus("0"); | |||
| result.setTaskName(firstNonEmpty(values,PatrolData::getTaskName)); | |||
| return result; | |||
| } | |||
| private static String firstNonEmpty(List<PatrolData> values, Function<PatrolData, String> getter) { | |||
| return values.stream().map(getter).filter(StringUtils::isNotEmpty).findFirst().orElse(""); | |||
| } | |||
| private static String joinDistinct(List<PatrolData> values, Function<PatrolData, String> getter) { | |||
| Set<String> distinct = new LinkedHashSet<>(); | |||
| for (PatrolData value : values) { | |||
| String item = getter.apply(value); | |||
| if (StringUtils.isNotEmpty(item)) { | |||
| distinct.add(item); | |||
| } | |||
| } | |||
| return String.join(",", distinct); | |||
| } | |||
| private static int statusRank(String status) { | |||
| if ("1".equals(status)) { | |||
| return 1; | |||
| } | |||
| if ("3".equals(status)) { | |||
| return 2; | |||
| } | |||
| if ("2".equals(status)) { | |||
| return 3; | |||
| } | |||
| return 10; | |||
| } | |||
| private static final class GroupKey { | |||
| private final String taskPatrolId; | |||
| private final String objectId; | |||
| private final String algType; | |||
| private GroupKey(String taskPatrolId, String objectId, String algType) { | |||
| this.taskPatrolId = taskPatrolId; | |||
| this.objectId = objectId; | |||
| this.algType = algType; | |||
| } | |||
| @Override | |||
| public boolean equals(Object other) { | |||
| if (this == other) { | |||
| return true; | |||
| } | |||
| if (!(other instanceof GroupKey)) { | |||
| return false; | |||
| } | |||
| GroupKey that = (GroupKey) other; | |||
| return Objects.equals(taskPatrolId, that.taskPatrolId) | |||
| && Objects.equals(objectId, that.objectId) | |||
| && Objects.equals(algType, that.algType); | |||
| } | |||
| @Override | |||
| public int hashCode() { | |||
| return Objects.hash(taskPatrolId, objectId, algType); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,32 @@ | |||
| package com.inspect.insreporttask.domain; | |||
| import lombok.Getter; | |||
| import lombok.Setter; | |||
| import java.util.Date; | |||
| /** | |||
| * 巡检报告-任务关联表。 | |||
| * <p> | |||
| * 归档改造后,一次归档(单个任务 / 多个任务合并归档)只生成一条 | |||
| * {@link com.inspect.insreport.domain.InspectionReport},被归档的每个任务执行 | |||
| * (patrol_task_result_main.line_id)都通过本表关联到同一条报告。 | |||
| * | |||
| * @author inspect | |||
| */ | |||
| @Getter | |||
| @Setter | |||
| public class InspectionReportTask { | |||
| /** 主键 */ | |||
| private Long id; | |||
| /** 报告主键 inspection_report.line_id */ | |||
| private Long reportLineId; | |||
| /** 归档任务执行主键 patrol_task_result_main.line_id */ | |||
| private Long taskResultLineId; | |||
| /** 任务执行标识 patrol_task_result_main.task_patrolled_id */ | |||
| private String taskPatrolledId; | |||
| /** 任务名称 patrol_task_result_main.task_name */ | |||
| private String taskName; | |||
| /** 创建时间 */ | |||
| private Date createTime; | |||
| } | |||
| @ -0,0 +1,36 @@ | |||
| package com.inspect.insreporttask.mapper; | |||
| import com.inspect.insreport.domain.InspectionReport; | |||
| import com.inspect.insreporttask.domain.InspectionReportTask; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import java.util.List; | |||
| @Mapper | |||
| public interface InspectionReportTaskMapper { | |||
| /** | |||
| * 批量写入 报告-任务 关联。 | |||
| * | |||
| * @param inspectionReportTasks 关联记录(一次归档一条报告,任务逐条关联) | |||
| */ | |||
| int batchInsertInspectionReportTask(@Param("list") List<InspectionReportTask> inspectionReportTasks); | |||
| /** | |||
| * 按任务执行标识查询其关联的报告(合并归档时多个任务关联同一条报告)。 | |||
| * | |||
| * @param taskPatrolledId 任务执行标识 patrol_task_result_main.task_patrolled_id | |||
| * @param filter 报告类型过滤(可为空/不传,表示不过滤) | |||
| */ | |||
| List<InspectionReport> selectInspectionReportsByTaskPatrolledId(@Param("taskPatrolledId") String taskPatrolledId, | |||
| @Param("filter") String filter); | |||
| /** | |||
| * 按报告主键查询该报告关联的所有任务执行标识。 | |||
| * | |||
| * @param reportLineId 报告主键 inspection_report.line_id | |||
| * @return 任务执行标识列表 inspection_report_task.task_patrolled_id | |||
| */ | |||
| List<String> selectTaskPatrolledIdsByReportLineId(@Param("reportLineId") Long reportLineId); | |||
| } | |||
| @ -0,0 +1,24 @@ | |||
| package com.inspect.insreporttask.service; | |||
| import com.inspect.insreport.domain.InspectionReport; | |||
| import com.inspect.insreporttask.domain.InspectionReportTask; | |||
| import java.util.List; | |||
| public interface IInspectionReportTaskService { | |||
| /** | |||
| * 写入 报告-任务 关联记录。 | |||
| * | |||
| * @param inspectionReportTasks 关联记录列表 | |||
| */ | |||
| int batchInsert(List<InspectionReportTask> inspectionReportTasks); | |||
| /** | |||
| * 按任务执行标识查询其关联的报告(多个任务可能关联同一条合并报告)。 | |||
| * | |||
| * @param taskPatrolledId 任务执行标识 | |||
| * @param filter 报告类型过滤(可空) | |||
| */ | |||
| List<InspectionReport> selectInspectionReportsByTaskPatrolledId(String taskPatrolledId, String filter); | |||
| } | |||
| @ -0,0 +1,32 @@ | |||
| package com.inspect.insreporttask.service.impl; | |||
| import com.inspect.insreport.domain.InspectionReport; | |||
| import com.inspect.insreporttask.domain.InspectionReportTask; | |||
| import com.inspect.insreporttask.mapper.InspectionReportTaskMapper; | |||
| import com.inspect.insreporttask.service.IInspectionReportTaskService; | |||
| import org.springframework.stereotype.Service; | |||
| import java.util.List; | |||
| @Service | |||
| public class InspectionReportTaskServiceImpl implements IInspectionReportTaskService { | |||
| private final InspectionReportTaskMapper inspectionReportTaskMapper; | |||
| public InspectionReportTaskServiceImpl(InspectionReportTaskMapper inspectionReportTaskMapper) { | |||
| this.inspectionReportTaskMapper = inspectionReportTaskMapper; | |||
| } | |||
| @Override | |||
| public int batchInsert(List<InspectionReportTask> inspectionReportTasks) { | |||
| if (inspectionReportTasks == null || inspectionReportTasks.isEmpty()) { | |||
| return 0; | |||
| } | |||
| return this.inspectionReportTaskMapper.batchInsertInspectionReportTask(inspectionReportTasks); | |||
| } | |||
| @Override | |||
| public List<InspectionReport> selectInspectionReportsByTaskPatrolledId(String taskPatrolledId, String filter) { | |||
| return this.inspectionReportTaskMapper.selectInspectionReportsByTaskPatrolledId(taskPatrolledId, filter); | |||
| } | |||
| } | |||
| @ -0,0 +1,283 @@ | |||
| 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/ 或外部目录。 | |||
| * 注意:fontTable 模板中「仿宋_GB2312」有 regular/bold/italic 三个样式条目 | |||
| * (rId1..3,对应 font1..3),正文/表头加粗/签署表斜体都引用同一份仿宋_GB2312; | |||
| * 方正小标宋简体只嵌 bold(rId4/font4,标题使用); | |||
| * 黑体只嵌 bold(rId5/font5,标题字体)。 | |||
| */ | |||
| private static final List<EmbeddedFont> EMBEDDED_FONTS = Arrays.asList( | |||
| new EmbeddedFont("font1", "仿宋_GB2312.ttf", "DAF739B1-18F1-4906-820A-C106608C2B73"), | |||
| new EmbeddedFont("font2", "仿宋_GB2312.ttf", "3039BACA-5140-4C4C-8AFB-F7B61E374105"), | |||
| new EmbeddedFont("font3", "仿宋_GB2312.ttf", "73410ACE-E9FA-46ED-BA3D-9ECB6C9FEF4E"), | |||
| new EmbeddedFont("font4", "方正小标宋简体.ttf", "4D5F1B72-2D28-435A-B7BC-848B95F30EBB"), | |||
| new EmbeddedFont("font5", "simhei.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,46 @@ | |||
| package com.inspect.reportpush.config; | |||
| import lombok.Data; | |||
| import org.springframework.boot.context.properties.ConfigurationProperties; | |||
| import org.springframework.stereotype.Component; | |||
| /** | |||
| * 任务巡检完成自动归档 + 生成报告 + websocket 推送配置 | |||
| * <p> | |||
| * 对应 application.yml 中 {@code inspect.report-push.*} 配置块 | |||
| * (可在 nacos 配置中心按现场覆盖)。 | |||
| * | |||
| * @author inspect | |||
| */ | |||
| @Data | |||
| @Component | |||
| @ConfigurationProperties(prefix = "inspect.report-push") | |||
| public class ReportPushProperties { | |||
| /** 是否启用推送 */ | |||
| private boolean enabled = true; | |||
| /** 是否启用自动归档 */ | |||
| private boolean archive = true; | |||
| /** 自动归档审核人 */ | |||
| private String checkPerson = "admin"; | |||
| /** 自动归档巡视结论 */ | |||
| private String taskResult = "巡检完成,自动归档"; | |||
| /** 任务状态更新事务提交等待时间(毫秒) */ | |||
| private long afterCommitDelayMillis = 2000; | |||
| /** 归档防重锁过期时间(秒) */ | |||
| private long lockExpireSeconds = 86400; | |||
| /** 上传报告的场站ID(为空时取 basedata_station.station_no) */ | |||
| private String stationId = ""; | |||
| /** 上传报告的巡检类型(风机巡检/光伏巡检/线路巡检/山火巡检/机器人巡检) */ | |||
| private String inspectType = "动环智能巡检"; | |||
| /** 上传报告的巡检设备名称(机库名称、机器人名称、摄像头名称,可选) */ | |||
| private String inspectDeviceName = ""; | |||
| } | |||
| @ -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,191 @@ | |||
| package com.inspect.reportpush.service; | |||
| import com.inspect.base.core.constant.RedisConst; | |||
| import com.inspect.base.core.enums.TaskStatus; | |||
| import com.inspect.base.core.enums.TaskType; | |||
| import com.inspect.base.core.utils.StringUtils; | |||
| import com.inspect.base.redis.service.RedisService; | |||
| import com.inspect.analysis.domain.ResultAnalysis; | |||
| import com.inspect.analysis.service.IResultAnalysisService; | |||
| import com.inspect.reportpush.config.ReportPushProperties; | |||
| import com.inspect.resultmain.controller.DonghuanReportExportController; | |||
| import com.inspect.resultmain.domain.PatrolTaskResultMain; | |||
| import com.inspect.resultmain.service.IPatrolTaskResultMainService; | |||
| import com.inspect.taskstatus.domain.PatrolTaskStatus; | |||
| import com.inspect.taskstatus.service.IPatrolTaskStatusService; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| import org.springframework.scheduling.annotation.Async; | |||
| import org.springframework.stereotype.Service; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.concurrent.TimeUnit; | |||
| /** | |||
| * 任务巡检完成 → 自动归档 + 生成报告(动环 Word) → websocket 推送 | |||
| * <p> | |||
| * 由 {@code TaskStatusChangeAspect} 在任务状态变更为「已完成(1)」时异步触发, | |||
| * 仅处理主任务/普通任务(子任务由主任务统一归档), | |||
| * 通过 Redis 防重锁避免重复归档。 | |||
| * | |||
| * @author inspect | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| public class ReportAutoArchiveService { | |||
| private final ReportPushProperties properties; | |||
| private final RedisService redisService; | |||
| private final IPatrolTaskStatusService patrolTaskStatusService; | |||
| private final IPatrolTaskResultMainService patrolTaskResultMainService; | |||
| private final IResultAnalysisService resultAnalysisService; | |||
| private final DonghuanReportExportController donghuanReportExportController; | |||
| public ReportAutoArchiveService(ReportPushProperties properties, | |||
| RedisService redisService, | |||
| IPatrolTaskStatusService patrolTaskStatusService, | |||
| IPatrolTaskResultMainService patrolTaskResultMainService, | |||
| IResultAnalysisService resultAnalysisService, | |||
| DonghuanReportExportController donghuanReportExportController) { | |||
| this.properties = properties; | |||
| this.redisService = redisService; | |||
| this.patrolTaskStatusService = patrolTaskStatusService; | |||
| this.patrolTaskResultMainService = patrolTaskResultMainService; | |||
| this.resultAnalysisService = resultAnalysisService; | |||
| this.donghuanReportExportController = donghuanReportExportController; | |||
| } | |||
| /** | |||
| * 任务状态变更为已完成时触发(异步)。 | |||
| * | |||
| * @param taskStatus 变更后的任务状态(切面入参) | |||
| */ | |||
| @Async | |||
| public void onTaskCompleted(PatrolTaskStatus taskStatus) { | |||
| try { | |||
| if (!properties.isArchive()) { | |||
| log.info("[AUTO_ARCHIVE] 未启用 inspect.report-push.archive,跳过自动归档: taskPatrolledId={}", | |||
| taskStatus == null ? null : taskStatus.getTaskPatrolledId()); | |||
| return; | |||
| } | |||
| if (taskStatus == null || StringUtils.isEmpty(taskStatus.getTaskPatrolledId())) { | |||
| log.warn("[AUTO_ARCHIVE] 任务状态参数为空,跳过自动归档"); | |||
| return; | |||
| } | |||
| // 子任务不单独归档,由主任务统一归档 | |||
| if (TaskType.SUB.getCode().equals(taskStatus.getTaskType())) { | |||
| log.debug("[AUTO_ARCHIVE] 子任务不触发归档: {}", taskStatus.getTaskPatrolledId()); | |||
| return; | |||
| } | |||
| // 归档单元:主任务(联合任务)或普通任务自身 | |||
| PatrolTaskStatus mainTask = patrolTaskStatusService.selectPatrolTaskStatusMain(taskStatus.getTaskPatrolledId()); | |||
| String archiveTaskPatrolledId = mainTask != null | |||
| ? mainTask.getTaskPatrolledId() : taskStatus.getTaskPatrolledId(); | |||
| if (StringUtils.isEmpty(archiveTaskPatrolledId)) { | |||
| log.warn("[AUTO_ARCHIVE] 无法确定归档任务ID,跳过: {}", taskStatus.getTaskPatrolledId()); | |||
| return; | |||
| } | |||
| // 防重锁:同一归档单元只允许一次自动归档 | |||
| String lockKey = RedisConst.REPORT_PUSH_LOCK + archiveTaskPatrolledId; | |||
| Boolean locked = redisService.redisTemplate.opsForValue() | |||
| .setIfAbsent(lockKey, archiveTaskPatrolledId, | |||
| properties.getLockExpireSeconds(), TimeUnit.SECONDS); | |||
| if (!Boolean.TRUE.equals(locked)) { | |||
| log.info("[AUTO_ARCHIVE] 任务 {} 已在归档/推送中(防重锁),跳过", archiveTaskPatrolledId); | |||
| return; | |||
| } | |||
| log.info("[AUTO_ARCHIVE] 获取归档防重锁: {}", lockKey); | |||
| // 等待任务状态事务提交、结果数据落库 | |||
| long waitMillis = properties.getAfterCommitDelayMillis(); | |||
| if (waitMillis > 0) { | |||
| try { | |||
| Thread.sleep(waitMillis); | |||
| } catch (InterruptedException e) { | |||
| Thread.currentThread().interrupt(); | |||
| releaseLock(lockKey); | |||
| return; | |||
| } | |||
| } | |||
| // 二次校验:任务仍为已完成状态 | |||
| PatrolTaskStatus freshTask = patrolTaskStatusService.selectPatrolTaskStatusByTaskPatrolledId(archiveTaskPatrolledId); | |||
| if (freshTask == null || !TaskStatus.DONE.getCode().equals(freshTask.getTaskState())) { | |||
| log.info("[AUTO_ARCHIVE] 任务 {} 当前状态 {},非已完成,跳过自动归档", | |||
| archiveTaskPatrolledId, freshTask == null ? "null" : freshTask.getTaskState()); | |||
| releaseLock(lockKey); | |||
| return; | |||
| } | |||
| // 二次校验:已归档(file_status=1)的任务不重复归档 | |||
| PatrolTaskResultMain existMain = patrolTaskResultMainService | |||
| .selectPatrolTaskResultMainByTaskPatrolledId(archiveTaskPatrolledId); | |||
| if (existMain != null && "1".equals(existMain.getFileStatus())) { | |||
| log.info("[AUTO_ARCHIVE] 任务 {} 已归档(fileStatus=1),跳过", archiveTaskPatrolledId); | |||
| releaseLock(lockKey); | |||
| return; | |||
| } | |||
| // 归档范围:主任务 + 其下所有子任务(联合任务) | |||
| List<String> taskPatrolledIds = new ArrayList<>(); | |||
| taskPatrolledIds.add(archiveTaskPatrolledId); | |||
| List<PatrolTaskStatus> subTasks = patrolTaskStatusService.selectPatrolTaskStatusListByMainId(archiveTaskPatrolledId); | |||
| if (subTasks != null) { | |||
| for (PatrolTaskStatus subTask : subTasks) { | |||
| if (StringUtils.isNotEmpty(subTask.getTaskPatrolledId())) { | |||
| taskPatrolledIds.add(subTask.getTaskPatrolledId()); | |||
| } | |||
| } | |||
| } | |||
| // 触发归档(动环 Word 报告),报告生成后由 exportDonghuanReport 回调推送 | |||
| PatrolTaskResultMain resultMain = new PatrolTaskResultMain(); | |||
| resultMain.setTaskPatrolledIds(taskPatrolledIds); | |||
| resultMain.setCheckPerson(properties.getCheckPerson()); | |||
| resultMain.setTaskResult(properties.getTaskResult()); | |||
| log.info("[AUTO_ARCHIVE] 触发自动归档: taskPatrolledIds={}, checkPerson={}, taskResult={}", | |||
| taskPatrolledIds, resultMain.getCheckPerson(), resultMain.getTaskResult()); | |||
| donghuanReportExportController.updateInfo_donghuan(resultMain); | |||
| } catch (Exception e) { | |||
| log.error("[AUTO_ARCHIVE] 自动归档异常: taskPatrolledId={}", | |||
| taskStatus == null ? null : taskStatus.getTaskPatrolledId(), e); | |||
| } | |||
| } | |||
| /** | |||
| * 校验任务(含子任务)是否存在缺陷数据(初筛缺陷 + 大模型缺陷) | |||
| */ | |||
| private boolean hasDefect(List<String> taskPatrolledIds) { | |||
| try { | |||
| Map<String, ResultAnalysis> abnormalMap = resultAnalysisService.selectTaskAbnormalDataBatch(taskPatrolledIds); | |||
| if (abnormalMap == null || abnormalMap.isEmpty()) { | |||
| return false; | |||
| } | |||
| int csQxNum = 0, aiQxNum = 0; | |||
| for (ResultAnalysis analysis : abnormalMap.values()) { | |||
| if (analysis == null) { | |||
| continue; | |||
| } | |||
| csQxNum += analysis.getCsQxNum(); | |||
| aiQxNum += analysis.getAiQxNum(); | |||
| } | |||
| return csQxNum + aiQxNum > 0; | |||
| } catch (Exception e) { | |||
| log.error("[AUTO_ARCHIVE] 查询缺陷数据异常,按存在缺陷处理: taskPatrolledIds={}", taskPatrolledIds, e); | |||
| return true; | |||
| } | |||
| } | |||
| private void releaseLock(String lockKey) { | |||
| try { | |||
| redisService.deleteObject(lockKey); | |||
| } catch (Exception e) { | |||
| log.warn("[AUTO_ARCHIVE] 释放防重锁失败: {}", lockKey, e); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,148 @@ | |||
| package com.inspect.reportpush.service; | |||
| import com.alibaba.fastjson.JSONObject; | |||
| 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.partrolresult.mapper.PatrolResultMapper; | |||
| import com.inspect.reportpush.config.ReportPushProperties; | |||
| import com.inspect.task.mapper.PatrolTaskMapper; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| import org.springframework.stereotype.Service; | |||
| import java.util.Date; | |||
| import java.util.Map; | |||
| /** | |||
| * 巡检报告推送服务(先上报 RabbitMQ,由 inspect-main-api 的 | |||
| * {@link com.inspect.api.service.ReportUploadConsumer} 异步 HTTP 上传到外部平台, | |||
| * 见《盐源新能源智慧感知平台_巡检报告接口文档 v1.0.0》)。 | |||
| * <p> | |||
| * 报告归档(Word)生成完毕后,组装 reportInfo 并发布 report.upload.queue 消息, | |||
| * 消费者下载报告 docx 后 POST /jb-nessp-external/api/uploadReport 上传 | |||
| * (multipart/form-data:file + reportInfo,请求头携带 joinbright-token)。 | |||
| * | |||
| * @author inspect | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| public class ReportPushService { | |||
| private final ReportPushProperties properties; | |||
| private final ReportPushPublisher reportPushPublisher; | |||
| private final PatrolTaskMapper patrolTaskMapper; | |||
| private final PatrolResultMapper patrolResultMapper; | |||
| public ReportPushService(ReportPushProperties properties, | |||
| ReportPushPublisher reportPushPublisher, | |||
| PatrolTaskMapper patrolTaskMapper, | |||
| PatrolResultMapper patrolResultMapper) { | |||
| this.properties = properties; | |||
| this.reportPushPublisher = reportPushPublisher; | |||
| this.patrolTaskMapper = patrolTaskMapper; | |||
| this.patrolResultMapper = patrolResultMapper; | |||
| } | |||
| /** | |||
| * 报告生成并上传完成后推送。自动归档与手动归档均会触发。 | |||
| * | |||
| * @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; | |||
| } | |||
| if (!properties.isEnabled()) { | |||
| 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.getTaskPatrolledId()) ? report.getTaskPatrolledId() : ""); | |||
| 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={}, message={}", | |||
| report.getLineId(), filePath, username, message); | |||
| } | |||
| /** | |||
| * 场站ID:配置优先,其次取 basedata_station.station_no | |||
| */ | |||
| private String resolveStationId() { | |||
| if (StringUtils.isNotEmpty(properties.getStationId())) { | |||
| return properties.getStationId(); | |||
| } | |||
| try { | |||
| String stationNo = patrolTaskMapper.selectStationNo(); | |||
| return stationNo == null ? "" : stationNo; | |||
| } catch (Exception e) { | |||
| log.warn("[REPORT_PUSH] 查询场站编号失败: {}", e.getMessage()); | |||
| return ""; | |||
| } | |||
| } | |||
| /** | |||
| * 电站名称:报告记录优先,其次 basedata_station.station_name | |||
| */ | |||
| private String resolveStationName(InspectionReport report) { | |||
| if (report != null && StringUtils.isNotEmpty(report.getStationName())) { | |||
| return report.getStationName(); | |||
| } | |||
| try { | |||
| Map<String, String> 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()); | |||
| } | |||
| return ""; | |||
| } | |||
| /** | |||
| * 巡检类型:配置优先,默认动环智能巡检 | |||
| */ | |||
| private String resolveInspectType() { | |||
| return StringUtils.isNotEmpty(properties.getInspectType()) ? properties.getInspectType() : "动环智能巡检"; | |||
| } | |||
| } | |||
| @ -0,0 +1,69 @@ | |||
| <?xml version="1.0" encoding="UTF-8" ?> | |||
| <!DOCTYPE mapper | |||
| PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |||
| "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |||
| <mapper namespace="com.inspect.insreporttask.mapper.InspectionReportTaskMapper"> | |||
| <resultMap type="com.inspect.insreport.domain.InspectionReport" id="InspectionReportResult"> | |||
| <result property="lineId" column="line_id"/> | |||
| <result property="stationName" column="station_name"/> | |||
| <result property="voltLevel" column="volt_level"/> | |||
| <result property="stationType" column="station_type"/> | |||
| <result property="inspectionDate" column="inspection_date"/> | |||
| <result property="inspectionTaskName" column="inspection_task_name"/> | |||
| <result property="envInfo" column="env_info"/> | |||
| <result property="inspectionStartTime" column="inspection_start_time"/> | |||
| <result property="inspectionEndTime" column="inspection_end_time"/> | |||
| <result property="patrolStatistics" column="patrol_statistics"/> | |||
| <result property="checkPerson" column="check_person"/> | |||
| <result property="checkTime" column="check_time"/> | |||
| <result property="description" column="description"/> | |||
| <result property="taskId" column="task_id"/> | |||
| <result property="taskPatrolledId" column="task_patrolled_id"/> | |||
| <result property="taskResultId" column="task_result_id"/> | |||
| <result property="filePath" column="file_path"/> | |||
| <result property="filter" column="filter"/> | |||
| <result property="location" column="location"/> | |||
| </resultMap> | |||
| <insert id="batchInsertInspectionReportTask" parameterType="java.util.List"> | |||
| insert into inspection_report_task | |||
| (report_line_id, task_result_line_id, task_patrolled_id, task_name) | |||
| values | |||
| <foreach collection="list" item="t" index="index" separator=","> | |||
| (#{t.reportLineId}, #{t.taskResultLineId}, #{t.taskPatrolledId}, #{t.taskName}) | |||
| </foreach> | |||
| </insert> | |||
| <select id="selectInspectionReportsByTaskPatrolledId" resultMap="InspectionReportResult"> | |||
| select r.line_id, | |||
| r.task_id, | |||
| r.task_patrolled_id, | |||
| r.file_path, | |||
| r.task_result_id, | |||
| r.station_name, | |||
| r.volt_level, | |||
| r.station_type, | |||
| r.location, | |||
| r.inspection_date, | |||
| r.inspection_task_name, | |||
| r.env_info, | |||
| r.inspection_start_time, | |||
| r.inspection_end_time, | |||
| r.patrol_statistics, | |||
| r.check_person, | |||
| r.check_time, | |||
| r.description, | |||
| r.filter | |||
| from inspection_report_task t | |||
| join inspection_report r on r.line_id = t.report_line_id | |||
| where t.task_patrolled_id = #{taskPatrolledId} | |||
| <if test="filter != null and filter != ''">and r.filter = #{filter}</if> | |||
| order by r.line_id desc | |||
| </select> | |||
| <select id="selectTaskPatrolledIdsByReportLineId" resultType="java.lang.String"> | |||
| select task_patrolled_id from inspection_report_task | |||
| where report_line_id = #{reportLineId} order by id | |||
| </select> | |||
| </mapper> | |||
| @ -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="仿宋_GB2312"><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"/> | |||