二期内容提交

This commit is contained in:
2026-08-04 19:48:35 +08:00
parent 954adb1287
commit c3bf4edd53
8 changed files with 281 additions and 6 deletions
@@ -9,6 +9,7 @@ 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.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
@@ -17,6 +18,10 @@ import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
/** Firstleg 保存第三方尺寸测量证据图片的内部入口。 */
@RestController
@@ -62,6 +67,26 @@ public class InternalMeasurementImageController {
return ApiResponse.success(null);
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
public ResponseEntity<byte[]> download(
@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");
}
var image = images.download(tenantId, inboundId, fileId);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(image.contentType()))
.contentLength(image.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename(image.fileName(), StandardCharsets.UTF_8).build().toString())
.body(image.content());
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
@@ -40,4 +40,16 @@ class InternalMeasurementImageControllerTest {
.isInstanceOf(BizException.class)
.hasMessageContaining("凭证无效");
}
@Test
void 内部下载同时校验租户入库单和文件ID() {
when(images.download(11L, 21L, 41L)).thenReturn(
new MeasurementImageService.Download("measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1, 2, 3}));
var response = controller.download("internal-token", 11L, 21L, 41L);
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.IMAGE_PNG);
assertThat(response.getBody()).containsExactly(1, 2, 3);
verify(images).download(11L, 21L, 41L);
}
}
@@ -5,6 +5,7 @@ import org.springframework.cloud.openfeign.FeignClient;
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.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
@@ -28,6 +29,13 @@ public interface MeasurementImageClient {
@PathVariable long inboundId,
@PathVariable long fileId);
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
org.springframework.http.ResponseEntity<byte[]> download(
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId);
record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes,
String recognitionStatus, BigDecimal weightValue, String weightUnit,
Double confidence, String error) {
@@ -14,6 +14,9 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@Tag(name = "客户端入库结果与费用")
@RestController
@@ -36,4 +39,20 @@ public class MerchantInboundBillingController {
}
return ApiResponse.success(service.find(user.tenantId(), orderId));
}
@GetMapping("/{orderId}/inbound-billing/images/{fileId}")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "查看已发布计费明细中的测量或称重图片")
public ResponseEntity<byte[]> image(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long orderId,
@PathVariable long fileId) {
if (user == null || !"MERCHANT".equals(user.appCode()) || user.tenantId() == null) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
}
var image = service.downloadImage(user.tenantId(), orderId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(image.contentType()))
.contentLength(image.content().length).body(image.content());
}
}
@@ -2,6 +2,47 @@ package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import java.math.BigDecimal;
import java.util.List;
public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
MeasurementSummary measurementSummary,
List<MeasurementItem> measurementItems,
List<WeightResult> weightResults,
List<ImageReference> measurementImages,
List<ImageReference> weightImages,
BillingBasis billingBasis,
FeeSnapshotResponse latestFee) {
public record MeasurementSummary(
Long measurementId, String sourceType, String measurementStatus,
Integer actualPackageCount, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName) {
}
public record ImageReference(
Long fileId, String imageType, Long measurementItemId,
Long weightResultId, String previewUrl) {
}
public record MeasurementItem(
Long id, String itemNo, String boxSpecification, Integer packageCount,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal unitWeightKg, BigDecimal totalWeightKg,
BigDecimal unitVolumeCbm, BigDecimal totalVolumeCbm,
BigDecimal volumeWeightKg) {
}
public record WeightResult(
Long id, Long fileId, BigDecimal recognizedWeightValue, String recognizedWeightUnit,
BigDecimal adjustedWeightValue, String adjustedWeightUnit,
BigDecimal finalWeightKg, String recognitionStatus) {
}
public record BillingBasis(
String chargeMode, String chargeUnit, String chargeableBasis,
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity,
BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm) {
}
}
@@ -3,18 +3,34 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.MerchantInboundBillingResponse;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundOrderRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class MerchantInboundBillingService {
private final InboundOrderRepository orders;
private final FbaPricingService pricing;
private final InboundMeasurementService measurements;
private final MeasurementImageClient imageClient;
private final String internalToken;
public MerchantInboundBillingService(InboundOrderRepository orders, FbaPricingService pricing) {
public MerchantInboundBillingService(InboundOrderRepository orders, FbaPricingService pricing,
InboundMeasurementService measurements,
MeasurementImageClient imageClient,
@org.springframework.beans.factory.annotation.Value(
"${baiyue.security.internal-service-token}") String internalToken) {
this.orders = orders;
this.pricing = pricing;
this.measurements = measurements;
this.imageClient = imageClient;
this.internalToken = internalToken;
}
public MerchantInboundBillingResponse find(long tenantId, long orderId) {
@@ -25,7 +41,90 @@ public class MerchantInboundBillingService {
if (!"INBOUNDED".equals(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "入库费用尚未由客服审核并发布");
}
return new MerchantInboundBillingResponse(orders.detail(tenantId, inbound),
pricing.latest(tenantId, inbound.getId()));
InboundOrderDetail platformDetail = orders.detail(tenantId, inbound);
InboundOrderDetail merchantDetail = new InboundOrderDetail(
platformDetail.summary(), platformDetail.publicRemark(), platformDetail.palletLocations(),
platformDetail.files().stream()
.filter(file -> "PUBLIC".equals(file.visibility()) || "MERCHANT".equals(file.visibility()))
.toList(), List.of(), List.of());
InboundMeasurementResponses.Detail measurement = measurements.detail(tenantId, inbound.getId());
FeeSnapshotResponse fee = pricing.latest(tenantId, inbound.getId());
var summary = new MerchantInboundBillingResponse.MeasurementSummary(
measurement.measurementId(), measurement.sourceType(), measurement.measurementStatus(),
measurement.actualPackageCount(), measurement.actualGrossWeightKg(),
measurement.actualVolumeCbm(), measurement.volumeWeightKg(), measurement.actualGoodsName());
var measurementImages = measurement.items().stream()
.filter(item -> item.intelligentImageFileId() != null)
.map(item -> new MerchantInboundBillingResponse.ImageReference(
item.intelligentImageFileId(), "MEASUREMENT", item.id(), null,
previewUrl(orderId, item.intelligentImageFileId())))
.toList();
var weightImages = measurement.weightResults().stream()
.filter(weight -> weight.fileId() != null)
.map(weight -> new MerchantInboundBillingResponse.ImageReference(
weight.fileId(), "WEIGHT", null, weight.id(), previewUrl(orderId, weight.fileId())))
.toList();
var measurementItems = measurement.items().stream()
.map(item -> new MerchantInboundBillingResponse.MeasurementItem(
item.id(), item.itemNo(), item.boxSpecification(), item.packageCount(),
item.lengthCm(), item.widthCm(), item.heightCm(), item.unitWeightKg(),
item.totalWeightKg(), item.unitVolumeCbm(), item.totalVolumeCbm(), item.volumeWeightKg()))
.toList();
var weightResults = measurement.weightResults().stream()
.map(weight -> new MerchantInboundBillingResponse.WeightResult(
weight.id(), weight.fileId(), weight.ocrWeightValue(), weight.ocrWeightUnit(),
weight.adjustedWeightValue(), weight.adjustedWeightUnit(),
weight.finalWeightKg(), weight.recognitionStatus()))
.toList();
var basis = new MerchantInboundBillingResponse.BillingBasis(
fee.chargeMode(), fee.chargeUnit(), chargeableBasis(fee), fee.actualWeightKg(),
fee.actualVolumeCbm(), fee.convertedQuantity(), fee.chargeableQuantity(),
platformDetail.summary().chargeableWeightKg(),
platformDetail.summary().chargeableVolumeCbm());
return new MerchantInboundBillingResponse(merchantDetail, summary, measurementItems,
weightResults, measurementImages, weightImages, basis, fee);
}
public ImageDownload downloadImage(long tenantId, long orderId, long fileId) {
InboundOrderEntity inbound = requirePublished(tenantId, orderId);
InboundMeasurementResponses.Detail measurement = measurements.detail(tenantId, inbound.getId());
boolean referenced = measurement.items().stream()
.anyMatch(item -> Long.valueOf(fileId).equals(item.intelligentImageFileId()))
|| measurement.weightResults().stream()
.anyMatch(weight -> Long.valueOf(fileId).equals(weight.fileId()));
if (!referenced) throw new BizException(ResultCode.NOT_FOUND, "计费明细中不存在该测量图片");
org.springframework.http.ResponseEntity<byte[]> response = imageClient.download(
internalToken, tenantId, inbound.getId(), fileId);
if (response == null || response.getBody() == null) {
throw new BizException(ResultCode.NOT_FOUND, "测量图片不存在");
}
String contentType = response.getHeaders().getContentType() == null
? "application/octet-stream" : response.getHeaders().getContentType().toString();
return new ImageDownload(response.getBody(), contentType);
}
private InboundOrderEntity requirePublished(long tenantId, long orderId) {
InboundOrderEntity inbound = orders.findByOrderId(tenantId, orderId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "订单尚未生成入库单");
if (!"INBOUNDED".equals(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "入库费用尚未由客服审核并发布");
}
return inbound;
}
private String previewUrl(long orderId, long fileId) {
return "/api/merchant/v1/first-leg/orders/" + orderId
+ "/inbound-billing/images/" + fileId;
}
private String chargeableBasis(FeeSnapshotResponse fee) {
int compared = ("KG".equals(fee.chargeMode()) ? fee.actualWeightKg() : fee.actualVolumeCbm())
.compareTo(fee.convertedQuantity());
if (compared == 0) return "EQUAL";
if ("KG".equals(fee.chargeMode())) return compared > 0 ? "ACTUAL_WEIGHT" : "VOLUME_WEIGHT";
return compared > 0 ? "ACTUAL_VOLUME" : "WEIGHT_CONVERTED_VOLUME";
}
public record ImageDownload(byte[] content, String contentType) {
}
}
@@ -32,4 +32,11 @@ class MerchantInboundBillingControllerContractTest {
"admin", "PLATFORM", "WEB", Set.of());
assertThatThrownBy(() -> controller.find(platform, 77L)).isInstanceOf(BizException.class);
}
@Test
void 商户测量图片复用订单查看权限() throws Exception {
var method = MerchantInboundBillingController.class.getDeclaredMethod(
"image", AuthenticatedUser.class, long.class, long.class);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('merchant:order:view')");
}
}
@@ -3,10 +3,15 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundOrderRepository;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
@@ -17,20 +22,43 @@ import static org.mockito.Mockito.when;
class MerchantInboundBillingServiceTest {
private final InboundOrderRepository orders = mock(InboundOrderRepository.class);
private final FbaPricingService pricing = mock(FbaPricingService.class);
private final MerchantInboundBillingService service = new MerchantInboundBillingService(orders, pricing);
private final InboundMeasurementService measurements = mock(InboundMeasurementService.class);
private final MeasurementImageClient imageClient = mock(MeasurementImageClient.class);
private final MerchantInboundBillingService service = new MerchantInboundBillingService(
orders, pricing, measurements, imageClient, "internal-token");
@Test
void 客户只能看到本租户已完成入库且已发布的费用() {
InboundOrderEntity inbound = inbound("INBOUNDED", "SENT");
InboundOrderDetail detail = mock(InboundOrderDetail.class);
InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class),
"客户备注", List.of(), List.of(
new InboundOrderDetail.FileSummary(1L, "RELATED", "public.pdf", "PUBLIC", 1, 1L, null),
new InboundOrderDetail.FileSummary(2L, "INTERNAL", "internal.pdf", "INTERNAL", 1, 1L, null)),
List.of(new InboundOrderDetail.InternalNoteSummary(1L, "内部备注", 1L, "客服", null)),
List.of(new InboundOrderDetail.StatusHistorySummary(null, "INBOUNDED", "COMPLETE",
"内部原因", 1L, "客服", null)));
InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class);
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
when(orders.detail(10001L, inbound)).thenReturn(detail);
when(measurements.detail(10001L, 88L)).thenReturn(measurement);
when(measurement.items()).thenReturn(List.of());
when(measurement.weightResults()).thenReturn(List.of());
when(pricing.latest(10001L, 88L)).thenReturn(fee);
when(fee.chargeMode()).thenReturn("KG");
when(fee.chargeUnit()).thenReturn("KG");
when(fee.actualWeightKg()).thenReturn(new BigDecimal("20"));
when(fee.actualVolumeCbm()).thenReturn(new BigDecimal("0.24"));
when(fee.convertedQuantity()).thenReturn(new BigDecimal("40.08"));
when(fee.chargeableQuantity()).thenReturn(new BigDecimal("40.08"));
var result = service.find(10001L, 77L);
assertThat(result.inbound()).isSameAs(detail);
assertThat(result.inbound().files()).extracting(InboundOrderDetail.FileSummary::fileId)
.containsExactly(1L);
assertThat(result.inbound().internalNotes()).isEmpty();
assertThat(result.inbound().statusHistory()).isEmpty();
assertThat(result.billingBasis().chargeableBasis()).isEqualTo("VOLUME_WEIGHT");
assertThat(result.latestFee()).isSameAs(fee);
verify(orders).findByOrderId(10001L, 77L);
}
@@ -56,6 +84,42 @@ class MerchantInboundBillingServiceTest {
verify(pricing, never()).latest(20002L, 88L);
}
@Test
void 只能下载已发布计费明细实际引用的图片() {
InboundOrderEntity inbound = inbound("INBOUNDED", "SENT");
InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class);
InboundMeasurementResponses.Item item = mock(InboundMeasurementResponses.Item.class);
when(item.intelligentImageFileId()).thenReturn(41L);
when(measurement.items()).thenReturn(List.of(item));
when(measurement.weightResults()).thenReturn(List.of());
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
when(measurements.detail(10001L, 88L)).thenReturn(measurement);
when(imageClient.download("internal-token", 10001L, 88L, 41L)).thenReturn(
org.springframework.http.ResponseEntity.ok()
.contentType(org.springframework.http.MediaType.IMAGE_PNG)
.body(new byte[]{1, 2, 3}));
var image = service.downloadImage(10001L, 77L, 41L);
assertThat(image.contentType()).isEqualTo("image/png");
assertThat(image.content()).containsExactly(1, 2, 3);
}
@Test
void 拒绝下载未被当前计费明细引用的图片() {
InboundOrderEntity inbound = inbound("INBOUNDED", "SENT");
InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class);
when(measurement.items()).thenReturn(List.of());
when(measurement.weightResults()).thenReturn(List.of());
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
when(measurements.detail(10001L, 88L)).thenReturn(measurement);
assertThatThrownBy(() -> service.downloadImage(10001L, 77L, 99L))
.isInstanceOf(BizException.class)
.hasMessageContaining("不存在该测量图片");
verify(imageClient, never()).download("internal-token", 10001L, 88L, 99L);
}
private static InboundOrderEntity inbound(String process, String feeStatus) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(88L);