This commit is contained in:
2026-08-06 11:49:51 +08:00
parent a11e47fe3b
commit 7dd92f1c84
91 changed files with 6233 additions and 174 deletions
@@ -0,0 +1,51 @@
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.LoadingEvidenceService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Firstleg 使用的装柜证据文件内部接口。 */
@RestController
@RequestMapping("/internal/file/v1/loading-evidence")
public class InternalLoadingEvidenceController {
@Resource private LoadingEvidenceService service;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expectedToken;
@PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); }
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long tenantId, @PathVariable long inboundId,
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0");
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
public ApiResponse<LoadingEvidenceService.FileMetadata> validate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
return ApiResponse.success(service.validate(tenantId, inboundId, fileId));
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -0,0 +1,54 @@
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.common.security.AuthenticatedUser;
import com.baiyue.file.service.LoadingEvidenceService;
import jakarta.annotation.Resource;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
/** 平台及作业端装柜证据上传和读取。 */
@RestController
@RequestMapping("/api/platform/v1/files/loading-evidence")
public class PlatformLoadingEvidenceController {
@Resource private LoadingEvidenceService service;
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable long inboundId,
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
long operatorId = requirePlatform(user); requirePositive(tenantId, inboundId);
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
requirePlatform(user); requirePositive(tenantId, inboundId, fileId);
var value = service.download(tenantId, inboundId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content());
}
private long requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) || user.tenantId() == null
|| user.tenantId() != 0L || user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份");
}
return user.userId();
}
private static void requirePositive(long... ids) {
for (long id : ids) if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0");
}
}
@@ -0,0 +1,74 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
/** 装柜四步照片和铅封证据的受控文件入口。 */
@Service
public class LoadingEvidenceService {
@Resource private MeasurementImageStorage storage;
@Resource private FileObjectMapper files;
@Transactional
public UploadResult upload(long tenantId, long inboundId, long operatorId, String evidenceType,
MultipartFile file) {
validateType(evidenceType);
var stored = storage.storeLoading(tenantId, inboundId, file);
FileObjectEntity object = new FileObjectEntity();
object.setTenantId(tenantId);
object.setBusinessType("LOADING_" + evidenceType);
object.setBusinessId(Long.toString(inboundId));
object.setOriginalName(stored.originalName());
object.setObjectKey(stored.objectKey());
object.setStorageProvider("LOCAL");
object.setBucketName("loading-evidence");
object.setContentType(stored.contentType());
object.setSizeBytes(stored.size());
object.setSha256(stored.sha256());
object.setStatus("ACTIVE");
object.setCreatedBy(operatorId); object.setUpdatedBy(operatorId);
object.setCreatedAt(LocalDateTime.now()); object.setUpdatedAt(LocalDateTime.now());
object.setDeleted(false); object.setVersion(0);
files.insert(object);
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), object.getSizeBytes(), object.getBusinessType());
}
public FileMetadata validate(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
return new FileMetadata(object.getId(), object.getOriginalName(), object.getBusinessType(), object.getContentType());
}
public Download download(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
return new Download(object.getOriginalName(), object.getContentType(), storage.read(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::getBusinessId, Long.toString(inboundId))
.likeRight(FileObjectEntity::getBusinessType, "LOADING_")
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在");
return object;
}
private static void validateType(String type) {
if (type == null || !(type.matches("PHOTO_STEP_[1-4]") || "SEAL".equals(type))) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型");
}
}
public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes, String businessType) {}
public record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {}
public record Download(String fileName, String contentType, byte[] content) {}
}
@@ -30,13 +30,21 @@ public class MeasurementImageStorage {
void initializeRoot() { root = configuredRoot.toAbsolutePath().normalize(); }
public Stored store(long tenantId, long inboundId, MultipartFile file) {
return store(tenantId, inboundId, file, "measurements");
}
public Stored storeLoading(long tenantId, long inboundId, MultipartFile file) {
return store(tenantId, inboundId, file, "loading-evidence");
}
private Stored store(long tenantId, long inboundId, MultipartFile file, String directory) {
if (file == null || file.isEmpty()) throw bad("称重图片不能为空");
if (file.getSize() > MAX_SIZE) throw bad("称重图片不能超过5MB");
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!TYPES.contains(type)) throw bad("称重图片仅支持JPG、PNG、BMP");
verifyImage(file);
String extension = "image/png".equals(type) ? ".png" : ("image/bmp".equals(type) ? ".bmp" : ".jpg");
String objectKey = "measurements/" + tenantId + "/" + inboundId + "/" + UUID.randomUUID() + extension;
String objectKey = directory + "/" + tenantId + "/" + inboundId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try {
@@ -0,0 +1,51 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LoadingEvidenceServiceTest {
@Mock MeasurementImageStorage storage;
@Mock FileObjectMapper files;
@InjectMocks LoadingEvidenceService service;
@Test
void uploadsEvidenceWithDedicatedBusinessType() {
var multipart = new MockMultipartFile("file", "step.jpg", "image/jpeg", new byte[]{1});
when(storage.storeLoading(10, 20, multipart)).thenReturn(new MeasurementImageStorage.Stored(
"loading-evidence/10/20/a.jpg", Path.of("a.jpg"), "image/jpeg", 1, "hash", "step.jpg"));
var result = service.upload(10, 20, 7, "PHOTO_STEP_2", multipart);
assertThat(result.businessType()).isEqualTo("LOADING_PHOTO_STEP_2");
verify(files).insert(any(FileObjectEntity.class));
}
@Test
void rejectsUnknownEvidenceType() {
var multipart = new MockMultipartFile("file", "x.jpg", "image/jpeg", new byte[]{1});
assertThatThrownBy(() -> service.upload(10, 20, 7, "UNKNOWN", multipart)).isInstanceOf(BizException.class);
}
@Test
void validatesTenantBoundFile() {
FileObjectEntity object = new FileObjectEntity();
object.setId(30L); object.setOriginalName("seal.jpg"); object.setBusinessType("LOADING_SEAL"); object.setContentType("image/jpeg");
when(files.selectOne(any(Wrapper.class))).thenReturn(object);
assertThat(service.validate(10, 20, 30).fileId()).isEqualTo(30L);
}
}