Compare commits

...

3 Commits

Author SHA1 Message Date
7d3130c4fe feat: 修改隐患排查计划列表待审核人数统计逻辑,改为只统计审核中(status=2,5)的数量 2026-07-28 16:47:10 +08:00
96d7f8d803 feat(隐患排查计划): 新增待审核人数统计字段及计算逻辑 2026-07-28 15:56:27 +08:00
0adba6f5cb feat(photo-evidence): 实现图片水印异常修复全流程功能
抽离并重构原有图片水印生成逻辑,统一输出jpeg格式,避免因异常文件后缀产生坏数据
新增异常水印图片的分页查询、预览确认、单条修复、批量异步修复及任务状态查询接口
新增对应的控制器、业务服务类与数据传输对象
补充图片水印异常修复的完整方案文档
2026-07-01 23:02:00 +08:00
18 changed files with 1635 additions and 148 deletions

View File

@@ -0,0 +1,421 @@
# 图片水印后缀异常兜底与补救方案
## 1. 背景
现有图片取证水印流程位于 [PhotoEvidenceServiceImpl.java:L51-L149](file:///g:/code/shw/hot/wucaixing-full/wucaixing-backend/src/main/java/com/hotwj/platform/common/photoEvidence/service/impl/PhotoEvidenceServiceImpl.java#L51-L149)。
当前已确认的问题链路如下:
- 前端上传 OSS 时,部分原始图片没有正确的文件后缀名。
- 原始 `sys_oss.file_suffix` 被写成了 `2``3``.2``.3` 这一类异常值。
- 后端在生成水印图时,直接使用原始 `file_suffix` 作为 `ImageIO.write` 的输出格式。
- 生成的临时文件可能为空文件,最终上传到 OSS 后变成 `0B`
- 新生成的 `sys_oss` 记录中,`ext1.fileSize=0``contentType=null`,页面预览和下载均异常。
示例数据可见 [data.json:L1-L35](file:///g:/code/shw/hot/wucaixing-full/data.json#L1-L35)
- 原图记录 `file_suffix=3`,但 `ext1.contentType=image/jpeg`
- 水印图记录 `file_suffix=.3``ext1.fileSize=0``contentType=null`
当前风险点位于 [PhotoEvidenceServiceImpl.java:L137-L149](file:///g:/code/shw/hot/wucaixing-full/wucaixing-backend/src/main/java/com/hotwj/platform/common/photoEvidence/service/impl/PhotoEvidenceServiceImpl.java#L137-L149)
,源码中也已经有 `TODO` 提示需要判断 `mime`
## 2. 根因分析
本问题的根因不是“没有后缀名”本身,而是“后端把不可信的后缀名继续当成图片编码格式使用”。
具体来说:
- 原始图片是否可读,真正应该依据的是二进制内容和 MIME不应该只依赖文件名后缀。
- 水印图不是前端上传文件,而是后端重新生成的系统产物,其输出格式应由后端统一决定。
- 只要继续信任 `sys_oss.file_suffix`,就会持续受到历史脏数据和前端上传异常的影响。
因此,兜底思路必须从“猜正确后缀”改成“后端固定输出规范”。
## 3. 线上兜底方案
### 3.1 目标
阻断新增坏数据,确保后续所有水印图都能稳定生成、上传、展示和下载。
### 3.2 方案原则
- 原图校验看内容,不看后缀。
- 水印图输出格式由后端统一指定,不继承原图后缀。
- 上传时必须带明确的 `contentType`
- 入库前必须校验结果文件非空。
### 3.3 推荐方案
建议将“图片取证水印图”统一输出为 `jpg/jpeg`
原因:
-
当前水印结果图在 [PhotoEvidenceServiceImpl.java:L261-L283](file:///g:/code/shw/hot/wucaixing-full/wucaixing-backend/src/main/java/com/hotwj/platform/common/photoEvidence/service/impl/PhotoEvidenceServiceImpl.java#L261-L283)
中已经固定为 `BufferedImage.TYPE_INT_RGB`,天然适合输出为 `JPEG`
- 业务场景是现场拍照取证,统一输出 `jpg` 与实际使用习惯一致。
- 可以彻底摆脱 `2``3``.2``.3` 这一类脏后缀的影响。
- 后续历史修复和排障标准会非常统一。
### 3.4 兜底规则
建议按以下规则处理:
1. 下载原始 OSS 文件后,先尝试按图片内容解析。
2. 只要原图字节能正常解析成图片,就允许继续加水印。
3. 水印结果图统一编码为 `jpeg`
4. 临时文件统一使用 `.jpg` 后缀。
5. 上传 OSS 时统一使用 `contentType=image/jpeg`
6. 上传前必须校验:
- `ImageIO.write` 返回成功
- 临时文件存在
- 临时文件大小大于 `0`
7. 任一校验失败,直接报错,不创建 `HotPhotoEvidence` 记录,不创建新的水印 `sys_oss` 记录。
### 3.5 兜底后的收益
- 前端即使继续上传无后缀文件,后端仍可稳定产出标准水印图。
- 生成的水印图始终具备标准后缀和标准 `contentType`
- 不再出现 `.2``.3` 这类系统生成的异常文件。
- 新增数据和历史补救数据将收敛到同一规范。
## 4. 历史补救总原则
### 4.1 必须满足的约束
本次历史补救必须满足以下硬约束:
- 必须替换原有坏掉的 `sys_oss` 记录内容。
- 必须保持原 `oss_id` 不变。
- 不能只改 `HotPhotoEvidence` 表中的 `watermarked_oss_id` 指向。
- 不能新增一条新的 `sys_oss` 记录再让业务表改引用。
原因:
- 这个 `oss_id` 不仅存在于 `HotPhotoEvidence`,还可能被其他业务表、日志记录、导出记录、接口缓存、前端页面配置等位置复用。
- 如果改成新 `oss_id`,补救范围会迅速扩散到未知引用方,风险不可控。
- 保持原 `oss_id` 不变,才能把历史补救范围收敛在“文件对象替换 + 原记录修补”这一层。
### 4.2 正确的补救思路
正确思路不是“替换原始图的 `sys_oss` 记录”,而是:
-`HotPhotoEvidence` 为主表定位坏掉的水印记录。
- 通过 `original_oss_id` 找回原始图片。
- 重新生成一张标准的水印图。
- 上传到 OSS 获得新的对象文件。
- 然后覆盖原 `watermarked_oss_id` 对应的 `sys_oss` 记录内容。
- `oss_id` 保持不变,只更新该记录指向的对象信息和元数据。
换句话说:
- 替换的是“坏水印图对应的 `sys_oss` 内容”
- 保留的是“该 `sys_oss` 的主键 `oss_id`
## 5. 历史补救识别范围
建议先做数据筛查,再执行修补。
### 5.1 强特征
以下记录可直接视为高风险坏数据:
- `sys_oss.ext1.fileSize = 0`
- `sys_oss.file_suffix` 为纯数字,如 `2``3`
- `sys_oss.file_suffix``.数字`,如 `.2``.3`
- `sys_oss.original_name``photo_evidence_xxx.2``photo_evidence_xxx.3`
- `sys_oss.ext1.contentType is null`
### 5.2 建议以 `HotPhotoEvidence` 为主表驱动
原因:
- 该表已经保留了 `originalOssId``watermarkedOssId`
- 已经保留了 `captureTimeClient``captureTimeServer`
- 已经保留了 `latitude``longitude`
- 已经保留了 `addressTextServer``addressTextClient``addressSource`
这些字段足够支撑“按历史状态重建水印图”,不需要重新依赖前端数据。
## 6. 历史补救执行方案
### 6.1 核心目标
在不改变 `watermarked_oss_id``oss_id` 的前提下,修复坏掉的水印图对象和其 `sys_oss` 元数据。
### 6.2 推荐执行步骤
1. 根据 `HotPhotoEvidence.watermarked_oss_id` 关联 `sys_oss`,筛出疑似坏水印记录。
2. 按记录读取 `original_oss_id` 对应的原始图。
3. 使用历史记录中的 `captureTimeClient` 作为水印时间。
4. 水印地址按以下优先级确定:
-`addressSource=SERVER`,取 `addressTextServer`
- 否则取 `addressTextClient`
5. 不重新调用逆地理接口,不重算地址。
6. 按统一规范重新生成 `jpg` 水印图。
7. 先上传到 OSS 新对象路径,确认对象可访问且大小正常。
8. 用新对象信息覆盖旧的 `sys_oss` 记录:
- `file_name`
- `url`
- `file_suffix`
- `original_name`
- `ext1.fileSize`
- `ext1.contentType`
- 如有必要同步更新时间和更新人
9. `oss_id` 保持不变。
10. 数据更新成功后,再删除旧的坏对象文件,或先保留待统一清理。
### 6.3 必须覆盖的字段
覆盖原 `sys_oss` 记录时,建议至少同步修正以下内容:
- `file_name`:替换为新上传对象键
- `url`:替换为新对象地址
- `file_suffix`:统一改为 `.jpg`
- `original_name`:统一规范化,例如 `photo_evidence_{ossId}.jpg`
- `ext1.fileSize`:更新为真实大小
- `ext1.contentType`:更新为 `image/jpeg`
如有以下字段被业务使用,也建议同步补齐:
- `ext1.source`
- `ext1.remark`
- `update_time`
- `update_by`
### 6.4 为什么不要重新生成新的 `sys_oss` 记录
- 会引入新的 `oss_id`
- 会导致其他业务表引用失效
- 会让修复脚本必须跨全库改引用
- 很难保证没有漏网表
- 回滚和审计也会变复杂
因此,历史补救必须采用“新对象文件 + 原记录覆盖”的模式。
## 7. 前端可视化补救方案
为了让补救方案易于操作,建议将历史补救放到后台管理界面,而不是只做一次性脚本。
### 7.1 推荐入口
建议新增后台管理页面,例如:
- 菜单名称:`水印图片修复`
- 所属模块:`系统管理``附件管理`
- 页面定位:面向运维、实施、研发,不对普通业务用户开放
前端落点建议明确放到维护模块中,参考现有维护页 [index.vue](file:///g:/code/shw/hot/wucaixing-full/wucaixing-frontend/src/views/system/maintenance/index.vue)
的维护项列表能力。
建议方案:
- 在前端维护模块 `wucaixing-frontend/src/views/system/maintenance/index.vue` 中新增一个维护项,例如 `图片水印修复`
- 点击维护项后进入专门的修复弹窗或修复页面
- 该页面承载异常筛选、预览比对、勾选记录、单条修复、批量修复、任务进度和结果查看
这样可以与现有 `媒体资源元数据修复` 的运维入口保持一致,便于统一权限、统一操作习惯、统一审计。
### 7.2 列表页建议字段
列表页建议展示以下信息,方便快速识别和人工核验:
- `evidenceId`
- `watermarkedOssId`
- `originalOssId`
- 原始图片缩略图
- 当前水印图缩略图
- `sys_oss.file_suffix`
- `ext1.fileSize`
- `contentType`
- `captureTimeClient`
- `addressSource`
- 地址文本
- 公司 / 业务类型 / 业务 ID
- 创建时间
- 修复状态
- 修复结果说明
### 7.3 列表页筛选条件
建议支持以下筛选项:
- 仅看异常记录
-`watermarkedOssId`
-`originalOssId`
- 按公司
- 按业务类型
- 按时间范围
-`fileSize=0`
-`contentType为空`
-`file_suffix` 异常
- 按修复状态
### 7.4 页面操作按钮
建议至少提供以下操作:
- `预览原图`
- `预览当前水印图`
- `生成修复预览`
- `勾选当前记录`
- `批量勾选筛选结果`
- `清空勾选`
- `执行单条修复`
- `批量修复已勾选记录`
- `导出异常清单`
- `查看修复日志`
补充要求:
- 不允许用户在未查看修复前后效果的情况下直接发起批量修复
- 批量修复应以“用户已勾选且已完成预览确认的记录”为执行范围,而不是直接对整个筛选结果无差别执行
### 7.5 单条修复建议流程
单条修复推荐流程如下:
1. 用户点开一条异常记录。
2. 页面展示:
- 原图预览
- 当前坏水印图预览
- 拟修复后的新水印预览
- 修复前后元数据对比
3. 用户点击 `确认修复`
4. 后端执行:
- 读取原图
- 按历史时间与地址重新加水印
- 上传新 OSS 对象
- 覆盖原 `sys_oss` 记录
- 记录操作日志
5. 页面回显修复结果。
这个流程适合先做试点修复和人工验证。
### 7.6 批量修复建议流程
批量修复推荐流程如下:
1. 用户先通过筛选条件锁定异常数据范围。
2. 页面展示总数、可修复数、疑似失败风险数,并支持逐条勾选待修复记录。
3. 用户先对准备修复的记录执行预览,页面必须展示:
- 原图
- 当前坏水印图
- 拟修复后的新水印图
- 修复前后元数据差异
4. 用户确认预览效果无误后,勾选本次真正要修复的记录。
5. 用户点击 `批量修复已勾选记录` 前,系统弹出二次确认,并明确显示本次修复条数。
6. 后端采用任务方式异步执行,页面可轮询查看进度。
7. 每条记录输出修复结果:
- 成功
- 原图不存在
- 原图无法解析
- 新水印生成失败
- OSS 上传失败
-`sys_oss` 覆盖失败
8. 任务完成后支持导出结果明细。
这里的关键要求是:
- 批量修复之前必须先有“预览确认”步骤
- 批量修复的对象必须是“用户主动勾选的记录”
- 不能仅凭筛选条件直接全量执行修复任务
### 7.7 为什么前端界面比一次性脚本更适合
- 更适合先小批量验证,再逐步扩大范围
- 操作人员无需直接跑脚本和改 SQL
- 每条记录可预览、可追踪、可重试
- 更方便做权限控制和操作审计
- 后续若再出现新脏数据,也可以复用同一页面处理
## 8. 后端接口建议
为支撑前端可视化操作,建议至少提供以下几类接口:
### 8.1 异常列表接口
用途:
- 按筛选条件查询疑似异常水印记录
- 返回分页列表和异常原因标签
### 8.2 预览接口
用途:
- 根据 `evidenceId``watermarkedOssId` 生成“拟修复水印图”的临时预览
- 不落库,不覆盖 `sys_oss`
### 8.3 单条修复接口
用途:
- 对单条异常记录执行正式修复
- 修复成功后覆盖原 `sys_oss`
### 8.4 批量修复任务接口
用途:
- 创建批量修复任务
- 入参必须是前端勾选确认后的记录 ID 列表,而不是只有筛选条件
- 后台异步执行
- 支持查询进度、成功数、失败数、失败原因
### 8.5 操作日志接口
用途:
- 记录谁在什么时间修复了哪条记录
- 保留修复前后对象信息,便于追溯
## 9. 操作审计与回滚建议
### 9.1 建议记录的审计信息
每次修复建议记录:
- 操作人
- 操作时间
- `evidenceId`
- `watermarkedOssId`
-`file_name`
-`file_name`
-`url`
-`url`
-`ext1`
-`ext1`
- 修复结果
- 错误信息
### 9.2 回滚方式
由于要求保持原 `oss_id` 不变,回滚方案建议也按“对象替换”处理:
1. 修复前先记录旧对象信息。
2. 如修复后发现异常,可重新把旧对象重新上传或恢复。
3. 再次覆盖同一个 `sys_oss` 记录,使其回到修复前状态。
也就是说,回滚不是改回旧 `oss_id`,而是把同一个 `oss_id` 再次指回旧对象。
## 10. 推荐实施顺序
建议按以下顺序落地:
1. 先完成线上兜底,阻断新增坏数据。
2. 再开发后台可视化补救页面和补救接口。
3. 先选少量样本做单条修复验证。
4. 确认预览、下载、页面展示全部正常后,再进行批量修复。
5. 批量修复完成后,统一清理被替换掉的坏 OSS 对象。
## 11. 最终结论
本问题的正确处理方式是:
- 线上新增场景:后端不再信任原始上传后缀,水印图统一输出为 `jpg/jpeg`
- 历史补救场景:以 `HotPhotoEvidence` 为主表重建水印图,但不能改 `oss_id`,必须覆盖原坏水印图对应的 `sys_oss` 记录。
- 落地形式:优先做成后台管理可视化能力,支持异常筛选、预览、单条修复、批量修复、日志追踪。
该方案既能快速止血,也能在不扩散引用风险的前提下,稳妥完成历史数据修复。

View File

@@ -2,9 +2,22 @@ package com.hotwj.platform.common.photoEvidence.mapper;
import com.hotwj.platform.common.photoEvidence.domain.HotPhotoEvidence; import com.hotwj.platform.common.photoEvidence.domain.HotPhotoEvidence;
import com.hotwj.platform.common.photoEvidence.domain.vo.PhotoEvidenceVo; import com.hotwj.platform.common.photoEvidence.domain.vo.PhotoEvidenceVo;
import com.hotwj.platform.system.maintenance.domain.bo.PhotoEvidenceRepairQueryBo;
import com.hotwj.platform.system.maintenance.domain.vo.PhotoEvidenceRepairCandidateVo;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
import java.util.List;
@Mapper @Mapper
public interface HotPhotoEvidenceMapper extends BaseMapperPlus<HotPhotoEvidence, PhotoEvidenceVo> { public interface HotPhotoEvidenceMapper extends BaseMapperPlus<HotPhotoEvidence, PhotoEvidenceVo> {
long countRepairCandidates(@Param("query") PhotoEvidenceRepairQueryBo query);
List<PhotoEvidenceRepairCandidateVo> selectRepairCandidates(@Param("query") PhotoEvidenceRepairQueryBo query,
@Param("offset") long offset,
@Param("pageSize") long pageSize);
PhotoEvidenceRepairCandidateVo selectRepairCandidateByEvidenceId(@Param("evidenceId") Long evidenceId);
} }

View File

@@ -6,6 +6,7 @@ import com.hotwj.platform.common.photoEvidence.domain.bo.PhotoEvidenceCreateBo;
import com.hotwj.platform.common.photoEvidence.domain.vo.PhotoEvidenceVo; import com.hotwj.platform.common.photoEvidence.domain.vo.PhotoEvidenceVo;
import com.hotwj.platform.common.photoEvidence.mapper.HotPhotoEvidenceMapper; import com.hotwj.platform.common.photoEvidence.mapper.HotPhotoEvidenceMapper;
import com.hotwj.platform.common.photoEvidence.service.IPhotoEvidenceService; import com.hotwj.platform.common.photoEvidence.service.IPhotoEvidenceService;
import com.hotwj.platform.common.photoEvidence.support.PhotoEvidenceWatermarkSupport;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.enums.FormatsType; import org.dromara.common.core.enums.FormatsType;
@@ -22,16 +23,12 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import javax.imageio.ImageIO; import java.io.ByteArrayOutputStream;
import java.awt.*; import java.io.InputStream;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.List;
@Slf4j @Slf4j
@Service @Service
@@ -43,6 +40,9 @@ public class PhotoEvidenceServiceImpl implements IPhotoEvidenceService {
@Resource @Resource
private ISysOssService ossService; private ISysOssService ossService;
@Resource
private PhotoEvidenceWatermarkSupport watermarkSupport;
@Value("${map.gd.key:}") @Value("${map.gd.key:}")
private String gdKey; private String gdKey;
@@ -129,30 +129,19 @@ public class PhotoEvidenceServiceImpl implements IPhotoEvidenceService {
Date captureTimeClient, Date captureTimeClient,
String address) { String address) {
byte[] originalBytes = downloadOssFile(originalOss); byte[] originalBytes = downloadOssFile(originalOss);
BufferedImage originalImage = readImage(originalBytes); PhotoEvidenceWatermarkSupport.RenderedWatermark renderedWatermark = null;
BufferedImage watermarked = addWatermark(originalImage, captureTimeClient, address);
File tempFile = null;
try { try {
String suffix = originalOss.getFileSuffix(); renderedWatermark = watermarkSupport.renderToTempFile(originalBytes, captureTimeClient, address);
if (StringUtils.isBlank(suffix)) { OssClient storage = OssFactory.instance();
suffix = "jpg"; String objectKey = storage.getPath("photo-evidence", PhotoEvidenceWatermarkSupport.OUTPUT_SUFFIX);
} return ossService.upload(
if (suffix.startsWith(".")) { renderedWatermark.getFile(),
suffix = suffix.substring(1); objectKey,
} PhotoEvidenceWatermarkSupport.OUTPUT_CONTENT_TYPE
tempFile = File.createTempFile("photo_evidence_", "." + suffix); ).getOssId();
try (OutputStream os = new FileOutputStream(tempFile)) {
ImageIO.write(watermarked, suffix, os);
}
return ossService.upload(tempFile).getOssId();
} catch (IOException e) {
throw new ServiceException("水印图片生成失败");
} finally { } finally {
if (tempFile != null && tempFile.exists()) { if (renderedWatermark != null) {
if (!tempFile.delete()) { watermarkSupport.deleteQuietly(renderedWatermark.getFile());
log.warn("临时文件删除失败: {}", tempFile.getAbsolutePath());
}
} }
} }
} }
@@ -167,124 +156,6 @@ public class PhotoEvidenceServiceImpl implements IPhotoEvidenceService {
} }
} }
private BufferedImage readImage(byte[] bytes) {
try (InputStream is = new ByteArrayInputStream(bytes)) {
BufferedImage image = ImageIO.read(is);
if (image == null) {
String hex = "";
if (bytes.length >= 4) {
hex = String.format("%02X%02X%02X%02X", bytes[0], bytes[1], bytes[2], bytes[3]);
}
log.error("ImageIO.read returned null. Header: {}", hex);
throw new ServiceException("原始图片格式不支持 (Header: " + hex + ")");
}
return image;
} catch (IOException e) {
log.error("图片解析失败", e);
throw new ServiceException("原始图片解析失败: " + e.getMessage());
}
}
private BufferedImage addWatermark(BufferedImage original,
Date captureTimeClient,
String address) {
int width = original.getWidth();
int height = original.getHeight();
// 强制最小画布宽度,防止文字区域过窄
int minWidth = 600;
if (width < minWidth) {
int newWidth = minWidth;
int newHeight = (int) Math.round((double) height * newWidth / width);
BufferedImage resized = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D gResize = resized.createGraphics();
gResize.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
gResize.drawImage(original, 0, 0, newWidth, newHeight, null);
gResize.dispose();
original = resized;
width = newWidth;
height = newHeight;
}
// 字体大小随宽度自适应(设置最小值)
int fontSize = Math.max(20, width / 30);
int padding = Math.max(fontSize, 16);
String timeText = DateUtils.parseDateToStr(FormatsType.YYYY_MM_DD_HH_MM_SS, captureTimeClient);
String addressText = "地址:" + (address != null ? address : "");
// 构建字体并测量
Font font;
try (InputStream is = getClass().getResourceAsStream("/fonts/simsun.ttc")) {
if (is == null) {
font = new Font("SansSerif", Font.PLAIN, fontSize);
} else {
font = Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(Font.PLAIN, fontSize);
}
} catch (Exception e) {
log.warn("Failed to load simsun font, fallback to SansSerif", e);
font = new Font("SansSerif", Font.PLAIN, fontSize);
}
// 使用临时图形上下文计算文字宽度与行高
BufferedImage tmp = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
Graphics2D gMeasure = tmp.createGraphics();
gMeasure.setFont(font);
FontMetrics fm = gMeasure.getFontMetrics();
int contentWidth = Math.max(1, width - padding * 2);
// 构造文本行:第一行时间,后续为地址按宽度换行
List<String> lines = new ArrayList<>();
lines.add(timeText);
if (fm.stringWidth(addressText) <= contentWidth) {
lines.add(addressText);
} else {
StringBuilder current = new StringBuilder();
for (char c : addressText.toCharArray()) {
if (fm.stringWidth(current.toString() + c) > contentWidth) {
lines.add(current.toString());
current = new StringBuilder();
}
current.append(c);
}
if (current.length() > 0) {
lines.add(current.toString());
}
}
int lineHeight = fm.getHeight() + fontSize / 4;
gMeasure.dispose();
// 计算白条高度,确保所有行完整显示
int barHeight = Math.max(lineHeight + padding * 2, lines.size() * lineHeight + padding * 2);
// 输出结果图
BufferedImage result = new BufferedImage(width, height + barHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = result.createGraphics();
try {
// 绘制原图
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(original, 0, 0, null);
// 绘制底部白条
g.setColor(Color.WHITE);
g.fillRect(0, height, width, barHeight);
// 绘制文本
g.setFont(font);
g.setColor(new Color(51, 51, 51));
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
int y = height + padding + fm.getAscent();
for (String line : lines) {
g.drawString(line, padding, y);
y += lineHeight;
}
return result;
} finally {
g.dispose();
}
}
private String reverseGeocode(Double latitude, Double longitude) { private String reverseGeocode(Double latitude, Double longitude) {
if (latitude == null || longitude == null) { if (latitude == null || longitude == null) {
return null; return null;

View File

@@ -0,0 +1,209 @@
package com.hotwj.platform.common.photoEvidence.support;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.enums.FormatsType;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.DateUtils;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 照片取证水印图统一生成组件。
* 无论原图文件名或后缀是否异常,系统产出的水印图都固定为 jpg/jpeg。
*/
@Slf4j
@Component
public class PhotoEvidenceWatermarkSupport {
public static final String OUTPUT_FORMAT = "jpeg";
public static final String OUTPUT_SUFFIX = ".jpg";
public static final String OUTPUT_CONTENT_TYPE = "image/jpeg";
public RenderedWatermark renderToTempFile(byte[] originalBytes, Date captureTimeClient, String address) {
BufferedImage watermarked = renderImage(originalBytes, captureTimeClient, address);
File tempFile = null;
try {
tempFile = File.createTempFile("photo_evidence_", OUTPUT_SUFFIX);
boolean writeSuccess;
try (OutputStream os = new FileOutputStream(tempFile)) {
writeSuccess = ImageIO.write(watermarked, OUTPUT_FORMAT, os);
}
validateWriteResult(writeSuccess, tempFile);
return new RenderedWatermark(tempFile, tempFile.length());
} catch (IOException ex) {
deleteQuietly(tempFile);
throw new ServiceException("水印图片生成失败: " + ex.getMessage());
}
}
public byte[] renderToBytes(byte[] originalBytes, Date captureTimeClient, String address) {
BufferedImage watermarked = renderImage(originalBytes, captureTimeClient, address);
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
boolean writeSuccess = ImageIO.write(watermarked, OUTPUT_FORMAT, outputStream);
if (!writeSuccess || outputStream.size() <= 0) {
throw new ServiceException("水印图片生成失败");
}
return outputStream.toByteArray();
} catch (IOException ex) {
throw new ServiceException("水印图片生成失败: " + ex.getMessage());
}
}
public String buildStandardOriginalName(Long ossId) {
if (ossId == null) {
return "photo_evidence" + OUTPUT_SUFFIX;
}
return "photo_evidence_" + ossId + OUTPUT_SUFFIX;
}
private BufferedImage renderImage(byte[] originalBytes, Date captureTimeClient, String address) {
BufferedImage originalImage = readImage(originalBytes);
return addWatermark(originalImage, captureTimeClient, address);
}
private void validateWriteResult(boolean writeSuccess, File tempFile) {
if (!writeSuccess) {
throw new ServiceException("水印图片生成失败: 未找到可用的 JPEG 编码器");
}
if (tempFile == null || !tempFile.exists()) {
throw new ServiceException("水印图片生成失败: 临时文件不存在");
}
if (tempFile.length() <= 0) {
throw new ServiceException("水印图片生成失败: 临时文件为空");
}
}
private BufferedImage readImage(byte[] bytes) {
try (InputStream is = new ByteArrayInputStream(bytes)) {
BufferedImage image = ImageIO.read(is);
if (image == null) {
String hex = "";
if (bytes.length >= 4) {
hex = String.format("%02X%02X%02X%02X", bytes[0], bytes[1], bytes[2], bytes[3]);
}
log.error("ImageIO.read returned null. Header: {}", hex);
throw new ServiceException("原始图片格式不支持 (Header: " + hex + ")");
}
return image;
} catch (IOException e) {
log.error("图片解析失败", e);
throw new ServiceException("原始图片解析失败: " + e.getMessage());
}
}
private BufferedImage addWatermark(BufferedImage original, Date captureTimeClient, String address) {
int width = original.getWidth();
int height = original.getHeight();
int minWidth = 600;
if (width < minWidth) {
int newWidth = minWidth;
int newHeight = (int) Math.round((double) height * newWidth / width);
BufferedImage resized = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D gResize = resized.createGraphics();
gResize.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
gResize.drawImage(original, 0, 0, newWidth, newHeight, null);
gResize.dispose();
original = resized;
width = newWidth;
height = newHeight;
}
int fontSize = Math.max(20, width / 30);
int padding = Math.max(fontSize, 16);
String timeText = DateUtils.parseDateToStr(FormatsType.YYYY_MM_DD_HH_MM_SS, captureTimeClient);
String addressText = "地址:" + (address != null ? address : "");
Font font;
try (InputStream is = getClass().getResourceAsStream("/fonts/simsun.ttc")) {
if (is == null) {
font = new Font("SansSerif", Font.PLAIN, fontSize);
} else {
font = Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(Font.PLAIN, fontSize);
}
} catch (Exception e) {
log.warn("Failed to load simsun font, fallback to SansSerif", e);
font = new Font("SansSerif", Font.PLAIN, fontSize);
}
BufferedImage tmp = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
Graphics2D gMeasure = tmp.createGraphics();
gMeasure.setFont(font);
FontMetrics fm = gMeasure.getFontMetrics();
int contentWidth = Math.max(1, width - padding * 2);
List<String> lines = new ArrayList<>();
lines.add(timeText);
if (fm.stringWidth(addressText) <= contentWidth) {
lines.add(addressText);
} else {
StringBuilder current = new StringBuilder();
for (char c : addressText.toCharArray()) {
if (fm.stringWidth(current.toString() + c) > contentWidth) {
lines.add(current.toString());
current = new StringBuilder();
}
current.append(c);
}
if (current.length() > 0) {
lines.add(current.toString());
}
}
int lineHeight = fm.getHeight() + fontSize / 4;
gMeasure.dispose();
int barHeight = Math.max(lineHeight + padding * 2, lines.size() * lineHeight + padding * 2);
BufferedImage result = new BufferedImage(width, height + barHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = result.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(original, 0, 0, null);
g.setColor(Color.WHITE);
g.fillRect(0, height, width, barHeight);
g.setFont(font);
g.setColor(new Color(51, 51, 51));
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
int y = height + padding + fm.getAscent();
for (String line : lines) {
g.drawString(line, padding, y);
y += lineHeight;
}
return result;
} finally {
g.dispose();
}
}
public void deleteQuietly(File file) {
if (file == null || !file.exists()) {
return;
}
if (!file.delete()) {
log.warn("临时文件删除失败: {}", file.getAbsolutePath());
}
}
@Getter
public static class RenderedWatermark {
private final File file;
private final long fileSize;
public RenderedWatermark(File file, long fileSize) {
this.file = file;
this.fileSize = fileSize;
}
}
}

View File

@@ -127,5 +127,11 @@ public class HotHiddenDangerPlanVo implements Serializable {
@ExcelProperty(value = "未完成数") @ExcelProperty(value = "未完成数")
private Long pendingCount; private Long pendingCount;
/**
* 待审核人数
*/
@ExcelProperty(value = "待审核人数")
private Long pendingAuditCount;
} }

View File

@@ -0,0 +1,53 @@
package com.hotwj.platform.system.maintenance.controller;
import com.hotwj.platform.system.maintenance.domain.bo.PhotoEvidenceRepairExecuteRequest;
import com.hotwj.platform.system.maintenance.domain.bo.PhotoEvidenceRepairQueryBo;
import com.hotwj.platform.system.maintenance.domain.bo.PhotoEvidenceRepairTaskRequest;
import com.hotwj.platform.system.maintenance.domain.vo.*;
import com.hotwj.platform.system.maintenance.service.IPhotoEvidenceRepairService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.web.core.BaseController;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController
@RequestMapping("/system/maintenance/photo-evidence")
public class PhotoEvidenceMaintenanceController extends BaseController {
private final IPhotoEvidenceRepairService photoEvidenceRepairService;
@GetMapping("/list")
@Operation(summary = "分页查询异常图片水印记录")
public TableDataInfo<PhotoEvidenceRepairCandidateVo> list(PhotoEvidenceRepairQueryBo queryBo, PageQuery pageQuery) {
return photoEvidenceRepairService.queryPageList(queryBo, pageQuery);
}
@GetMapping("/preview/{evidenceId}")
@Operation(summary = "生成图片水印修复预览")
public R<PhotoEvidenceRepairPreviewVo> preview(@PathVariable Long evidenceId) {
return R.ok(photoEvidenceRepairService.preview(evidenceId));
}
@PostMapping("/repair")
@Operation(summary = "执行单条图片水印修复")
public R<PhotoEvidenceRepairExecuteVo> repair(@Valid @RequestBody PhotoEvidenceRepairExecuteRequest request) {
return R.ok(photoEvidenceRepairService.repair(request));
}
@PostMapping("/repair-task")
@Operation(summary = "创建批量图片水印修复任务")
public R<PhotoEvidenceRepairSubmitVo> submitRepairTask(@Valid @RequestBody PhotoEvidenceRepairTaskRequest request) {
return R.ok(photoEvidenceRepairService.submitRepairTask(request));
}
@GetMapping("/repair-task/{taskId}")
@Operation(summary = "查询图片水印修复任务状态")
public R<PhotoEvidenceRepairTaskStatusVo> getTaskStatus(@PathVariable String taskId) {
return R.ok(photoEvidenceRepairService.getTaskStatus(taskId));
}
}

View File

@@ -0,0 +1,17 @@
package com.hotwj.platform.system.maintenance.domain.bo;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class PhotoEvidenceRepairExecuteRequest {
@NotNull(message = "evidenceId不能为空")
private Long evidenceId;
/**
* 前端必须在预览确认后才能发起正式修复。
*/
@NotNull(message = "previewConfirmed不能为空")
private Boolean previewConfirmed;
}

View File

@@ -0,0 +1,34 @@
package com.hotwj.platform.system.maintenance.domain.bo;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.util.Date;
@Data
public class PhotoEvidenceRepairQueryBo {
private Long evidenceId;
private Long watermarkedOssId;
private Long originalOssId;
private Long companyId;
private String bizType;
private String bizId;
private Boolean onlyAbnormal;
private Boolean fileSizeZero;
private Boolean fileSuffixAbnormal;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date beginCreateTime;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endCreateTime;
}

View File

@@ -0,0 +1,20 @@
package com.hotwj.platform.system.maintenance.domain.bo;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Data
public class PhotoEvidenceRepairTaskRequest {
@NotEmpty(message = "evidenceIds不能为空")
private List<Long> evidenceIds;
/**
* 前端必须确保本次提交的记录都已经过人工预览确认。
*/
@NotNull(message = "previewConfirmed不能为空")
private Boolean previewConfirmed;
}

View File

@@ -0,0 +1,51 @@
package com.hotwj.platform.system.maintenance.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
@Data
public class PhotoEvidenceRepairCandidateVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long evidenceId;
private Long watermarkedOssId;
private Long originalOssId;
private Long companyId;
private String bizType;
private String bizId;
private Date captureTimeClient;
private String addressSource;
private String addressText;
private Date createTime;
private String watermarkedFileSuffix;
private String watermarkedOriginalName;
private String watermarkedExt1;
private String originalImageUrl;
private String currentWatermarkedUrl;
private Long currentFileSize;
private String currentContentType;
private List<String> abnormalReasons;
}

View File

@@ -0,0 +1,29 @@
package com.hotwj.platform.system.maintenance.domain.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PhotoEvidenceRepairExecuteVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long evidenceId;
private Long watermarkedOssId;
private String oldFileName;
private String newFileName;
private String newUrl;
private String message;
}

View File

@@ -0,0 +1,46 @@
package com.hotwj.platform.system.maintenance.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
@Data
public class PhotoEvidenceRepairPreviewVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long evidenceId;
private Long watermarkedOssId;
private Long originalOssId;
private String originalImageUrl;
private String currentWatermarkedUrl;
private String previewDataUrl;
private MetadataSnapshot currentMetadata;
private MetadataSnapshot targetMetadata;
private List<String> abnormalReasons;
@Data
public static class MetadataSnapshot implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String fileSuffix;
private String originalName;
private Long fileSize;
private String contentType;
}
}

View File

@@ -0,0 +1,23 @@
package com.hotwj.platform.system.maintenance.domain.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PhotoEvidenceRepairSubmitVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String taskId;
private Boolean accepted;
private String message;
}

