From 0adba6f5cb81bb7dae9952885c6c47bd5320c4f2 Mon Sep 17 00:00:00 2001 From: shihongwei <178899525@qq.com> Date: Wed, 1 Jul 2026 23:02:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(photo-evidence):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=B0=B4=E5=8D=B0=E5=BC=82=E5=B8=B8=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=85=A8=E6=B5=81=E7=A8=8B=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽离并重构原有图片水印生成逻辑,统一输出jpeg格式,避免因异常文件后缀产生坏数据 新增异常水印图片的分页查询、预览确认、单条修复、批量异步修复及任务状态查询接口 新增对应的控制器、业务服务类与数据传输对象 补充图片水印异常修复的完整方案文档 --- ...水印后缀异常兜底与补救方案.md | 421 +++++++++++++++ .../mapper/HotPhotoEvidenceMapper.java | 13 + .../impl/PhotoEvidenceServiceImpl.java | 163 +----- .../PhotoEvidenceWatermarkSupport.java | 209 ++++++++ .../PhotoEvidenceMaintenanceController.java | 53 ++ .../bo/PhotoEvidenceRepairExecuteRequest.java | 17 + .../domain/bo/PhotoEvidenceRepairQueryBo.java | 34 ++ .../bo/PhotoEvidenceRepairTaskRequest.java | 20 + .../vo/PhotoEvidenceRepairCandidateVo.java | 51 ++ .../vo/PhotoEvidenceRepairExecuteVo.java | 29 + .../vo/PhotoEvidenceRepairPreviewVo.java | 46 ++ .../vo/PhotoEvidenceRepairSubmitVo.java | 23 + .../vo/PhotoEvidenceRepairTaskStatusVo.java | 55 ++ .../service/IPhotoEvidenceRepairService.java | 21 + .../impl/PhotoEvidenceRepairServiceImpl.java | 502 ++++++++++++++++++ .../mapper/common/HotPhotoEvidenceMapper.xml | 114 ++++ 16 files changed, 1625 insertions(+), 146 deletions(-) create mode 100644 docs/图片水印后缀异常兜底与补救方案.md create mode 100644 src/main/java/com/hotwj/platform/common/photoEvidence/support/PhotoEvidenceWatermarkSupport.java create mode 100644 src/main/java/com/hotwj/platform/system/maintenance/controller/PhotoEvidenceMaintenanceController.java create mode 100644 src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairExecuteRequest.java create mode 100644 src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairQueryBo.java create mode 100644 src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairTaskRequest.java create mode 100644 src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairCandidateVo.java create mode 100644 src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairExecuteVo.java create mode 100644 src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairPreviewVo.java create mode 100644 src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairSubmitVo.java create mode 100644 src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairTaskStatusVo.java create mode 100644 src/main/java/com/hotwj/platform/system/maintenance/service/IPhotoEvidenceRepairService.java create mode 100644 src/main/java/com/hotwj/platform/system/maintenance/service/impl/PhotoEvidenceRepairServiceImpl.java create mode 100644 src/main/resources/mapper/common/HotPhotoEvidenceMapper.xml diff --git a/docs/图片水印后缀异常兜底与补救方案.md b/docs/图片水印后缀异常兜底与补救方案.md new file mode 100644 index 0000000..709d9e6 --- /dev/null +++ b/docs/图片水印后缀异常兜底与补救方案.md @@ -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` 记录。 +- 落地形式:优先做成后台管理可视化能力,支持异常筛选、预览、单条修复、批量修复、日志追踪。 + +该方案既能快速止血,也能在不扩散引用风险的前提下,稳妥完成历史数据修复。 diff --git a/src/main/java/com/hotwj/platform/common/photoEvidence/mapper/HotPhotoEvidenceMapper.java b/src/main/java/com/hotwj/platform/common/photoEvidence/mapper/HotPhotoEvidenceMapper.java index aff6b4a..87c82df 100644 --- a/src/main/java/com/hotwj/platform/common/photoEvidence/mapper/HotPhotoEvidenceMapper.java +++ b/src/main/java/com/hotwj/platform/common/photoEvidence/mapper/HotPhotoEvidenceMapper.java @@ -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.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.Param; import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import java.util.List; + @Mapper public interface HotPhotoEvidenceMapper extends BaseMapperPlus { + + long countRepairCandidates(@Param("query") PhotoEvidenceRepairQueryBo query); + + List selectRepairCandidates(@Param("query") PhotoEvidenceRepairQueryBo query, + @Param("offset") long offset, + @Param("pageSize") long pageSize); + + PhotoEvidenceRepairCandidateVo selectRepairCandidateByEvidenceId(@Param("evidenceId") Long evidenceId); } diff --git a/src/main/java/com/hotwj/platform/common/photoEvidence/service/impl/PhotoEvidenceServiceImpl.java b/src/main/java/com/hotwj/platform/common/photoEvidence/service/impl/PhotoEvidenceServiceImpl.java index 03a5ba5..f82317b 100644 --- a/src/main/java/com/hotwj/platform/common/photoEvidence/service/impl/PhotoEvidenceServiceImpl.java +++ b/src/main/java/com/hotwj/platform/common/photoEvidence/service/impl/PhotoEvidenceServiceImpl.java @@ -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.mapper.HotPhotoEvidenceMapper; import com.hotwj.platform.common.photoEvidence.service.IPhotoEvidenceService; +import com.hotwj.platform.common.photoEvidence.support.PhotoEvidenceWatermarkSupport; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; 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.transaction.annotation.Transactional; -import javax.imageio.ImageIO; -import java.awt.*; -import java.awt.image.BufferedImage; -import java.io.*; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.Date; -import java.util.List; @Slf4j @Service @@ -43,6 +40,9 @@ public class PhotoEvidenceServiceImpl implements IPhotoEvidenceService { @Resource private ISysOssService ossService; + @Resource + private PhotoEvidenceWatermarkSupport watermarkSupport; + @Value("${map.gd.key:}") private String gdKey; @@ -129,30 +129,19 @@ public class PhotoEvidenceServiceImpl implements IPhotoEvidenceService { Date captureTimeClient, String address) { byte[] originalBytes = downloadOssFile(originalOss); - BufferedImage originalImage = readImage(originalBytes); - BufferedImage watermarked = addWatermark(originalImage, captureTimeClient, address); - - File tempFile = null; + PhotoEvidenceWatermarkSupport.RenderedWatermark renderedWatermark = null; try { - String suffix = originalOss.getFileSuffix(); - if (StringUtils.isBlank(suffix)) { - suffix = "jpg"; - } - if (suffix.startsWith(".")) { - suffix = suffix.substring(1); - } - tempFile = File.createTempFile("photo_evidence_", "." + suffix); - try (OutputStream os = new FileOutputStream(tempFile)) { - ImageIO.write(watermarked, suffix, os); - } - return ossService.upload(tempFile).getOssId(); - } catch (IOException e) { - throw new ServiceException("水印图片生成失败"); + renderedWatermark = watermarkSupport.renderToTempFile(originalBytes, captureTimeClient, address); + OssClient storage = OssFactory.instance(); + String objectKey = storage.getPath("photo-evidence", PhotoEvidenceWatermarkSupport.OUTPUT_SUFFIX); + return ossService.upload( + renderedWatermark.getFile(), + objectKey, + PhotoEvidenceWatermarkSupport.OUTPUT_CONTENT_TYPE + ).getOssId(); } finally { - if (tempFile != null && tempFile.exists()) { - if (!tempFile.delete()) { - log.warn("临时文件删除失败: {}", tempFile.getAbsolutePath()); - } + if (renderedWatermark != null) { + watermarkSupport.deleteQuietly(renderedWatermark.getFile()); } } } @@ -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 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) { if (latitude == null || longitude == null) { return null; diff --git a/src/main/java/com/hotwj/platform/common/photoEvidence/support/PhotoEvidenceWatermarkSupport.java b/src/main/java/com/hotwj/platform/common/photoEvidence/support/PhotoEvidenceWatermarkSupport.java new file mode 100644 index 0000000..9ad3eee --- /dev/null +++ b/src/main/java/com/hotwj/platform/common/photoEvidence/support/PhotoEvidenceWatermarkSupport.java @@ -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 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; + } + } +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/controller/PhotoEvidenceMaintenanceController.java b/src/main/java/com/hotwj/platform/system/maintenance/controller/PhotoEvidenceMaintenanceController.java new file mode 100644 index 0000000..c7d14ae --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/controller/PhotoEvidenceMaintenanceController.java @@ -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 list(PhotoEvidenceRepairQueryBo queryBo, PageQuery pageQuery) { + return photoEvidenceRepairService.queryPageList(queryBo, pageQuery); + } + + @GetMapping("/preview/{evidenceId}") + @Operation(summary = "生成图片水印修复预览") + public R preview(@PathVariable Long evidenceId) { + return R.ok(photoEvidenceRepairService.preview(evidenceId)); + } + + @PostMapping("/repair") + @Operation(summary = "执行单条图片水印修复") + public R repair(@Valid @RequestBody PhotoEvidenceRepairExecuteRequest request) { + return R.ok(photoEvidenceRepairService.repair(request)); + } + + @PostMapping("/repair-task") + @Operation(summary = "创建批量图片水印修复任务") + public R submitRepairTask(@Valid @RequestBody PhotoEvidenceRepairTaskRequest request) { + return R.ok(photoEvidenceRepairService.submitRepairTask(request)); + } + + @GetMapping("/repair-task/{taskId}") + @Operation(summary = "查询图片水印修复任务状态") + public R getTaskStatus(@PathVariable String taskId) { + return R.ok(photoEvidenceRepairService.getTaskStatus(taskId)); + } +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairExecuteRequest.java b/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairExecuteRequest.java new file mode 100644 index 0000000..b3d8c92 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairExecuteRequest.java @@ -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; +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairQueryBo.java b/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairQueryBo.java new file mode 100644 index 0000000..b362605 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairQueryBo.java @@ -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; +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairTaskRequest.java b/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairTaskRequest.java new file mode 100644 index 0000000..b8586f8 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/PhotoEvidenceRepairTaskRequest.java @@ -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 evidenceIds; + + /** + * 前端必须确保本次提交的记录都已经过人工预览确认。 + */ + @NotNull(message = "previewConfirmed不能为空") + private Boolean previewConfirmed; +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairCandidateVo.java b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairCandidateVo.java new file mode 100644 index 0000000..01d9b30 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairCandidateVo.java @@ -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 abnormalReasons; +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairExecuteVo.java b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairExecuteVo.java new file mode 100644 index 0000000..7bbf62d --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairExecuteVo.java @@ -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; +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairPreviewVo.java b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairPreviewVo.java new file mode 100644 index 0000000..7098c6f --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairPreviewVo.java @@ -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 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; + } +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairSubmitVo.java b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairSubmitVo.java new file mode 100644 index 0000000..d442a8a --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairSubmitVo.java @@ -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; +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairTaskStatusVo.java b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairTaskStatusVo.java new file mode 100644 index 0000000..7fef357 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/PhotoEvidenceRepairTaskStatusVo.java @@ -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 successEvidenceIds = new ArrayList<>(); + + private List 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; + } +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/service/IPhotoEvidenceRepairService.java b/src/main/java/com/hotwj/platform/system/maintenance/service/IPhotoEvidenceRepairService.java new file mode 100644 index 0000000..91320c7 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/service/IPhotoEvidenceRepairService.java @@ -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 queryPageList(PhotoEvidenceRepairQueryBo queryBo, PageQuery pageQuery); + + PhotoEvidenceRepairPreviewVo preview(Long evidenceId); + + PhotoEvidenceRepairExecuteVo repair(PhotoEvidenceRepairExecuteRequest request); + + PhotoEvidenceRepairSubmitVo submitRepairTask(PhotoEvidenceRepairTaskRequest request); + + PhotoEvidenceRepairTaskStatusVo getTaskStatus(String taskId); +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/service/impl/PhotoEvidenceRepairServiceImpl.java b/src/main/java/com/hotwj/platform/system/maintenance/service/impl/PhotoEvidenceRepairServiceImpl.java new file mode 100644 index 0000000..b9f3bd6 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/service/impl/PhotoEvidenceRepairServiceImpl.java @@ -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 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 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 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 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 rows) { + if (rows == null || rows.isEmpty()) { + return; + } + Set ossIds = new LinkedHashSet<>(); + for (PhotoEvidenceRepairCandidateVo row : rows) { + if (row.getOriginalOssId() != null) { + ossIds.add(row.getOriginalOssId()); + } + if (row.getWatermarkedOssId() != null) { + ossIds.add(row.getWatermarkedOssId()); + } + } + Map 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 listOssAsMap(Collection ossIds) { + if (ossIds == null || ossIds.isEmpty()) { + return Collections.emptyMap(); + } + List ossList = ossService.listByIds(ossIds); + Map result = new LinkedHashMap<>(); + for (SysOssVo item : ossList) { + if (item != null && item.getOssId() != null) { + result.put(item.getOssId(), item); + } + } + return result; + } + + private List resolveAbnormalReasons(String fileSuffix, String ext1Json) { + List 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.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 normalizeEvidenceIds(List 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)) + "..."; + } +} diff --git a/src/main/resources/mapper/common/HotPhotoEvidenceMapper.xml b/src/main/resources/mapper/common/HotPhotoEvidenceMapper.xml new file mode 100644 index 0000000..d2a587c --- /dev/null +++ b/src/main/resources/mapper/common/HotPhotoEvidenceMapper.xml @@ -0,0 +1,114 @@ + + + + + + + e.is_deleted = 0 + AND e.watermarked_oss_id IS NOT NULL + AND wo.oss_id IS NOT NULL + + + AND e.id = #{query.evidenceId} + + + AND e.watermarked_oss_id = #{query.watermarkedOssId} + + + AND e.original_oss_id = #{query.originalOssId} + + + AND e.company_id = #{query.companyId} + + + AND e.biz_type = #{query.bizType} + + + AND e.biz_id = #{query.bizId} + + + AND e.create_time = ]]> #{query.beginCreateTime} + + + AND e.create_time #{query.endCreateTime} + + + AND ( + wo.file_suffix REGEXP '^[.]?[0-9]+$' + OR ( + JSON_VALID(wo.ext1) + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(wo.ext1, '$.fileSize')) AS SIGNED) = 0 + ) + ) + + + AND JSON_VALID(wo.ext1) + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(wo.ext1, '$.fileSize')) AS SIGNED) = 0 + + + AND wo.file_suffix REGEXP '^[.]?[0-9]+$' + + + + + + + + + + + +