From 331f7508715a8aba5c27acde9845bcab7ed3b6a4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 10 Oct 2026 14:44:23 +0800 Subject: [PATCH] fix(procurement): reject unsupported purchase payload fields --- .../dto/OverseasProcurementRequests.java | 7 ++++- .../ProcurementRequestBoundaryTest.java | 23 ++++++++++++++ .../ProcurementPhysicalPersistenceTest.java | 24 +++++++++++++++ .../ProcurementPurchasePersistenceTest.java | 30 +++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java index 0b9b3f9d7..d17d98de1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -28,7 +28,12 @@ public final class OverseasProcurementRequests { @NotBlank @Size(max = 128) String contactName, @NotBlank @Size(max = 64) @jakarta.validation.constraints.Pattern(regexp="[+0-9][0-9 ()-]{5,63}") String contactPhone, @Size(max = 1000) String remark, - @NotBlank @Size(max = 255) String idempotencyKey) {} + @NotBlank @Size(max = 255) String idempotencyKey) { + @com.fasterxml.jackson.annotation.JsonAnySetter + public void rejectUnknownField(String name, JsonNode value) { + throw new IllegalArgumentException("采购下单仅支持单商品、单规格及一次购买批次,不支持扩展字段:" + name); + } + } public record Query( @Min(1) long page, @Min(1) @Max(200) long size, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java index 5cf726db8..9669a8e8a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java @@ -10,6 +10,29 @@ import java.util.List; import static org.assertj.core.api.Assertions.*; class ProcurementRequestBoundaryTest { + @Test void createRejectsMultiProductAndBatchExtensionsWithApplicationJacksonDefaults() { + new org.springframework.boot.test.context.runner.ApplicationContextRunner() + .withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of( + org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration.class)) + .withUserConfiguration(com.baiyue.common.web.LongIdJacksonConfiguration.class) + .run(context -> { + var json=context.getBean(ObjectMapper.class); + assertThat(json.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse(); + String valid=""" + {"purchaseCategory":"sample","productName":"Sample","productSpecification":"A", + "purchaseQuantity":1,"budgetUnitPrice":10,"currency":"CNY","receivingAddress":"Address", + "contactName":"Buyer","contactPhone":"13800000000","idempotencyKey":"one"} + """; + assertThat(json.readValue(valid,OverseasProcurementRequests.Create.class).productName()).isEqualTo("Sample"); + for(String field:List.of("items","products","batches","purchaseBatches","shipmentBatches")) { + var extended=(com.fasterxml.jackson.databind.node.ObjectNode)json.readTree(valid); + extended.putArray(field).addObject().put("productName","Second product"); + assertThatThrownBy(()->json.treeToValue(extended,OverseasProcurementRequests.Create.class)) + .as(field).hasMessageContaining("单商品"); + } + }); + } + @Test void missingCommandVersionCannotDefaultToZero() throws Exception { var json=new ObjectMapper().findAndRegisterModules(); try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java index c686dfe9a..2e6a01164 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java @@ -109,6 +109,30 @@ class ProcurementPhysicalPersistenceTest { assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_manifest",Integer.class)).isZero(); } + @Test void nonPositiveMeasurementsAndOverPreciseWeightCannotPersistPacking() { + var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0); + var source=List.of(new Source(receipt.id(),10)); + for(BigDecimal invalid:List.of(BigDecimal.ZERO,BigDecimal.ONE.negate())) { + for(int axis=0;axis<4;axis++) { + BigDecimal[] values={BigDecimal.TEN,BigDecimal.TEN,BigDecimal.TEN,BigDecimal.ONE};values[axis]=invalid; + var carton=new Carton("C1",values[0],values[1],values[2],values[3],source); + assertThatThrownBy(()->pack(List.of(carton))).hasMessageContaining("正数"); + } + } + assertThatThrownBy(()->pack(List.of(new Carton("C1",BigDecimal.TEN,BigDecimal.TEN,BigDecimal.TEN,new BigDecimal("1.0001"),source)))) + .hasMessageContaining("最多三位"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_manifest",Integer.class)).isZero(); + } + + @Test void foreignReceiptAndIncompleteAllocationCannotBecomeConfirmedPacking() { + var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS); + assertThatThrownBy(()->pack(List.of(carton("C1",new Source(999,10))))).hasMessageContaining("不属于本单"); + var draft=pack(List.of(carton("C1",new Source(receipt.id(),9)))); + assertThatThrownBy(()->tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"incomplete",draft.packingVersion(),"checked")))) + .hasMessageContaining("全部"); + assertThat(service.read("9","PO-1").packingStatus()).isEqualTo("DRAFT"); + } + @Test void repackingCreatesANewVersionAndCannotRewriteAnActualDispatch() { var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS); var first=pack(List.of(carton("C1",new Source(receipt.id(),10)))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java index 4048bab32..cc449ad89 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java @@ -113,6 +113,36 @@ class ProcurementPurchasePersistenceTest { assertThat(confirmed.purchase().confirmed()).isFalse();verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); } + @Test void unreviewedPurchaseFactCannotConfirmOrCharge() { + jdbc.update("UPDATE ll_overseas_procurement_order SET reviewed_at=NULL,status='PENDING_REVIEW' WHERE id=11"); + var recorded=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); + assertThat(recorded.purchase().recorded()).isTrue(); + assertThat(recorded.purchase().confirmed()).isFalse(); + assertThat(recorded.purchase().issue()).contains("尚未审核"); + var again=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(1L,"confirm-unreviewed","actual fact"))); + assertThat(again.purchase().confirmed()).isFalse(); + assertThat(again.status()).isEqualTo("PENDING_REVIEW"); + verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); + } + + @Test void missingBudgetCannotConfirmOrInventACharge() { + jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=NULL WHERE id=11"); + var recorded=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); + assertThat(recorded.purchase().recorded()).isTrue(); + assertThat(recorded.purchase().confirmed()).isFalse(); + assertThat(recorded.purchase().issue()).contains("预算尚未明确"); + assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("80"); + verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); + } + + @Test void anotherPurchaseKeyCannotAppendOrOverwriteRecordedPurchase() { + tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); + var another=new Purchase(1L,"purchase-2","Sample","A",10,new BigDecimal("90"),"CNY",LocalDateTime.now().minusDays(1),LocalDateTime.now().plusDays(3),1,"additional purchase"); + assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",another))).hasMessageContaining("不能再次购买"); + assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("80"); + verify(finance,times(1)).confirm(anyString(),anyString(),anyString(),any()); + } + @Test void cancellationCannotErasePurchasedFacts() { tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); assertThatThrownBy(()->tx.execute(s->service.cancel("9","PO-1","7",new Command(1L,"cancel","reason")))).hasMessageContaining("购买后不能取消");