View File

@@ -0,0 +1,55 @@
package com.hotwj.platform.system.maintenance.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
public class PhotoEvidenceRepairTaskStatusVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String taskId;
private String status;
private Integer total = 0;
private Integer processedCount = 0;
private Integer successCount = 0;
private Integer failCount = 0;
private Long startTime;
private Long endTime;
private Long durationMillis;
private String operatorName;
private String requestSummary;
private String message;
private List<Long> successEvidenceIds = new ArrayList<>();
private List<ErrorSample> errorSamples = new ArrayList<>();
@Data
public static class ErrorSample implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long evidenceId;
private Long watermarkedOssId;
private String reason;
}
}

View File

@@ -0,0 +1,21 @@
package com.hotwj.platform.system.maintenance.service;
import com.hotwj.platform.system.maintenance.domain.bo.PhotoEvidenceRepairExecuteRequest;
import com.hotwj.platform.system.maintenance.domain.bo.PhotoEvidenceRepairQueryBo;
import com.hotwj.platform.system.maintenance.domain.bo.PhotoEvidenceRepairTaskRequest;
import com.hotwj.platform.system.maintenance.domain.vo.*;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
public interface IPhotoEvidenceRepairService {
TableDataInfo<PhotoEvidenceRepairCandidateVo> queryPageList(PhotoEvidenceRepairQueryBo queryBo, PageQuery pageQuery);
PhotoEvidenceRepairPreviewVo preview(Long evidenceId);
PhotoEvidenceRepairExecuteVo repair(PhotoEvidenceRepairExecuteRequest request);
PhotoEvidenceRepairSubmitVo submitRepairTask(PhotoEvidenceRepairTaskRequest request);
PhotoEvidenceRepairTaskStatusVo getTaskStatus(String taskId);
}

