Merge branch 'dev' into dev-lyhfive

This commit is contained in:
2026-08-26 16:07:27 +08:00
21 changed files with 406 additions and 20 deletions
@@ -11,6 +11,10 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -24,6 +28,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@@ -66,6 +71,18 @@ public class ReceivingContainerController {
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
}
@GetMapping("/current/unload-photos/files/{fileId}/download")
public ResponseEntity<byte[]> downloadCurrentPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String fileId) {
PdaWarehouseIdentity identity = currentIdentity(user);
var download = service.downloadCurrentPhoto(identity.operatorKey(), fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline().filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(download.contentType()))
.contentLength(download.content().length).body(download.content());
}
@PostMapping("/current/sign")
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
@@ -17,7 +17,7 @@ public final class ReceivingContainerResponses {
int totalPackages, int receivedPackages, int pendingPackages,
List<Inbound> inbounds) { }
public record SignedPhoto(ReceivingPhotoRole role, String fileId) { }
public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { }
public record Inbound(String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
@@ -8,6 +8,9 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Component;
@@ -85,6 +88,39 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
}
}
@Override
public DownloadedFile download(String customerId, long taskId, long fileId) {
long tenantId = tenantId(customerId);
ResponseEntity<byte[]> response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content",
tenantId, BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, result) -> {
HttpStatusCode status = result.getStatusCode();
if (status.value() == HttpStatus.NOT_FOUND.value()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Unload photo was not found");
}
if (status.value() == HttpStatus.FORBIDDEN.value()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Unload photo is not in current account scope");
}
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo download failed");
})
.onStatus(HttpStatusCode::is5xxServerError, (request, result) -> {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Unload photo download failed");
})
.toEntity(byte[].class);
byte[] content = response.getBody() == null ? new byte[0] : response.getBody();
String fileName = contentDispositionFilename(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION));
if (fileName == null || fileName.isBlank()) {
fileName = String.valueOf(fileId) + ".bin";
}
String contentType = response.getHeaders().getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
return new DownloadedFile(content, fileName, contentType);
}
private long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) { throw new ResponseStatusException(
@@ -104,6 +140,15 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
response.message() == null || response.message().isBlank() ? fallback : response.message());
}
private static String contentDispositionFilename(String rawValue) {
if (rawValue == null || rawValue.isBlank()) return null;
try {
return ContentDisposition.parse(rawValue).getFilename();
} catch (IllegalArgumentException ignored) {
return rawValue.replace("attachment; filename=\"", "").replace("\"", "").trim();
}
}
private record MetadataEnvelope(String code, String message, Metadata data) {}
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) {}
@@ -5,6 +5,8 @@ import org.springframework.web.multipart.MultipartFile;
public interface UnloadPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
DownloadedFile download(String customerId, long taskId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
record DownloadedFile(byte[] content, String fileName, String contentType) {}
}
@@ -17,6 +17,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
@@ -58,6 +59,9 @@ import java.util.stream.Collectors;
@Service
public class ReceivingContainerService {
private static final String PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@Resource private InboundPlanPalletMapper pallets;
@@ -198,6 +202,16 @@ public class ReceivingContainerService {
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
}
public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) {
ReceivingContainerSession session = requireCurrent(operatorKey, false);
long parsedFileId = positiveFileId(fileId);
if (signedPhotos(session).stream().noneMatch(photo -> fileId.equals(photo.fileId()))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Unload photo is not part of the current receiving session");
}
return unloadPhotos.download(session.getCustomerId(), session.getId(), parsedFileId);
}
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
}
@@ -510,7 +524,7 @@ public class ReceivingContainerService {
throw conflict("Exactly three typed unload photos are required");
}
if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null");
Set<com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole> roles = request.photos().stream()
Set<ReceivingPhotoRole> roles = request.photos().stream()
.map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet());
if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once");
Set<Long> fileIds = request.photos().stream().map(value -> positiveFileId(value.fileId()))
@@ -520,7 +534,7 @@ public class ReceivingContainerService {
}
return request.photos().stream()
.sorted(Comparator.comparing(value -> value.role().ordinal()))
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList();
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId(), null)).toList();
}
private long positiveFileId(String value) {
@@ -726,6 +740,13 @@ public class ReceivingContainerService {
}
private List<ReceivingContainerResponses.SignedPhoto> signPhotos(ReceivingContainerSession session) {
return signedPhotos(session).stream()
.map(photo -> new ReceivingContainerResponses.SignedPhoto(photo.role(), photo.fileId(),
PHOTO_DOWNLOAD_PATH.replace("{fileId}", photo.fileId())))
.toList();
}
private List<ReceivingContainerResponses.SignedPhoto> signedPhotos(ReceivingContainerSession session) {
if (session.getSignPhotos() == null || session.getSignPhotos().isBlank()) return List.of();
try { return objectMapper.readValue(session.getSignPhotos(),
new TypeReference<List<ReceivingContainerResponses.SignedPhoto>>() { }); }