This commit is contained in:
2026-08-07 20:07:30 +08:00
parent c698290106
commit c4b0972dc9
31 changed files with 1089 additions and 204 deletions
+2 -2
View File
@@ -24,7 +24,7 @@
| 毒消息隔离 | `mq/ExportDlqHandler.java`;定位必须使用四个 User Properties,不解析损坏 body |
| 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` |
| IAM 权限协作 | `client/IamQualificationClient.java`;商户登记 `/qualifications`,平台登记/替换 `/platform-qualifications` |
| 平台称重图片上传、OCR 重试与查看 | `controller/PlatformMeasurementImageController.java`(`/api/platform/v1/files/measurements`)→ `service/MeasurementImageService.java` |
| 平台称重图片上传、OCR 重试、原图与缩略图查看 | `controller/PlatformMeasurementImageController.java`(`/api/platform/v1/files/measurements`)→ `service/MeasurementImageService.java` |
| Firstleg 第三方测量证据图片 | `controller/InternalMeasurementImageController.java`(`/internal/file/v1/measurement-images`)→ `MeasurementImageService.uploadMeasurementEvidence/deleteMeasurementEvidence`;必须校验内部令牌 |
| 测量图片磁盘与元数据 | `service/MeasurementImageStorage.java`、`entity/FileObjectEntity.java`、`mapper/FileObjectMapper.java`;表结构 `src/main/resources/db/file.sql` |
| 重量 OCR 下游 | `client/WeightOcrClient.java` → `integration-service` 的 `/internal/integrations/weight-ocr/single`;尺寸测量证据不触发重量 OCR |
@@ -41,7 +41,7 @@
- 企业资质元数据后续若迁入 File,需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller;不得复用测量图片 `file_object` 的已接入状态宣称资质元数据已迁移。
- 测量图片必须同时按 `tenantId + inboundId + fileId + businessType` 校验归属。平台称重图片使用 `INBOUND_WEIGHT_IMAGE` 并可调用 OCR;第三方尺寸证据使用 `INBOUND_MEASUREMENT_IMAGE`,只允许内部上传/删除且不得触发重量 OCR。
- 平台测量图片权限与 Firstleg 权限码保持一致:上传和重试要求 `platform:firstleg:inbound:weight:edit`,查看要求 `platform:firstleg:inbound:measure:view`;内部证据接口只信任共享内部令牌和明确操作人。
- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;删除第三方证据采用逻辑删除并校验业务类型,不能删除称重图片或其他客户文件。
- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;入库称重图片和第三方尺寸证据均由其业务服务确认引用关系后通过内部接口逻辑删除,其他客户文件不得使用该接口删除。
- 从父目录运行 `mvn -pl file -am test`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。
- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而首次上传才返回 HTTP 200 + `S0503`。
- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。
@@ -81,6 +81,24 @@ public class PlatformMeasurementImageController {
.body(image.content());
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}/thumbnail")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
@Operation(summary = "查看入库单称重图片缩略图")
public ResponseEntity<byte[]> thumbnail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId) {
requirePlatform(user);
requirePositive(tenantId, inboundId, fileId);
var image = images.thumbnail(tenantId, inboundId, fileId);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.IMAGE_JPEG)
.contentLength(image.content().length)
.body(image.content());
}
private long requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() != 0L
@@ -15,11 +15,19 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.Set;
import javax.imageio.ImageIO;
@Service
public class MeasurementImageService {
@@ -81,11 +89,45 @@ public class MeasurementImageService {
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
}
public Download thumbnail(long tenantId, long inboundId, long fileId) {
Download original = download(tenantId, inboundId, fileId);
try {
BufferedImage source = ImageIO.read(new ByteArrayInputStream(original.content()));
if (source == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "称重图片无法生成缩略图");
}
double scale = Math.min(1D, Math.min(480D / source.getWidth(), 320D / source.getHeight()));
int width = Math.max(1, (int) Math.round(source.getWidth() * scale));
int height = Math.max(1, (int) Math.round(source.getHeight() * scale));
BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = target.createGraphics();
try {
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, width, height);
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
graphics.setRenderingHint(RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY);
graphics.drawImage(source, 0, 0, width, height, null);
} finally {
graphics.dispose();
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
if (!ImageIO.write(target, "jpg", output)) {
throw new BizException(ResultCode.INTERNAL_ERROR, "称重图片缩略图编码失败");
}
return new Download("thumbnail.jpg", "image/jpeg", output.toByteArray());
} catch (IOException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "称重图片缩略图生成失败");
}
}
@Transactional
public void deleteMeasurementEvidence(long tenantId, long inboundId, long fileId, long operatorId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
if (!"INBOUND_MEASUREMENT_IMAGE".equals(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "仅允许清理第三方尺寸测量证据图片");
if (!Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
.contains(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "仅允许清理入库测量图片");
}
object.setStatus("DELETED");
object.setUpdatedBy(operatorId);
@@ -10,6 +10,7 @@ import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -71,4 +72,15 @@ class PlatformMeasurementImageControllerContractTest {
assertThatThrownBy(() -> controller.upload(malformed, 10001L, 88L, file))
.isInstanceOf(BizException.class);
}
@Test
void thumbnailUsesViewPermission() throws Exception {
Method method = PlatformMeasurementImageController.class.getDeclaredMethod("thumbnail",
AuthenticatedUser.class, long.class, long.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/{tenantId}/{inboundId}/{fileId}/thumbnail");
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:measure:view')");
}
}
@@ -20,6 +20,10 @@ import jakarta.annotation.Resource;
import java.nio.file.Path;
import java.math.BigDecimal;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import javax.imageio.ImageIO;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -137,6 +141,38 @@ class MeasurementImageServiceEvidenceTest {
ordered.verify(storage).delete(object.getObjectKey());
}
@Test
void deletesWeightImageThroughTheSameOwnedFileBoundary() {
FileObjectEntity object = measurementEvidence();
object.setBusinessType("INBOUND_WEIGHT_IMAGE");
when(files.selectOne(any())).thenReturn(object);
when(files.updateById(object)).thenReturn(1);
when(files.deleteById(41L)).thenReturn(1);
service.deleteMeasurementEvidence(11L, 21L, 41L, 31L);
verify(storage).delete(object.getObjectKey());
}
@Test
void createsBoundedJpegThumbnail() throws Exception {
FileObjectEntity object = measurementEvidence();
object.setOriginalName("weight.png");
object.setContentType(MediaType.IMAGE_PNG_VALUE);
when(files.selectOne(any())).thenReturn(object);
BufferedImage source = new BufferedImage(1200, 800, BufferedImage.TYPE_INT_RGB);
ByteArrayOutputStream image = new ByteArrayOutputStream();
ImageIO.write(source, "png", image);
when(storage.read(object.getObjectKey())).thenReturn(image.toByteArray());
MeasurementImageService.Download result = service.thumbnail(11L, 21L, 41L);
BufferedImage thumbnail = ImageIO.read(new java.io.ByteArrayInputStream(result.content()));
assertThat(result.contentType()).isEqualTo(MediaType.IMAGE_JPEG_VALUE);
assertThat(thumbnail.getWidth()).isEqualTo(480);
assertThat(thumbnail.getHeight()).isEqualTo(320);
}
@Test
void doesNotDeleteStoredContentWhenLogicalDeleteLosesTheRace() {
FileObjectEntity object = measurementEvidence();