View File

@@ -0,0 +1,502 @@
package com.hotwj.platform.system.maintenance.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.hotwj.platform.common.photoEvidence.domain.HotPhotoEvidence;
import com.hotwj.platform.common.photoEvidence.mapper.HotPhotoEvidenceMapper;
import com.hotwj.platform.common.photoEvidence.support.PhotoEvidenceWatermarkSupport;
import com.hotwj.platform.system.maintenance.domain.bo.PhotoEvidenceRepairExecuteRequest;
import com.hotwj.platform.system.maintenance.domain.bo.PhotoEvidenceRepairQueryBo;
import com.hotwj.platform.system.maintenance.domain.bo.PhotoEvidenceRepairTaskRequest;
import com.hotwj.platform.system.maintenance.domain.vo.*;
import com.hotwj.platform.system.maintenance.service.IPhotoEvidenceRepairService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.CacheNames;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.oss.core.OssClient;
import org.dromara.common.oss.entity.UploadResult;
import org.dromara.common.oss.factory.OssFactory;
import org.dromara.common.redis.utils.CacheUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.SysOss;
import org.dromara.system.domain.SysOssExt;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.mapper.SysOssMapper;
import org.dromara.system.service.ISysOssService;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.util.*;
import java.util.concurrent.ScheduledExecutorService;
@Slf4j
@Service
@RequiredArgsConstructor
public class PhotoEvidenceRepairServiceImpl implements IPhotoEvidenceRepairService {
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_PARTIAL_SUCCESS = "PARTIAL_SUCCESS";
private static final String STATUS_FAILED = "FAILED";
private static final String TASK_KEY_PREFIX = "photo_evidence_repair_task:";
private static final int MAX_ERROR_SAMPLES = 20;
private final HotPhotoEvidenceMapper hotPhotoEvidenceMapper;
private final SysOssMapper sysOssMapper;
private final ISysOssService ossService;
private final ScheduledExecutorService scheduledExecutorService;
private final PhotoEvidenceWatermarkSupport watermarkSupport;
@Override
public TableDataInfo<PhotoEvidenceRepairCandidateVo> queryPageList(PhotoEvidenceRepairQueryBo queryBo, PageQuery pageQuery) {
PhotoEvidenceRepairQueryBo normalizedQuery = normalizeQuery(queryBo);
PageQuery normalizedPage = normalizePage(pageQuery);
long total = hotPhotoEvidenceMapper.countRepairCandidates(normalizedQuery);
if (total <= 0) {
return new TableDataInfo<>(Collections.emptyList(), 0);
}
List<PhotoEvidenceRepairCandidateVo> rows = hotPhotoEvidenceMapper.selectRepairCandidates(
normalizedQuery,
normalizedPage.getFirstNum(),
normalizedPage.getPageSize()
);
fillCandidateRuntimeFields(rows);
return new TableDataInfo<>(rows, total);
}
@Override
public PhotoEvidenceRepairPreviewVo preview(Long evidenceId) {
HotPhotoEvidence evidence = requireEvidence(evidenceId);
SysOssVo originalOss = requireOss(evidence.getOriginalOssId(), "原始图片不存在");
SysOssVo watermarkedOss = requireOss(evidence.getWatermarkedOssId(), "水印图片不存在");
byte[] previewBytes = watermarkSupport.renderToBytes(
downloadOssFile(originalOss),
resolveCaptureTime(evidence),
resolveWatermarkAddress(evidence)
);
PhotoEvidenceRepairPreviewVo previewVo = new PhotoEvidenceRepairPreviewVo();
previewVo.setEvidenceId(evidence.getId());
previewVo.setOriginalOssId(evidence.getOriginalOssId());
previewVo.setWatermarkedOssId(evidence.getWatermarkedOssId());
previewVo.setOriginalImageUrl(originalOss.getUrl());
previewVo.setCurrentWatermarkedUrl(watermarkedOss.getUrl());
previewVo.setPreviewDataUrl("data:" + PhotoEvidenceWatermarkSupport.OUTPUT_CONTENT_TYPE + ";base64," +
Base64.getEncoder().encodeToString(previewBytes));
previewVo.setCurrentMetadata(buildMetadataSnapshot(watermarkedOss, parseExt(watermarkedOss.getExt1())));
previewVo.setTargetMetadata(buildTargetMetadata(watermarkedOss.getOssId(), previewBytes.length));
previewVo.setAbnormalReasons(resolveAbnormalReasons(watermarkedOss.getFileSuffix(), watermarkedOss.getExt1()));
return previewVo;
}
@Override
public PhotoEvidenceRepairExecuteVo repair(PhotoEvidenceRepairExecuteRequest request) {
requirePreviewConfirmed(request.getPreviewConfirmed());
return doRepair(request.getEvidenceId());
}
@Override
public PhotoEvidenceRepairSubmitVo submitRepairTask(PhotoEvidenceRepairTaskRequest request) {
requirePreviewConfirmed(request.getPreviewConfirmed());
List<Long> evidenceIds = normalizeEvidenceIds(request.getEvidenceIds());
if (evidenceIds.isEmpty()) {
throw new ServiceException("请选择需要修复的记录");
}
String taskId = UUID.randomUUID().toString().replace("-", "");
PhotoEvidenceRepairTaskStatusVo taskStatus = new PhotoEvidenceRepairTaskStatusVo();
taskStatus.setTaskId(taskId);
taskStatus.setStatus(STATUS_PENDING);
taskStatus.setTotal(evidenceIds.size());
taskStatus.setOperatorName(resolveOperatorName());
taskStatus.setRequestSummary("evidenceIds=" + evidenceIds.size());
taskStatus.setMessage("任务已提交,等待执行");
saveTask(taskStatus);
scheduledExecutorService.execute(() -> runTask(taskId, evidenceIds));
return new PhotoEvidenceRepairSubmitVo(taskId, Boolean.TRUE, "图片水印修复任务已提交");
}
@Override
public PhotoEvidenceRepairTaskStatusVo getTaskStatus(String taskId) {
PhotoEvidenceRepairTaskStatusVo taskStatus = loadTask(taskId);
if (taskStatus == null) {
throw new ServiceException("任务不存在或已过期");
}
return taskStatus;
}
private void runTask(String taskId, List<Long> evidenceIds) {
PhotoEvidenceRepairTaskStatusVo taskStatus = requireTask(taskId);
long startTime = System.currentTimeMillis();
try {
taskStatus.setStatus(STATUS_RUNNING);
taskStatus.setStartTime(startTime);
taskStatus.setMessage("任务执行中");
saveTask(taskStatus);
for (Long evidenceId : evidenceIds) {
try {
PhotoEvidenceRepairExecuteVo result = doRepair(evidenceId);
taskStatus.setSuccessCount(taskStatus.getSuccessCount() + 1);
taskStatus.getSuccessEvidenceIds().add(result.getEvidenceId());
} catch (Exception ex) {
log.error("图片水印修复失败, evidenceId={}", evidenceId, ex);
recordFailure(taskStatus, evidenceId, abbreviate(ex.getMessage(), 300));
} finally {
taskStatus.setProcessedCount(taskStatus.getProcessedCount() + 1);
saveTask(taskStatus);
}
}
taskStatus.setEndTime(System.currentTimeMillis());
taskStatus.setDurationMillis(taskStatus.getEndTime() - startTime);
taskStatus.setStatus(determineFinalStatus(taskStatus));
taskStatus.setMessage(buildFinishMessage(taskStatus));
saveTask(taskStatus);
} catch (Exception ex) {
log.error("图片水印修复任务执行失败, taskId={}", taskId, ex);
taskStatus.setStatus(STATUS_FAILED);
taskStatus.setEndTime(System.currentTimeMillis());
taskStatus.setDurationMillis(taskStatus.getEndTime() - startTime);
taskStatus.setMessage("任务执行失败: " + abbreviate(ex.getMessage(), 300));
saveTask(taskStatus);
}
}
private PhotoEvidenceRepairExecuteVo doRepair(Long evidenceId) {
HotPhotoEvidence evidence = requireEvidence(evidenceId);
SysOssVo originalOss = requireOss(evidence.getOriginalOssId(), "原始图片不存在");
SysOssVo watermarkedOss = requireOss(evidence.getWatermarkedOssId(), "水印图片不存在");
PhotoEvidenceWatermarkSupport.RenderedWatermark renderedWatermark = null;
try {
renderedWatermark = watermarkSupport.renderToTempFile(
downloadOssFile(originalOss),
resolveCaptureTime(evidence),
resolveWatermarkAddress(evidence)
);
String oldFileName = watermarkedOss.getFileName();
OssClient storage = resolveStorage(watermarkedOss.getService());
String objectKey = storage.getPath("photo-evidence", PhotoEvidenceWatermarkSupport.OUTPUT_SUFFIX);
UploadResult uploadResult = storage.upload(
renderedWatermark.getFile().toPath(),
objectKey,
null,
PhotoEvidenceWatermarkSupport.OUTPUT_CONTENT_TYPE
);
SysOssExt ext = parseExt(watermarkedOss.getExt1());
if (ext == null) {
ext = new SysOssExt();
}
ext.setFileSize(renderedWatermark.getFileSize());
ext.setContentType(PhotoEvidenceWatermarkSupport.OUTPUT_CONTENT_TYPE);
SysOss update = new SysOss();
update.setOssId(watermarkedOss.getOssId());
update.setFileName(uploadResult.getFilename());
update.setUrl(uploadResult.getUrl());
update.setFileSuffix(PhotoEvidenceWatermarkSupport.OUTPUT_SUFFIX);
update.setOriginalName(watermarkSupport.buildStandardOriginalName(watermarkedOss.getOssId()));
update.setService(resolveServiceKey(watermarkedOss.getService()));
update.setExt1(JsonUtils.toJsonString(ext));
if (sysOssMapper.updateById(update) <= 0) {
throw new ServiceException("覆盖原水印 sys_oss 记录失败");
}
CacheUtils.evict(CacheNames.SYS_OSS, watermarkedOss.getOssId());
SysOssVo refreshed = requireOss(watermarkedOss.getOssId(), "修复后的水印图片不存在");
log.info("图片水印修复成功, evidenceId={}, watermarkedOssId={}, oldFileName={}, newFileName={}",
evidenceId, watermarkedOss.getOssId(), oldFileName, refreshed.getFileName());
return new PhotoEvidenceRepairExecuteVo(
evidenceId,
watermarkedOss.getOssId(),
oldFileName,
refreshed.getFileName(),
refreshed.getUrl(),
"修复成功"
);
} finally {
if (renderedWatermark != null) {
watermarkSupport.deleteQuietly(renderedWatermark.getFile());
}
}
}
private PhotoEvidenceRepairPreviewVo.MetadataSnapshot buildMetadataSnapshot(SysOssVo oss, SysOssExt ext) {
PhotoEvidenceRepairPreviewVo.MetadataSnapshot snapshot = new PhotoEvidenceRepairPreviewVo.MetadataSnapshot();
if (oss != null) {
snapshot.setFileSuffix(oss.getFileSuffix());
snapshot.setOriginalName(oss.getOriginalName());
}
if (ext != null) {
snapshot.setFileSize(ext.getFileSize());
snapshot.setContentType(ext.getContentType());
}
return snapshot;
}
private PhotoEvidenceRepairPreviewVo.MetadataSnapshot buildTargetMetadata(Long watermarkedOssId, long fileSize) {
PhotoEvidenceRepairPreviewVo.MetadataSnapshot snapshot = new PhotoEvidenceRepairPreviewVo.MetadataSnapshot();
snapshot.setFileSuffix(PhotoEvidenceWatermarkSupport.OUTPUT_SUFFIX);
snapshot.setOriginalName(watermarkSupport.buildStandardOriginalName(watermarkedOssId));
snapshot.setFileSize(fileSize);
snapshot.setContentType(PhotoEvidenceWatermarkSupport.OUTPUT_CONTENT_TYPE);
return snapshot;
}
private void fillCandidateRuntimeFields(List<PhotoEvidenceRepairCandidateVo> rows) {
if (rows == null || rows.isEmpty()) {
return;
}
Set<Long> ossIds = new LinkedHashSet<>();
for (PhotoEvidenceRepairCandidateVo row : rows) {
if (row.getOriginalOssId() != null) {
ossIds.add(row.getOriginalOssId());
}
if (row.getWatermarkedOssId() != null) {
ossIds.add(row.getWatermarkedOssId());
}
}
Map<Long, SysOssVo> ossMap = listOssAsMap(ossIds);
for (PhotoEvidenceRepairCandidateVo row : rows) {
SysOssVo originalOss = ossMap.get(row.getOriginalOssId());
SysOssVo watermarkedOss = ossMap.get(row.getWatermarkedOssId());
if (originalOss != null) {
row.setOriginalImageUrl(originalOss.getUrl());
}
if (watermarkedOss != null) {
row.setCurrentWatermarkedUrl(watermarkedOss.getUrl());
row.setWatermarkedFileSuffix(watermarkedOss.getFileSuffix());
row.setWatermarkedOriginalName(watermarkedOss.getOriginalName());
row.setWatermarkedExt1(watermarkedOss.getExt1());
}
SysOssExt ext = parseExt(row.getWatermarkedExt1());
if (ext != null) {
row.setCurrentFileSize(ext.getFileSize());
row.setCurrentContentType(ext.getContentType());
}
row.setAbnormalReasons(resolveAbnormalReasons(row.getWatermarkedFileSuffix(), row.getWatermarkedExt1()));
}
}
private Map<Long, SysOssVo> listOssAsMap(Collection<Long> ossIds) {
if (ossIds == null || ossIds.isEmpty()) {
return Collections.emptyMap();
}
List<SysOssVo> ossList = ossService.listByIds(ossIds);
Map<Long, SysOssVo> result = new LinkedHashMap<>();
for (SysOssVo item : ossList) {
if (item != null && item.getOssId() != null) {
result.put(item.getOssId(), item);
}
}
return result;
}
private List<String> resolveAbnormalReasons(String fileSuffix, String ext1Json) {
List<String> reasons = new ArrayList<>();
if (isAbnormalSuffix(fileSuffix)) {
reasons.add("文件后缀异常");
}
SysOssExt ext = parseExt(ext1Json);
if (ext != null && ext.getFileSize() != null && ext.getFileSize() <= 0) {
reasons.add("文件大小为0");
}
return reasons;
}
private boolean isAbnormalSuffix(String fileSuffix) {
if (StringUtils.isBlank(fileSuffix)) {
return false;
}
return fileSuffix.matches("^\\.?\\d+$");
}
private SysOssExt parseExt(String ext1Json) {
if (StringUtils.isBlank(ext1Json)) {
return null;
}
try {
return JsonUtils.parseObject(ext1Json, SysOssExt.class);
} catch (Exception ex) {
log.warn("解析 sys_oss.ext1 失败: {}", abbreviate(ext1Json, 200), ex);
return null;
}
}
private PhotoEvidenceRepairQueryBo normalizeQuery(PhotoEvidenceRepairQueryBo queryBo) {
PhotoEvidenceRepairQueryBo query = queryBo == null ? new PhotoEvidenceRepairQueryBo() : queryBo;
if (query.getOnlyAbnormal() == null) {
query.setOnlyAbnormal(Boolean.TRUE);
}
return query;
}
private PageQuery normalizePage(PageQuery pageQuery) {
PageQuery normalized = pageQuery == null ? new PageQuery(20, 1) : pageQuery;
if (normalized.getPageNum() == null || normalized.getPageNum() <= 0) {
normalized.setPageNum(1);
}
if (normalized.getPageSize() == null || normalized.getPageSize() <= 0) {
normalized.setPageSize(20);
}
return normalized;
}
private HotPhotoEvidence requireEvidence(Long evidenceId) {
if (evidenceId == null) {
throw new ServiceException("evidenceId不能为空");
}
HotPhotoEvidence evidence = hotPhotoEvidenceMapper.selectOne(
Wrappers.<HotPhotoEvidence>lambdaQuery()
.eq(HotPhotoEvidence::getId, evidenceId)
.eq(HotPhotoEvidence::getIsDeleted, 0L)
);
if (evidence == null) {
throw new ServiceException("取证记录不存在");
}
return evidence;
}
private SysOssVo requireOss(Long ossId, String message) {
if (ossId == null) {
throw new ServiceException(message);
}
SysOssVo oss = ossService.getById(ossId);
if (oss == null) {
throw new ServiceException(message);
}
return oss;
}
private byte[] downloadOssFile(SysOssVo oss) {
OssClient storage = resolveStorage(oss.getService());
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
storage.download(oss.getFileName(), baos, null);
byte[] bytes = baos.toByteArray();
if (bytes.length <= 0) {
throw new ServiceException("原始图片为空文件");
}
return bytes;
} catch (Exception ex) {
throw new ServiceException("原始图片下载失败: " + ex.getMessage());
}
}
private Date resolveCaptureTime(HotPhotoEvidence evidence) {
return evidence.getCaptureTimeClient() != null ? evidence.getCaptureTimeClient() : evidence.getCaptureTimeServer();
}
private String resolveWatermarkAddress(HotPhotoEvidence evidence) {
if (StringUtils.equalsIgnoreCase("SERVER", evidence.getAddressSource()) && StringUtils.isNotBlank(evidence.getAddressTextServer())) {
return evidence.getAddressTextServer();
}
if (StringUtils.isNotBlank(evidence.getAddressTextClient())) {
return evidence.getAddressTextClient();
}
return evidence.getAddressTextServer();
}
private OssClient resolveStorage(String serviceKey) {
String resolvedServiceKey = resolveServiceKey(serviceKey);
return StringUtils.isBlank(resolvedServiceKey) ? OssFactory.instance() : OssFactory.instance(resolvedServiceKey);
}
private String resolveServiceKey(String serviceKey) {
return StringUtils.isBlank(serviceKey) ? null : serviceKey;
}
private void requirePreviewConfirmed(Boolean previewConfirmed) {
if (!Boolean.TRUE.equals(previewConfirmed)) {
throw new ServiceException("请先完成预览确认后再执行修复");
}
}
private List<Long> normalizeEvidenceIds(List<Long> evidenceIds) {
if (evidenceIds == null || evidenceIds.isEmpty()) {
return Collections.emptyList();
}
return evidenceIds.stream()
.filter(Objects::nonNull)
.distinct()
.toList();
}
private void saveTask(PhotoEvidenceRepairTaskStatusVo taskStatus) {
RedisUtils.setCacheObject(TASK_KEY_PREFIX + taskStatus.getTaskId(), taskStatus);
}
private PhotoEvidenceRepairTaskStatusVo loadTask(String taskId) {
if (StringUtils.isBlank(taskId)) {
return null;
}
return RedisUtils.getCacheObject(TASK_KEY_PREFIX + taskId);
}
private PhotoEvidenceRepairTaskStatusVo requireTask(String taskId) {
PhotoEvidenceRepairTaskStatusVo taskStatus = loadTask(taskId);
if (taskStatus == null) {
throw new ServiceException("任务不存在或已过期");
}
return taskStatus;
}
private void recordFailure(PhotoEvidenceRepairTaskStatusVo taskStatus, Long evidenceId, String reason) {
taskStatus.setFailCount(taskStatus.getFailCount() + 1);
if (taskStatus.getErrorSamples().size() >= MAX_ERROR_SAMPLES) {
return;
}
PhotoEvidenceRepairTaskStatusVo.ErrorSample errorSample = new PhotoEvidenceRepairTaskStatusVo.ErrorSample();
errorSample.setEvidenceId(evidenceId);
PhotoEvidenceRepairCandidateVo candidate = hotPhotoEvidenceMapper.selectRepairCandidateByEvidenceId(evidenceId);
if (candidate != null) {
errorSample.setWatermarkedOssId(candidate.getWatermarkedOssId());
}
errorSample.setReason(reason);
taskStatus.getErrorSamples().add(errorSample);
}
private String determineFinalStatus(PhotoEvidenceRepairTaskStatusVo taskStatus) {
if (taskStatus.getFailCount() > 0 && taskStatus.getSuccessCount() > 0) {
return STATUS_PARTIAL_SUCCESS;
}
if (taskStatus.getFailCount() > 0) {
return STATUS_FAILED;
}
return STATUS_SUCCESS;
}
private String buildFinishMessage(PhotoEvidenceRepairTaskStatusVo taskStatus) {
return String.format(
Locale.ROOT,
"任务完成:成功 %d失败 %d",
taskStatus.getSuccessCount(),
taskStatus.getFailCount()
);
}
private String resolveOperatorName() {
try {
return LoginHelper.getUsername();
} catch (Exception ignored) {
return "unknown";
}
}
private String abbreviate(String value, int maxLength) {
if (value == null) {
return "";
}
String normalized = value.replaceAll("\\s+", " ").trim();
if (normalized.length() <= maxLength) {
return normalized;
}
return normalized.substring(0, Math.max(0, maxLength - 3)) + "...";
}
}

