二期内容提交

This commit is contained in:
2026-08-04 12:23:46 +08:00
parent 25d2a13581
commit addde4375e
28 changed files with 897 additions and 132 deletions
@@ -0,0 +1,70 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.MeasurementImageService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Firstleg 保存第三方尺寸测量证据图片的内部入口。 */
@RestController
@RequestMapping("/internal/file/v1/measurement-images")
public class InternalMeasurementImageController {
private final MeasurementImageService images;
private final byte[] expectedToken;
public InternalMeasurementImageController(
MeasurementImageService images,
@Value("${baiyue.security.internal-service-token}") String token) {
this.images = images;
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<MeasurementImageService.UploadResult> upload(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@RequestPart("file") MultipartFile file) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0");
}
return ApiResponse.success(
images.uploadMeasurementEvidence(tenantId, inboundId, operatorId, file));
}
@DeleteMapping("/{tenantId}/{inboundId}/{fileId}")
public ApiResponse<Void> delete(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单、文件和操作主体ID必须大于0");
}
images.deleteMeasurementEvidence(tenantId, inboundId, fileId, operatorId);
return ApiResponse.success(null);
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -36,10 +36,25 @@ public class MeasurementImageService {
@Transactional
public UploadResult upload(long tenantId, long inboundId, long operatorId, MultipartFile file) {
return recognize(storeObject(tenantId, inboundId, operatorId, file, "INBOUND_WEIGHT_IMAGE"));
}
/** 保存第三方尺寸测量证据图片,不触发重量 OCR。 */
@Transactional
public UploadResult uploadMeasurementEvidence(long tenantId, long inboundId, long operatorId,
MultipartFile file) {
FileObjectEntity object = storeObject(
tenantId, inboundId, operatorId, file, "INBOUND_MEASUREMENT_IMAGE");
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null);
}
private FileObjectEntity storeObject(long tenantId, long inboundId, long operatorId,
MultipartFile file, String businessType) {
var stored = storage.store(tenantId, inboundId, file);
FileObjectEntity object = new FileObjectEntity();
object.setTenantId(tenantId);
object.setBusinessType("INBOUND_WEIGHT_IMAGE");
object.setBusinessType(businessType);
object.setBusinessId(Long.toString(inboundId));
object.setOriginalName(stored.originalName());
object.setObjectKey(stored.objectKey());
@@ -56,12 +71,15 @@ public class MeasurementImageService {
object.setDeleted(false);
object.setVersion(0);
files.insert(object);
return recognize(object);
return object;
}
public UploadResult retryRecognition(long tenantId, long inboundId, long fileId) {
return recognize(requireObject(tenantId, inboundId, fileId));
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
if (!"INBOUND_WEIGHT_IMAGE".equals(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "尺寸测量证据图片不执行重量OCR");
}
return recognize(object);
}
public Download download(long tenantId, long inboundId, long fileId) {
@@ -69,11 +87,25 @@ public class MeasurementImageService {
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
}
@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, "仅允许清理第三方尺寸测量证据图片");
}
object.setDeleted(true);
object.setStatus("DELETED");
object.setUpdatedBy(operatorId);
object.setUpdatedAt(LocalDateTime.now());
files.updateById(object);
storage.delete(object.getObjectKey());
}
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId)
.eq(FileObjectEntity::getId, fileId)
.eq(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE")
.in(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
@@ -56,6 +56,16 @@ public class MeasurementImageStorage {
catch (IOException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "称重图片读取失败"); }
}
public void delete(String objectKey) {
Path path = root.resolve(objectKey).normalize();
if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "测量图片清理失败");
}
}
private void verifyImage(MultipartFile file) {
try (InputStream input = file.getInputStream()) {
if (ImageIO.read(input) == null) throw bad("上传内容不是有效图片");
@@ -0,0 +1,43 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.MeasurementImageService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InternalMeasurementImageControllerTest {
private final MeasurementImageService images = mock(MeasurementImageService.class);
private final InternalMeasurementImageController controller =
new InternalMeasurementImageController(images, "internal-token");
@Test
void 保存第三方尺寸测量图片但不触发重量识别契约() {
var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1, 2, 3});
var result = new MeasurementImageService.UploadResult(41L, "measure.png",
MediaType.IMAGE_PNG_VALUE, 3L, "NOT_REQUIRED", null, null, null, null);
when(images.uploadMeasurementEvidence(11L, 21L, 31L, file)).thenReturn(result);
var response = controller.upload("internal-token", 31L, 11L, 21L, file);
assertThat(response.data().fileId()).isEqualTo(41L);
assertThat(response.data().recognitionStatus()).isEqualTo("NOT_REQUIRED");
verify(images).uploadMeasurementEvidence(11L, 21L, 31L, file);
}
@Test
void 内部令牌错误时拒绝保存() {
var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1});
assertThatThrownBy(() -> controller.upload("bad-token", 31L, 11L, 21L, file))
.isInstanceOf(BizException.class)
.hasMessageContaining("凭证无效");
}
}
@@ -0,0 +1,63 @@
package com.baiyue.file.service;
import com.baiyue.file.client.WeightOcrClient;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class MeasurementImageServiceEvidenceTest {
@Test
void 尺寸测量证据图片落库但不调用重量Ocr() {
MeasurementImageStorage storage = mock(MeasurementImageStorage.class);
FileObjectMapper files = mock(FileObjectMapper.class);
WeightOcrClient ocr = mock(WeightOcrClient.class);
MeasurementImageService service = new MeasurementImageService(storage, files, ocr, "internal-token");
var image = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1, 2, 3});
when(storage.store(11L, 21L, image)).thenReturn(new MeasurementImageStorage.Stored(
"measurements/11/21/a.png", Path.of("a.png"), MediaType.IMAGE_PNG_VALUE,
3L, "hash", "measure.png"));
doAnswer(invocation -> {
FileObjectEntity object = invocation.getArgument(0);
object.setId(41L);
return 1;
}).when(files).insert(any(FileObjectEntity.class));
var result = service.uploadMeasurementEvidence(11L, 21L, 31L, image);
assertThat(result.fileId()).isEqualTo(41L);
assertThat(result.recognitionStatus()).isEqualTo("NOT_REQUIRED");
verifyNoInteractions(ocr);
}
@Test
void 尺寸测量证据图片不能通过重试入口触发重量Ocr() {
MeasurementImageStorage storage = mock(MeasurementImageStorage.class);
FileObjectMapper files = mock(FileObjectMapper.class);
WeightOcrClient ocr = mock(WeightOcrClient.class);
MeasurementImageService service = new MeasurementImageService(storage, files, ocr, "internal-token");
FileObjectEntity object = new FileObjectEntity();
object.setId(41L);
object.setTenantId(11L);
object.setBusinessType("INBOUND_MEASUREMENT_IMAGE");
object.setBusinessId("21");
when(files.selectOne(any())).thenReturn(object);
assertThatThrownBy(() -> service.retryRecognition(11L, 21L, 41L))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("不执行重量OCR");
verifyNoInteractions(ocr);
}
}