View File

@@ -0,0 +1,114 @@
<?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.hotwj.platform.common.photoEvidence.mapper.HotPhotoEvidenceMapper">
<sql id="repairCandidateWhere">
<where>
e.is_deleted = 0
AND e.watermarked_oss_id IS NOT NULL
AND wo.oss_id IS NOT NULL
<if test="query != null">
<if test="query.evidenceId != null">
AND e.id = #{query.evidenceId}
</if>
<if test="query.watermarkedOssId != null">
AND e.watermarked_oss_id = #{query.watermarkedOssId}
</if>
<if test="query.originalOssId != null">
AND e.original_oss_id = #{query.originalOssId}
</if>
<if test="query.companyId != null">
AND e.company_id = #{query.companyId}
</if>
<if test="query.bizType != null and query.bizType != ''">
AND e.biz_type = #{query.bizType}
</if>
<if test="query.bizId != null and query.bizId != ''">
AND e.biz_id = #{query.bizId}
</if>
<if test="query.beginCreateTime != null">
AND e.create_time <![CDATA[ >= ]]> #{query.beginCreateTime}
</if>
<if test="query.endCreateTime != null">
AND e.create_time <![CDATA[ <= ]]> #{query.endCreateTime}
</if>
<if test="query.onlyAbnormal != null and query.onlyAbnormal">
AND (
wo.file_suffix REGEXP '^[.]?[0-9]+$'
OR (
JSON_VALID(wo.ext1)
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(wo.ext1, '$.fileSize')) AS SIGNED) = 0
)
)
</if>
<if test="query.fileSizeZero != null and query.fileSizeZero">
AND JSON_VALID(wo.ext1)
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(wo.ext1, '$.fileSize')) AS SIGNED) = 0
</if>
<if test="query.fileSuffixAbnormal != null and query.fileSuffixAbnormal">
AND wo.file_suffix REGEXP '^[.]?[0-9]+$'
</if>
</if>
</where>
</sql>
<select id="countRepairCandidates" resultType="long">
SELECT COUNT(1)
FROM hot_photo_evidence e
LEFT JOIN sys_oss wo ON wo.oss_id = e.watermarked_oss_id
<include refid="repairCandidateWhere"/>
</select>
<select id="selectRepairCandidates"
resultType="com.hotwj.platform.system.maintenance.domain.vo.PhotoEvidenceRepairCandidateVo">
SELECT e.id AS evidenceId,
e.watermarked_oss_id AS watermarkedOssId,
e.original_oss_id AS originalOssId,
e.company_id AS companyId,
e.biz_type AS bizType,
e.biz_id AS bizId,
e.capture_time_client AS captureTimeClient,
e.address_source AS addressSource,
CASE
WHEN e.address_source = 'SERVER' THEN e.address_text_server
ELSE e.address_text_client
END AS addressText,
e.create_time AS createTime,
wo.file_suffix AS watermarkedFileSuffix,
wo.original_name AS watermarkedOriginalName,
wo.ext1 AS watermarkedExt1
FROM hot_photo_evidence e
LEFT JOIN sys_oss wo ON wo.oss_id = e.watermarked_oss_id
<include refid="repairCandidateWhere"/>
ORDER BY e.create_time DESC, e.id DESC
LIMIT #{offset}, #{pageSize}
</select>
<select id="selectRepairCandidateByEvidenceId"
resultType="com.hotwj.platform.system.maintenance.domain.vo.PhotoEvidenceRepairCandidateVo">
SELECT e.id AS evidenceId,
e.watermarked_oss_id AS watermarkedOssId,
e.original_oss_id AS originalOssId,
e.company_id AS companyId,
e.biz_type AS bizType,
e.biz_id AS bizId,
e.capture_time_client AS captureTimeClient,
e.address_source AS addressSource,
CASE
WHEN e.address_source = 'SERVER' THEN e.address_text_server
ELSE e.address_text_client
END AS addressText,
e.create_time AS createTime,
wo.file_suffix AS watermarkedFileSuffix,
wo.original_name AS watermarkedOriginalName,
wo.ext1 AS watermarkedExt1
FROM hot_photo_evidence e
LEFT JOIN sys_oss wo ON wo.oss_id = e.watermarked_oss_id
WHERE e.is_deleted = 0
AND e.id = #{evidenceId}
LIMIT 1
</select>
</mapper>

View File

@@ -21,13 +21,15 @@
p.is_auto_generated_by_backend, p.is_auto_generated_by_backend,
p.plan_date_key, p.plan_date_key,
COALESCE(stats.completed_count, 0) AS completedCount, COALESCE(stats.completed_count, 0) AS completedCount,
COALESCE(stats.pending_count, 0) AS pendingCount COALESCE(stats.pending_count, 0) AS pendingCount,
COALESCE(stats.pending_audit_count, 0) AS pendingAuditCount
FROM hot_hidden_danger_plan p FROM hot_hidden_danger_plan p
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
i.plan_id, i.plan_id,
SUM(CASE WHEN i.status IN (3, 9) THEN 1 ELSE 0 END) AS completed_count, SUM(CASE WHEN i.status IN (3, 9) THEN 1 ELSE 0 END) AS completed_count,
SUM(CASE WHEN i.status IS NULL OR i.status NOT IN (3, 9) THEN 1 ELSE 0 END) AS pending_count SUM(CASE WHEN i.status IS NULL OR i.status NOT IN (3, 9) THEN 1 ELSE 0 END) AS pending_count,
SUM(CASE WHEN i.status IN (2, 5) THEN 1 ELSE 0 END) AS pending_audit_count
FROM hot_hidden_danger_inspection i FROM hot_hidden_danger_inspection i
WHERE i.is_deleted = 0 WHERE i.is_deleted = 0
GROUP BY i.plan_id GROUP BY i.plan_id