diff --git a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java index c0bb9d5ed..bb60517ca 100644 --- a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java +++ b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java @@ -22,7 +22,7 @@ public interface IamOnboardingClient { @PathVariable("authUserId") Long authUserId); record OnboardingRequest(Long authUserId, String displayName, String companyName, - String legalPerson, String legalPhone, String contactEmail, + String legalPerson, String administratorPhone, String contactEmail, String countryCode, String address) { } record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus, diff --git a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java index 5901b1d0e..6130bf029 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -77,7 +77,7 @@ public class AuthController { @SecurityRequirements @PostMapping("/register") @Operation(summary = "Merchant registration / 客户注册", - description = "Create a login account and an enterprise profile awaiting the required business license and legal-person ID; other qualification files are optional.") + description = "Create a login account and enterprise profile. The supplied phone initializes both the first administrator login phone and company contact phone; they are maintained independently after creation. Only the business license and unified social credit code are required for review; legal-person ID is optional.") public ApiResponse register(@Valid @RequestBody MerchantRegisterRequest request) { return ApiResponse.success(registrationService.register(request)); } diff --git a/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java index 41093a628..e63be9874 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java +++ b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java @@ -45,7 +45,7 @@ public final class AccountManagementDtos { @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @NotBlank String currentPassword) { } public record UpdateAccountPhoneRequest( - @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone) { } + @Pattern(regexp = "^1[3-9]\\d{9}$") String phone) { } public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { } public record UpdateAccountStatusRequest(@NotBlank String status) { } public record RevokeAccountSessionsRequest( diff --git a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java index 13287dedd..8203a100e 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java +++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java @@ -13,7 +13,8 @@ public record MerchantRegisterRequest( @NotBlank @Size(max = 64) String displayName, @NotBlank @Size(max = 128) String companyName, @NotBlank @Size(max = 64) String legalPerson, - @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String legalPhone, + @Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护") + @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @Email @Size(max = 128) String contactEmail, @Size(min = 2, max = 2) String countryCode, @Size(max = 255) String address diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java index 651cc0b8f..6c64184e1 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -151,9 +151,9 @@ public class AccountManagementService { description = "同步受管账号手机号", captureAfter = false) public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) { AuthAccount account = required(accountId); - String phone = request.phone().trim(); + String phone = request.phone() == null ? null : request.phone().trim(); if (Objects.equals(account.getPhone(), phone)) return; - if (repository.existsByPhoneForOtherAccount(phone, accountId)) { + if (phone != null && repository.existsByPhoneForOtherAccount(phone, accountId)) { throw new BizException(ResultCode.CONFLICT, "手机号已存在"); } account.setPhone(phone); diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java index e0fc11be3..1ce404873 100644 --- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java +++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java @@ -38,7 +38,7 @@ public class MerchantRegistrationService { if (accountRepository.existsByUsername(request.username())) { throw new BizException(ResultCode.CONFLICT, "用户名已存在"); } - if (accountRepository.existsByPhone(request.legalPhone())) { + if (accountRepository.existsByPhone(request.phone())) { throw new BizException(ResultCode.CONFLICT, "手机号已存在"); } long accountId = IdWorker.getId(); @@ -49,7 +49,7 @@ public class MerchantRegistrationService { try { var response = onboardingClient.register(internalToken, new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(), - request.companyName(), request.legalPerson(), request.legalPhone(), + request.companyName(), request.legalPerson(), request.phone(), request.contactEmail(), request.countryCode(), request.address())); if (response == null) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed"); @@ -84,7 +84,7 @@ public class MerchantRegistrationService { AuthAccount account = new AuthAccount(); account.setId(accountId); account.setUsername(request.username()); - account.setPhone(request.legalPhone()); + account.setPhone(request.phone()); account.setPasswordHash(passwordHash); account.setDisplayName(request.displayName()); account.setAccountStatus("ACTIVE"); diff --git a/auth/src/test/java/com/baiyue/auth/repository/LoginIdentifierContractTest.java b/auth/src/test/java/com/baiyue/auth/repository/LoginIdentifierContractTest.java index 0b471f746..5ce5d40e9 100644 --- a/auth/src/test/java/com/baiyue/auth/repository/LoginIdentifierContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/repository/LoginIdentifierContractTest.java @@ -7,11 +7,21 @@ import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat; class LoginIdentifierContractTest { + @Test + void registrationExposesAnAccountPhoneInsteadOfALegalPersonPhoneAlias() { + var componentNames = Arrays.stream(MerchantRegisterRequest.class.getRecordComponents()) + .map(component -> component.getName()) + .toList(); + + assertThat(componentNames).contains("phone").doesNotContain("legalPhone"); + } + @Test void mainlandMobileRuleRejectsPlaceholderNumbersAcrossJavaAndSql() throws Exception { try (var factory = Validation.buildDefaultValidatorFactory()) { diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java index 8b207c1e3..ee951962c 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest; import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest; +import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountPhoneRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest; import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest; @@ -173,6 +174,17 @@ class AccountManagementServiceTest { verify(sessions).revokeAll(10L); } + @Test void managedPhoneChangeOrRemovalRevokesOnlyThatAccountsSessions() { + account.setPhone("13800138000"); + + service.updatePhone(10L, new UpdateAccountPhoneRequest(null)); + + assertEquals(null, account.getPhone()); + assertEquals(3, account.getTokenVersion()); + verify(repository).save(account); + verify(sessions).revokeAll(10L); + } + @Test void duplicatePhoneDoesNotChangeLoginIdentifiers() { account.setUsername("old.user"); account.setPhone("13800138000"); diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java index fd61c5925..b09d43d33 100644 --- a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java @@ -54,6 +54,8 @@ class MerchantRegistrationServiceTest { assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId()); assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode()); assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus()); + assertEquals("13800138000", storedAccount.getValue().getPhone()); + assertEquals("13800138000", onboardingRequest.getValue().administratorPhone()); assertEquals("encoded-password", storedAccount.getValue().getPasswordHash()); assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep()); assertEquals("PENDING", result.dimensionInitializationStatus()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java index 54fb3d8a9..43973be5d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java @@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.PriceBookChannelValidation; +import com.baiyue.firstleg.dto.OrderChannelChangePricing; import com.baiyue.firstleg.service.MerchantPriceBookService; +import com.baiyue.firstleg.service.OrderChannelChangePricingService; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; @@ -22,6 +24,7 @@ import java.security.MessageDigest; @RequestMapping("/internal/firstleg/v1/pricing") public class InternalPriceBookController { @Resource private MerchantPriceBookService service; + @Resource private OrderChannelChangePricingService channelChanges; @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expectedToken; @@ -30,6 +33,22 @@ public class InternalPriceBookController { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } + @PostMapping("/order-channel-changes/validate") + public ApiResponse validateOrderChannelChange( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody OrderChannelChangePricing.Request request) { + requireToken(token); + return ApiResponse.success(channelChanges.validate(request)); + } + + @PostMapping("/order-channel-changes/apply") + public ApiResponse applyOrderChannelChange( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody OrderChannelChangePricing.Request request) { + requireToken(token); + return ApiResponse.success(channelChanges.apply(request)); + } + @PostMapping("/channels/validate") public ApiResponse validateChannel( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderChannelChangePricing.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderChannelChangePricing.java new file mode 100644 index 000000000..e7ce31ce3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderChannelChangePricing.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** Order 审核后变更渠道时使用的 Firstleg 计价门禁与同步契约。 */ +public final class OrderChannelChangePricing { + private OrderChannelChangePricing() {} + + public record Request( + @Min(1) long tenantId, + @Min(1) long orderId, + @Min(0) long orderVersion, + @Min(1) long originWarehouseId, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 128) String channelName, + @NotNull @Pattern(regexp = "FBA|OVERSEAS") String destinationType, + Long destinationWarehouseId, + @Size(max = 64) String destinationCode, + @NotNull @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String pricingTaxMode + ) {} + + public record Response( + long inboundId, + long orderId, + long orderVersion, + String channelCode, + Long priceBookVersionId, + String feeStatus, + boolean calculatedFeeWillBeInvalidated, + Long invalidatedFeeId + ) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java index a8429d117..7e4d21022 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java @@ -17,6 +17,7 @@ public class InboundPricingEntity extends BaseEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long tenantId; + private Long orderId; private String inboundCode; private String processStatus; private String measureReviewStatus; @@ -27,6 +28,7 @@ public class InboundPricingEntity extends BaseEntity { private String warehouseName; private String businessType; private String channelCode; + private String channelName; private Long carrierId; private String carrierName; private String taxMode; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index d2cdc3d62..1f8929b1c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -119,6 +119,19 @@ public class FbaPricingRepository { .eq(InboundPricingEntity::getId, inboundId)); } + public InboundPricingEntity findInboundByOrderForUpdate(long tenantId, long orderId) { + return inbounds.selectOne(new LambdaQueryWrapper() + .eq(InboundPricingEntity::getTenantId, tenantId) + .eq(InboundPricingEntity::getOrderId, orderId) + .last("FOR UPDATE")); + } + + public InboundPricingEntity findInboundByOrder(long tenantId, long orderId) { + return inbounds.selectOne(new LambdaQueryWrapper() + .eq(InboundPricingEntity::getTenantId, tenantId) + .eq(InboundPricingEntity::getOrderId, orderId)); + } + public List matchRules(InboundPricingEntity inbound, LocalDateTime at) { return rules.selectList(new LambdaQueryWrapper() .eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId()) @@ -178,6 +191,31 @@ public class FbaPricingRepository { .orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1")); } + public InboundFeeEntity findLatestFeeForUpdate(long tenantId, long inboundId) { + return fees.selectOne(new LambdaQueryWrapper() + .eq(InboundFeeEntity::getTenantId, tenantId) + .eq(InboundFeeEntity::getInboundId, inboundId) + .orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1 FOR UPDATE")); + } + + public int applyOrderChannelChange(InboundPricingEntity inbound, String channelCode, String channelName) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(InboundPricingEntity::getTenantId, inbound.getTenantId()) + .eq(InboundPricingEntity::getId, inbound.getId()) + .in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "VOIDED")) + .set(InboundPricingEntity::getChannelCode, channelCode) + .set(InboundPricingEntity::getChannelName, channelName) + .set(InboundPricingEntity::getFeeStatus, "UNPRICED") + .set(InboundPricingEntity::getChargeableWeightKg, null) + .set(InboundPricingEntity::getChargeableVolumeCbm, null) + .set(InboundPricingEntity::getUpdatedAt, LocalDateTime.now()) + .setSql("version = version + 1"); + if ("FEE_CONFIRMING".equals(inbound.getProcessStatus())) { + update.set(InboundPricingEntity::getProcessStatus, "WAIT_PRICING"); + } + return inbounds.update(null, update); + } + public InboundFeeItemEntity findBaseItem(long tenantId, long feeId) { return items.selectOne(new LambdaQueryWrapper() .eq(InboundFeeItemEntity::getTenantId, tenantId) @@ -302,6 +340,16 @@ public class FbaPricingRepository { public void insertIdempotency(InboundPricingIdempotencyEntity value) { idempotencies.insert(value); } + public InboundPricingIdempotencyEntity findLatestCompletedOrderChannelChange(long tenantId, long inboundId) { + return idempotencies.selectOne(new LambdaQueryWrapper() + .eq(InboundPricingIdempotencyEntity::getTenantId, tenantId) + .eq(InboundPricingIdempotencyEntity::getInboundId, inboundId) + .eq(InboundPricingIdempotencyEntity::getOperation, "ORDER_CHANNEL_CHANGE") + .eq(InboundPricingIdempotencyEntity::getStatus, "COMPLETED") + .orderByDesc(InboundPricingIdempotencyEntity::getResultResourceId) + .last("LIMIT 1")); + } + public int completeIdempotency(InboundPricingIdempotencyEntity value, long resourceId, LocalDateTime now) { return idempotencies.update(null, new LambdaUpdateWrapper() .eq(InboundPricingIdempotencyEntity::getTenantId, value.getTenantId()) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java new file mode 100644 index 000000000..6b1d7c4f8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java @@ -0,0 +1,174 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.domain.PricingDestinationType; +import com.baiyue.firstleg.domain.PricingTaxMode; +import com.baiyue.firstleg.dto.OrderChannelChangePricing.Request; +import com.baiyue.firstleg.dto.OrderChannelChangePricing.Response; +import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; +import com.baiyue.firstleg.entity.InboundFeeEntity; +import com.baiyue.firstleg.entity.InboundPricingEntity; +import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import com.baiyue.firstleg.repository.FeeItemRepository; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Objects; + +@Service +public class OrderChannelChangePricingService { + private static final BigDecimal ELIGIBILITY_WEIGHT = new BigDecimal("1000000"); + private static final BigDecimal ELIGIBILITY_VOLUME = BigDecimal.ONE; + @Resource private FbaPricingRepository repository; + @Resource private FeeItemRepository feeItems; + @Resource private MerchantPriceBookService priceBooks; + @Resource private ActivePriceBookEstimator estimator; + + @Transactional(readOnly = true) + public Response validate(Request request) { + InboundPricingEntity inbound = requiredInbound(request, false); + InboundFeeEntity fee = repository.findLatestFee(request.tenantId(), inbound.getId()); + requireReversible(inbound, fee); + var estimate = requireEligiblePrice(request, inbound); + return response(request, inbound, estimate.priceBookVersionId(), fee, null); + } + + @Transactional + public Response apply(Request request) { + InboundPricingEntity inbound = requiredInbound(request, true); + String key = request.orderId() + ":" + request.orderVersion(); + String requestHash = hash(request); + InboundPricingIdempotencyEntity replay = repository.findIdempotency( + request.tenantId(), 0L, "ORDER_CHANNEL_CHANGE", key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) throw conflict("同一订单版本已用于不同渠道变更"); + if (!"COMPLETED".equals(replay.getStatus())) throw conflict("相同渠道变更正在处理中"); + var estimate = requireEligiblePrice(request, inbound); + return new Response(inbound.getId(), request.orderId(), request.orderVersion(), estimate.channelCode(), + estimate.priceBookVersionId(), inbound.getFeeStatus(), false, null); + } + InboundPricingIdempotencyEntity latest = repository.findLatestCompletedOrderChannelChange( + request.tenantId(), inbound.getId()); + if (latest != null && latest.getResultResourceId() > request.orderVersion()) { + throw conflict("订单渠道版本已推进,请勿重放旧版本"); + } + + InboundFeeEntity fee = repository.findLatestFeeForUpdate(request.tenantId(), inbound.getId()); + requireReversible(inbound, fee); + var estimate = requireEligiblePrice(request, inbound); + Long invalidatedFeeId = fee != null && "CALCULATED".equals(fee.getFeeStatus()) ? fee.getId() : null; + if (invalidatedFeeId != null) { + if (repository.supersedeCurrent(request.tenantId(), invalidatedFeeId, "CALCULATED") != 1) { + throw conflict("费用预估状态已变化,渠道变更未应用"); + } + feeItems.supersedeFreight(request.tenantId(), inbound.getId(), 0L, 0L, LocalDateTime.now()); + } + if (repository.applyOrderChannelChange(inbound, estimate.channelCode(), request.channelName()) != 1) { + throw conflict("入库单费用状态已变化,渠道变更未应用"); + } + + InboundPricingIdempotencyEntity token = new InboundPricingIdempotencyEntity(); + token.setId(IdWorker.getId()); + token.setTenantId(request.tenantId()); + token.setOperation("ORDER_CHANNEL_CHANGE"); + token.setOperatorId(0L); + token.setInboundId(inbound.getId()); + token.setIdempotencyKey(key); + token.setRequestHash(requestHash); + token.setStatus("PROCESSING"); + token.setCreatedAt(LocalDateTime.now()); + token.setUpdatedAt(LocalDateTime.now()); + repository.insertIdempotency(token); + if (repository.completeIdempotency(token, request.orderVersion(), LocalDateTime.now()) != 1) { + throw conflict("渠道变更幂等状态已变化"); + } + return new Response(inbound.getId(), request.orderId(), request.orderVersion(), estimate.channelCode(), + estimate.priceBookVersionId(), "UNPRICED", invalidatedFeeId != null, invalidatedFeeId); + } + + private InboundPricingEntity requiredInbound(Request request, boolean lock) { + InboundPricingEntity inbound = lock + ? repository.findInboundByOrderForUpdate(request.tenantId(), request.orderId()) + : repository.findInboundByOrder(request.tenantId(), request.orderId()); + if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "订单对应入库单不存在,无法校验费用门禁"); + if (!Objects.equals(inbound.getWarehouseId(), request.originWarehouseId()) + || !Objects.equals(inbound.getDestinationType(), request.destinationType()) + || !Objects.equals(inbound.getDestinationWarehouseId(), request.destinationWarehouseId()) + || !sameText(inbound.getDestinationCode(), request.destinationCode()) + || !Objects.equals(inbound.getPricingTaxMode(), request.pricingTaxMode())) { + throw conflict("订单计价维度与 Firstleg 入库快照不一致"); + } + return inbound; + } + + private ActivePriceBookEstimator.Estimate requireEligiblePrice(Request request, InboundPricingEntity inbound) { + priceBooks.requireAvailableChannel(request.tenantId(), request.originWarehouseId(), + request.channelCode(), request.destinationType(), false); + BigDecimal weight = positive(inbound.getActualNetWeightKg(), ELIGIBILITY_WEIGHT); + BigDecimal volume = positive(inbound.getActualVolumeCbm(), ELIGIBILITY_VOLUME); + return estimator.estimate(new EstimateRequest(request.originWarehouseId(), request.channelCode(), + request.destinationCode(), PricingDestinationType.valueOf(request.destinationType()), + request.destinationWarehouseId(), inbound.getDestinationName(), null, + PricingTaxMode.valueOf(request.pricingTaxMode()), weight, volume)); + } + + private void requireReversible(InboundPricingEntity inbound, InboundFeeEntity fee) { + String feeStatus = fee == null ? inbound.getFeeStatus() : fee.getFeeStatus(); + if ("CONFIRMED".equals(feeStatus) || "SENT".equals(feeStatus) + || feeItems.findPublishedFreight(inbound.getTenantId(), inbound.getId()) != null) { + throw conflict("费用已确认或已发布,渠道不可变更;请先按费用作废流程处理"); + } + if (!"UNPRICED".equals(inbound.getFeeStatus()) && !"CALCULATED".equals(inbound.getFeeStatus()) + && !"VOIDED".equals(inbound.getFeeStatus())) { + throw conflict("当前费用状态不可逆,渠道不可变更"); + } + } + + private static Response response(Request request, InboundPricingEntity inbound, Long versionId, + InboundFeeEntity fee, Long invalidatedFeeId) { + String status = fee != null && ("CALCULATED".equals(fee.getFeeStatus()) + || "CONFIRMED".equals(fee.getFeeStatus()) || "SENT".equals(fee.getFeeStatus())) + ? fee.getFeeStatus() : inbound.getFeeStatus(); + boolean calculated = "CALCULATED".equals(status); + return new Response(inbound.getId(), request.orderId(), request.orderVersion(), request.channelCode().trim().toUpperCase(), + versionId, status, calculated, invalidatedFeeId); + } + + private static BigDecimal positive(BigDecimal value, BigDecimal fallback) { + return value != null && value.signum() > 0 ? value : fallback; + } + + private static boolean sameText(String left, String right) { + String a = left == null ? null : left.trim().toUpperCase(); + String b = right == null ? null : right.trim().toUpperCase(); + return Objects.equals(a, b); + } + + private static String hash(Request request) { + try { + String value = request.tenantId() + ":" + request.orderId() + ":" + request.orderVersion() + ":" + + request.originWarehouseId() + ":" + request.channelCode().trim().toUpperCase() + ":" + + request.destinationType() + ":" + request.destinationWarehouseId() + ":" + + request.destinationCode() + ":" + request.pricingTaxMode(); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("JVM不支持SHA-256", exception); + } + } + + private static BizException conflict(String message) { + return new BizException(ResultCode.CONFLICT, message); + } +} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 676a2c63c..8d71aa160 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -70,6 +70,39 @@ paths: requireInboundCodePrefix: {type: boolean} responses: '200': {description: 成功时 data 返回 channelCode;requireInboundCodePrefix=true 时同时返回 inboundCodePrefix} + /internal/firstleg/v1/pricing/order-channel-changes/validate: + post: + summary: Order 提交渠道变更前校验 Firstleg 计价资格与费用可逆性 + description: >- + 按客户、订单、国内收货仓、目的仓类型/标识、报价包税方式和新渠道校验当前生效价格。 + 若已有 CONFIRMED/SENT 费用或 PUBLISHED 运费明细,返回冲突并明确要求先走费用作废流程; + CALCULATED 仅在响应中标记将失效,本接口不写数据。 + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/OrderChannelChangePricingRequest'} + responses: + '200': {description: 成功时 data 返回价格版本、费用状态及是否需失效未确认预估} + /internal/firstleg/v1/pricing/order-channel-changes/apply: + post: + summary: 按订单版本幂等应用渠道变更并失效未确认价格预估 + description: >- + 仅供 Order 在渠道变更成功后调用。tenantId + orderId + orderVersion 构成幂等边界; + 旧版本重放不会覆盖新版本。只将 CALCULATED 费用及草稿运费标记为 SUPERSEDED, + 入库单恢复 UNPRICED 并可重新计价;实际净重、实际体积和测量审核事实保持不变。 + 已确认、已发送或已发布费用一律拒绝,不能由本命令覆盖;费用正式作废为 VOIDED 后可重新计价。 + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/OrderChannelChangePricingRequest'} + responses: + '200': {description: 成功或同版本同请求幂等重放,data 返回应用版本和失效费用 ID} /internal/firstleg/v1/inbounds/{inboundId}/fees/{feeId}/source-snapshot: get: summary: Finance 按入库费用来源版本精确回查不可变快照 @@ -1009,6 +1042,33 @@ components: required: true schema: {type: string, pattern: '^[1-9][0-9]*$'} schemas: + OrderChannelChangePricingRequest: + type: object + additionalProperties: false + required: [tenantId, orderId, orderVersion, originWarehouseId, channelCode, channelName, destinationType, pricingTaxMode] + properties: + tenantId: {type: integer, format: int64, minimum: 1} + orderId: {type: integer, format: int64, minimum: 1} + orderVersion: {type: integer, format: int64, minimum: 0, description: Order 渠道变更完成后的聚合版本;用于幂等与拒绝旧事件} + originWarehouseId: {type: integer, format: int64, minimum: 1} + channelCode: {type: string, minLength: 1, maxLength: 64} + channelName: {type: string, minLength: 1, maxLength: 128} + destinationType: {type: string, enum: [FBA, OVERSEAS]} + destinationWarehouseId: {type: integer, format: int64, minimum: 1, nullable: true} + destinationCode: {type: string, maxLength: 64, nullable: true} + pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]} + OrderChannelChangePricingResponse: + type: object + required: [inboundId, orderId, orderVersion, channelCode, feeStatus, calculatedFeeWillBeInvalidated] + properties: + inboundId: {type: integer, format: int64} + orderId: {type: integer, format: int64} + orderVersion: {type: integer, format: int64} + channelCode: {type: string} + priceBookVersionId: {type: integer, format: int64, nullable: true} + feeStatus: {type: string, enum: [UNPRICED, CALCULATED, VOIDED]} + calculatedFeeWillBeInvalidated: {type: boolean} + invalidatedFeeId: {type: integer, format: int64, nullable: true} UnifiedMainStatus: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index 5a9a269a2..c9480da62 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -120,6 +120,10 @@ class PriceBookContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(firstlegOpenapi) .contains("/internal/firstleg/v1/pricing/channels/validate:") + .contains("/internal/firstleg/v1/pricing/order-channel-changes/validate:") + .contains("/internal/firstleg/v1/pricing/order-channel-changes/apply:") + .contains("tenantId + orderId + orderVersion 构成幂等边界") + .contains("实际净重、实际体积和测量审核事实保持不变") .contains("required: [tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix]") .contains("LEGACY_MANUAL 模式仅校验渠道可用性"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java new file mode 100644 index 000000000..25685cfe4 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java @@ -0,0 +1,164 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.OrderChannelChangePricing.Request; +import com.baiyue.firstleg.entity.InboundFeeEntity; +import com.baiyue.firstleg.entity.InboundPricingEntity; +import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import com.baiyue.firstleg.repository.FeeItemRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(OrderChannelChangePricingService.class) +class OrderChannelChangePricingServiceTest { + @MockBean private FbaPricingRepository repository; + @MockBean private FeeItemRepository feeItems; + @MockBean private MerchantPriceBookService priceBooks; + @MockBean private ActivePriceBookEstimator estimator; + @Resource private OrderChannelChangePricingService service; + + @Test + void rejectsConfirmedFeeBeforeChannelCanChange() { + InboundPricingEntity inbound = inbound("CONFIRMED"); + InboundFeeEntity fee = fee(91L, "CONFIRMED"); + when(repository.findInboundByOrder(10001L, 77L)).thenReturn(inbound); + when(repository.findLatestFee(10001L, 88L)).thenReturn(fee); + + assertThatThrownBy(() -> service.validate(request(4))) + .isInstanceOf(BizException.class) + .hasMessageContaining("费用已确认或已发布"); + verify(estimator, never()).estimate(any()); + } + + @Test + void validatesExactPriceAndMarksCalculatedEstimateForInvalidation() { + InboundPricingEntity inbound = inbound("CALCULATED"); + when(repository.findInboundByOrder(10001L, 77L)).thenReturn(inbound); + when(repository.findLatestFee(10001L, 88L)).thenReturn(fee(91L, "CALCULATED")); + when(estimator.estimate(any())).thenReturn(estimate()); + + var response = service.validate(request(4)); + + assertThat(response.priceBookVersionId()).isEqualTo(12L); + assertThat(response.calculatedFeeWillBeInvalidated()).isTrue(); + assertThat(response.feeStatus()).isEqualTo("CALCULATED"); + verify(priceBooks).requireAvailableChannel(10001L, 9L, "MATSON_NEW", "FBA", false); + } + + @Test + void appliesByOrderVersionAndOnlyInvalidatesCalculatedFacts() { + InboundPricingEntity inbound = inbound("CALCULATED"); + InboundFeeEntity fee = fee(91L, "CALCULATED"); + when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); + when(repository.findLatestFeeForUpdate(10001L, 88L)).thenReturn(fee); + when(estimator.estimate(any())).thenReturn(estimate()); + when(repository.supersedeCurrent(10001L, 91L, "CALCULATED")).thenReturn(1); + when(repository.applyOrderChannelChange(inbound, "MATSON_NEW", "新渠道")).thenReturn(1); + when(repository.completeIdempotency(any(), org.mockito.ArgumentMatchers.eq(4L), any())).thenReturn(1); + + var response = service.apply(request(4)); + + assertThat(response.feeStatus()).isEqualTo("UNPRICED"); + assertThat(response.invalidatedFeeId()).isEqualTo(91L); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("37"); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.102"); + verify(feeItems).supersedeFreight( + org.mockito.ArgumentMatchers.eq(10001L), org.mockito.ArgumentMatchers.eq(88L), + org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(0L), any()); + verify(repository).insertIdempotency(any()); + } + + @Test + void sameOrderVersionReplaysWithoutInvalidatingAgain() { + InboundPricingEntity inbound = inbound("UNPRICED"); + InboundPricingIdempotencyEntity token = token(4L, "COMPLETED"); + token.setRequestHash(requestHash(request(4))); + when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); + when(repository.findIdempotency(10001L, 0L, "ORDER_CHANNEL_CHANGE", "77:4")).thenReturn(token); + when(estimator.estimate(any())).thenReturn(estimate()); + + assertThat(service.apply(request(4)).orderVersion()).isEqualTo(4L); + + verify(repository, never()).supersedeCurrent(any(Long.class), any(Long.class), any()); + verify(repository, never()).applyOrderChannelChange(any(), any(), any()); + } + + @Test + void rejectsOlderOrderVersionAfterNewerVersionWasApplied() { + InboundPricingEntity inbound = inbound("UNPRICED"); + when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); + when(repository.findLatestCompletedOrderChannelChange(10001L, 88L)) + .thenReturn(token(5L, "COMPLETED")); + + assertThatThrownBy(() -> service.apply(request(4))) + .isInstanceOf(BizException.class) + .hasMessageContaining("订单渠道版本已推进"); + verify(estimator, never()).estimate(any()); + } + + private static Request request(long orderVersion) { + return new Request(10001L, 77L, orderVersion, 9L, "MATSON_NEW", "新渠道", + "FBA", 501L, "ONT8", "TAX_INCLUDED"); + } + + private static InboundPricingEntity inbound(String feeStatus) { + InboundPricingEntity value = new InboundPricingEntity(); + value.setId(88L); + value.setTenantId(10001L); + value.setOrderId(77L); + value.setWarehouseId(9L); + value.setDestinationType("FBA"); + value.setDestinationWarehouseId(501L); + value.setDestinationCode("ONT8"); + value.setDestinationName("ONT8"); + value.setPricingTaxMode("TAX_INCLUDED"); + value.setProcessStatus("WAIT_SPLIT"); + value.setFeeStatus(feeStatus); + value.setActualNetWeightKg(new BigDecimal("37")); + value.setActualVolumeCbm(new BigDecimal("0.102")); + return value; + } + + private static InboundFeeEntity fee(long id, String status) { + InboundFeeEntity value = new InboundFeeEntity(); + value.setId(id); + value.setInboundId(88L); + value.setFeeStatus(status); + return value; + } + + private static ActivePriceBookEstimator.Estimate estimate() { + return new ActivePriceBookEstimator.Estimate(12L, "MATSON_NEW", null, null, null, + BigDecimal.ONE, "KG", BigDecimal.ONE, BigDecimal.ONE, "CNY", BigDecimal.ONE, 6, "HALF_UP"); + } + + private static InboundPricingIdempotencyEntity token(long version, String status) { + InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); + value.setResultResourceId(version); + value.setStatus(status); + return value; + } + + private static String requestHash(Request request) { + try { + var method = OrderChannelChangePricingService.class.getDeclaredMethod("hash", Request.class); + method.setAccessible(true); + return (String) method.invoke(null, request); + } catch (ReflectiveOperationException exception) { + throw new AssertionError(exception); + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 9a5e0642c..004b32af5 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -96,15 +96,15 @@ public class PlatformTenantController { return ApiResponse.success(service.dimensionInitializationStatus(tenantId)); } - @GetMapping("/tenants/contact-phone-availability") - @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") - @Operation(summary = "校验客户联系电话唯一性") - public ApiResponse contactPhoneAvailability( - @RequestParam String contactPhone, - @RequestParam(required = false) Long excludeTenantId, + @GetMapping("/tenants/administrator-phone-availability") + @PreAuthorize("hasAuthority('platform:customer:create')") + @Operation(summary = "校验首个管理员登录手机号唯一性", + description = "仅用于创建客户前校验 Auth 登录手机号;企业联系电话本身不要求唯一。") + public ApiResponse administratorPhoneAvailability( + @RequestParam @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); - return ApiResponse.success(service.contactPhoneAndUsernameAvailable(contactPhone, excludeTenantId)); + return ApiResponse.success(service.administratorPhoneAvailable(phone)); } @GetMapping("/tenants/{tenantId:\\d+}/members") diff --git a/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java b/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java index e8cb9ce4b..77b6fd492 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java @@ -1,5 +1,6 @@ package com.baiyue.iam.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; @@ -18,6 +19,7 @@ public final class MemberDtos { @NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, @NotBlank @Size(max = 64) String displayName, + @Schema(description = "成员登录手机号;同步 Auth 且与公司联系电话独立") @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @Email @Size(max = 128) String email, @NotBlank String memberType, @@ -30,6 +32,7 @@ public final class MemberDtos { public record UpdateMemberRequest( @NotBlank @Size(max = 64) String displayName, + @Schema(description = "成员登录手机号;变更时同步 Auth、撤销该账号全部会话并审计") @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @Email @Size(max = 128) String email, @NotBlank String memberType, diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java index 08bbab0ad..0383c9105 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -14,7 +14,7 @@ public final class OnboardingDtos { @NotBlank String displayName, @NotBlank String companyName, @NotBlank String legalPerson, - @NotBlank String legalPhone, + @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String administratorPhone, String contactEmail, String countryCode, String address diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java index 1056a6519..a90f46028 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java @@ -1,5 +1,6 @@ package com.baiyue.iam.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -16,6 +17,7 @@ public final class PlatformUserManagementDtos { public record CreateUserRequest( @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username, @NotBlank @Size(max = 64) String displayName, + @Schema(description = "平台账号登录手机号;与企业联系电话独立") @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone, @Email @Size(max = 128) String email, @NotNull Long tenantId, @@ -32,6 +34,7 @@ public final class PlatformUserManagementDtos { public record UpdateUserRequest( @NotBlank @Size(max = 64) String displayName, + @Schema(description = "平台账号登录手机号;变更时同步 Auth、撤销该账号全部会话并审计") @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone, @Email @Size(max = 128) String email, @NotNull Long tenantId, diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 69a08ec1c..875d001a9 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -27,8 +27,8 @@ public final class TenantDtos { @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, @Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative, @Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName, - @Schema(description = "Contact phone and administrator username / 联系电话及管理员用户名", example = "13800138000") - @NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone, + @Schema(description = "Company contact phone; also initializes the first administrator login phone at creation, then maintained independently / 公司联系电话;创建时同时初始化首个管理员登录手机号,之后独立维护", example = "13800138000") + @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String contactPhone, @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail, @NotBlank @Size(max = 64) String servicePackage, @Size(max = 64) String contactWechat, @@ -48,7 +48,8 @@ public final class TenantDtos { @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, @Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative, @Schema(description = "Primary contact / 联系人", example = "李四") @NotBlank String contactName, - @Schema(description = "Contact phone / 联系电话", example = "13900139000") @NotBlank String contactPhone, + @Schema(description = "Company contact phone, never used to resolve login identity / 公司联系电话,不作为登录标识", example = "0755-12345678") + @NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone, @Schema(description = "Contact email / 联系邮箱", example = "service@example.com") @Email String contactEmail ) { } @@ -59,6 +60,7 @@ public final class TenantDtos { @NotBlank String legalName, @NotBlank @Size(max = 64) String legalRepresentative, @NotBlank String contactName, + @Schema(description = "Company contact phone, independent from every member login phone / 公司联系电话,与所有成员登录手机号独立") @NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone, @Email String contactEmail, @NotBlank @Size(max = 64) String servicePackage, @@ -120,14 +122,14 @@ public final class TenantDtos { public record ApprovalResponse( @Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId, @Schema(description = "Whether this approval created the administrator / 本次审核是否新建管理员") boolean administratorCreated, - @Schema(description = "Contact phone used as login username; null when an owner already exists / 使用联系电话作为登录账号,已有所有者时为空", nullable = true) String administratorUsername, + @Schema(description = "Generated first-administrator username; independent from company contact phone / 自动生成的首个管理员用户名,与公司联系电话独立", nullable = true) String administratorUsername, @Schema(description = "One-time temporary password; only returned when created / 一次性初始密码,仅新建时返回", format = "password", nullable = true) String temporaryPassword ) { } @Schema(name = "TenantCreationResponse", description = "Created enterprise draft and one-time administrator credentials / 新建企业草稿及一次性管理员凭据") public record CreationResponse( @Schema(description = "Created enterprise draft / 新建企业草稿") TenantResponse tenant, - @Schema(description = "Contact phone used as login username / 使用联系电话作为登录账号") String administratorUsername, + @Schema(description = "Generated administrator username, independent from company contact phone / 自动生成的管理员用户名,与公司联系电话独立") String administratorUsername, @Schema(description = "One-time temporary password / 一次性初始密码", format = "password") String temporaryPassword ) { } diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index 04e432e32..c2fd87bae 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -35,19 +35,6 @@ public class TenantRepository { .ne(IamTenant::getId, tenantId)) > 0; } - public boolean existsByContactPhone(String contactPhone) { - return tenantMapper.selectCount(new LambdaQueryWrapper() - .eq(IamTenant::getTenantType, "MERCHANT") - .eq(IamTenant::getContactPhone, contactPhone)) > 0; - } - - public boolean existsByContactPhoneForOtherTenant(String contactPhone, long tenantId) { - return tenantMapper.selectCount(new LambdaQueryWrapper() - .eq(IamTenant::getTenantType, "MERCHANT") - .eq(IamTenant::getContactPhone, contactPhone) - .ne(IamTenant::getId, tenantId)) > 0; - } - public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); } public List findMerchantsByIds(Collection tenantIds) { if (tenantIds == null || tenantIds.isEmpty()) return List.of(); diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index a393d5cac..a38f0b668 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -74,7 +74,7 @@ public class MerchantOnboardingService { tenant.setLegalName(request.companyName()); tenant.setLegalRepresentative(request.legalPerson()); tenant.setContactName(request.displayName()); - tenant.setContactPhone(request.legalPhone()); + tenant.setContactPhone(request.administratorPhone()); tenant.setContactEmail(request.contactEmail()); tenant.setDeleted(false); tenant.setVersion(0); @@ -86,7 +86,7 @@ public class MerchantOnboardingService { user.setAuthUserId(request.authUserId()); user.setUserType("ENTERPRISE"); user.setDisplayName(request.displayName()); - user.setPhone(request.legalPhone()); + user.setPhone(request.administratorPhone()); user.setEmail(request.contactEmail()); user.setStatus("ACTIVE"); userMapper.insert(user); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java index 83b298eed..811857965 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -88,7 +88,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni long userId = IdWorker.getId(); long membershipId = IdWorker.getId(); String password = temporaryPassword(); - String username = createAuthAccountWithUniqueUsername(authUserId, password, tenant, membershipId); + String username = createAuthAccount(authUserId, password, tenant, membershipId, + tenant.getContactPhone(), tenant.getContactName()); ProvisioningResult result = ProvisioningResult.created(authUserId, username, password); try { @@ -153,8 +154,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni AuthAccountClient.AccountSummary account = findAuthAccount(user.getAuthUserId()); if (account == null) { String password = temporaryPassword(); - String username = createAuthAccountWithUniqueUsername(user.getAuthUserId(), password, - tenant, owner.getId()); + String username = createAuthAccount(user.getAuthUserId(), password, tenant, owner.getId(), + user.getPhone(), user.getDisplayName()); publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId()); return ProvisioningResult.created(user.getAuthUserId(), username, password); } @@ -204,19 +205,13 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni if (result.created()) compensate(result.authUserId()); } - private String createAuthAccountWithUniqueUsername(long authUserId, String password, - IamTenant tenant, long membershipId) { - String username = username(tenant.getContactPhone()); - createAuthAccount(authUserId, username, password, tenant, membershipId); - return username; - } - - private void createAuthAccount(long authUserId, String username, String password, - IamTenant tenant, long membershipId) { + private String createAuthAccount(long authUserId, String password, IamTenant tenant, long membershipId, + String loginPhone, String displayName) { + String username = generatedUsername(authUserId); try { ApiResponse response = authClient.create(internalToken, - new AuthAccountClient.CreateAccountRequest(authUserId, username, tenant.getContactPhone(), password, - tenant.getContactName(), tenant.getId(), membershipId, "MERCHANT", "ACTIVE")); + new AuthAccountClient.CreateAccountRequest(authUserId, username, loginPhone, password, + displayName, tenant.getId(), membershipId, "MERCHANT", "ACTIVE")); if (response == null) throw unavailable(); if (!response.successful()) { ResultCode code = ResultCode.of(response.code()); @@ -224,9 +219,10 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni ? code.message() : response.message()); } if (response.data() == null) throw unavailable(); + return username; } catch (FeignException exception) { if (exception.status() == 409) { - throw new BizException(ResultCode.CONFLICT, "联系电话已被其他账号使用,不能作为企业管理员登录用户名"); + throw new BizException(ResultCode.CONFLICT, "管理员用户名或登录手机号已被其他账号使用"); } throw unavailable(); } catch (BizException exception) { @@ -267,8 +263,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni return new String(value); } - private String username(String contactPhone) { - return contactPhone == null ? "" : contactPhone.trim(); + private String generatedUsername(long authUserId) { + return "merchant_" + authUserId; } private char pick(char[] source) { return source[random.nextInt(source.length)]; } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index a8a7835d5..50221060e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -162,7 +162,7 @@ public class TenantService { @Transactional public CreationResponse register(RegisterRequest request, long operatorId) { String contactPhone = normalizeContactPhone(request.contactPhone()); - ensureContactPhoneAvailable(contactPhone, null); + ensureAdministratorPhoneAvailable(contactPhone); IamTenant tenant = new IamTenant(); tenant.setId(IdWorker.getId()); tenant.setTenantCode("TEN" + tenant.getId()); @@ -364,24 +364,17 @@ public class TenantService { return dimensionRuleInitialization.status(tenantId); } - public boolean contactPhoneAvailable(String contactPhone, Long tenantId) { - String normalized = normalizeContactPhone(contactPhone); - return tenantId == null - ? !repository.existsByContactPhone(normalized) - : !repository.existsByContactPhoneForOtherTenant(normalized, tenantId); - } - - public boolean contactPhoneAndUsernameAvailable(String contactPhone, Long tenantId) { - String normalized = normalizeContactPhone(contactPhone); - if (!contactPhoneAvailable(normalized, tenantId)) return false; + /** Platform creation uses the same initial value for company contact and owner login phone. */ + public boolean administratorPhoneAvailable(String phone) { + String normalized = normalizeContactPhone(phone); ApiResponse response; try { response = authClient.availability(internalToken, normalized); } catch (FeignException exception) { - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话"); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验管理员登录手机号"); } if (response == null || !response.successful() || response.data() == null) { - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话"); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验管理员登录手机号"); } return response.data(); } @@ -393,13 +386,6 @@ public class TenantService { IamTenant tenant = requireMerchant(tenantId); ensureNotArchived(tenant); String contactPhone = normalizeContactPhone(request.contactPhone()); - String existingContactPhone = normalizeContactPhone(tenant.getContactPhone()); - if (!contactPhone.equals(existingContactPhone)) { - if ("ACTIVE".equals(tenant.getStatus()) || "DISABLED".equals(tenant.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "客户已开户,联系电话作为登录用户名不能直接修改"); - } - ensureContactPhoneAvailable(contactPhone, tenantId); - } tenant.setTenantName(request.tenantName()); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); @@ -446,7 +432,6 @@ public class TenantService { if (repository.findPendingReviewByTenant(tenantId) != null) { throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中"); } - ensureContactPhoneAvailable(normalizeContactPhone(tenant.getContactPhone()), tenantId); if (tenant.getUnifiedSocialCreditCode() == null || !tenant.getUnifiedSocialCreditCode().matches("[0-9A-Z]{18}")) { throw new BizException(ResultCode.BAD_REQUEST, @@ -477,7 +462,7 @@ public class TenantService { tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); tenant.setContactName(request.contactName()); - tenant.setContactPhone(request.contactPhone()); + tenant.setContactPhone(normalizeContactPhone(request.contactPhone())); tenant.setContactEmail(request.contactEmail()); repository.saveTenant(tenant); return merchantVisibleResponse(tenant); @@ -497,9 +482,9 @@ public class TenantService { return contactPhone == null ? "" : contactPhone.trim(); } - private void ensureContactPhoneAvailable(String contactPhone, Long tenantId) { - if (!contactPhoneAvailable(contactPhone, tenantId)) { - throw new BizException(ResultCode.CONFLICT, "联系电话已被其他客户使用,不能作为登录用户名"); + private void ensureAdministratorPhoneAvailable(String phone) { + if (!administratorPhoneAvailable(phone)) { + throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用"); } } diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index bc250abe7..4b4dd4f59 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( legal_representative VARCHAR(64) NULL COMMENT '法定代表人', unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', contact_name VARCHAR(64) NULL COMMENT '企业联系人', - contact_phone VARCHAR(32) NULL COMMENT '联系电话', + contact_phone VARCHAR(32) NULL COMMENT '公司联系电话,非登录标识且不要求跨企业唯一', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', service_package VARCHAR(64) NOT NULL DEFAULT '基础版' COMMENT '客户服务套餐', contact_wechat VARCHAR(64) NULL COMMENT '联系微信', @@ -119,7 +119,7 @@ CREATE TABLE IF NOT EXISTS iam_user ( auth_user_id BIGINT NOT NULL COMMENT '认证服务账号逻辑ID', user_type VARCHAR(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型:ENTERPRISE企业用户、PLATFORM平台用户', display_name VARCHAR(64) NOT NULL COMMENT '用户展示名称', - phone VARCHAR(32) NULL COMMENT '手机号', + phone VARCHAR(32) NULL COMMENT '用户登录手机号,与企业联系电话独立', email VARCHAR(128) NULL COMMENT '邮箱', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '用户资料状态:ACTIVE正常、DISABLED停用', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index e2bef5864..9932c3f44 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -67,16 +67,16 @@ paths: '401': {$ref: '#/components/responses/Unauthorized'} '403': {$ref: '#/components/responses/Forbidden'} '409': {$ref: '#/components/responses/Conflict'} - /api/platform/v1/iam/tenants/contact-phone-availability: + /api/platform/v1/iam/tenants/administrator-phone-availability: get: tags: [Platform Tenant / 平台租户] - summary: 校验客户联系电话唯一性 + summary: 校验首个管理员登录手机号唯一性 + description: 仅在创建客户前校验 Auth 登录手机号;公司联系电话不要求跨企业唯一。 parameters: - - {name: contactPhone, in: query, required: true, schema: {type: string}} - - {name: excludeTenantId, in: query, required: false, schema: {type: integer, format: int64}} + - {name: phone, in: query, required: true, schema: {type: string, pattern: '^1[3-9]\d{9}$'}} responses: '200': - description: 返回联系电话是否可作为登录用户名 + description: 返回手机号是否可作为首个管理员登录手机号 content: application/json: schema: @@ -211,7 +211,7 @@ paths: post: tags: [Platform Tenant / 平台租户] summary: 提交企业客户审核 - description: 校验营业执照和法人身份证已上传后创建审核单,并把客户状态改为 `PENDING`;保存资料和上传文件均不会自动调用本接口。 + description: 校验营业执照和统一社会信用代码后创建审核单,并把客户状态改为 `PENDING`;法人身份证选填,保存资料和上传文件均不会自动调用本接口。 responses: '200': {$ref: '#/components/responses/TenantSuccess'} '400': {$ref: '#/components/responses/BadRequest'} @@ -683,7 +683,7 @@ paths: post: tags: [IAM Resources / IAM 资源] summary: Submit enterprise qualifications for review / 提交企业资质审核 - description: Uploading or replacing a qualification keeps the tenant in draft state. This endpoint creates the review after the business license and legal-person ID have been provided; the freight-forwarder certificate and bank account license are optional. / 上传或替换资质不会自动送审;营业执照和法人身份证齐全后由此接口创建审核单,货代资质证书和银行开户许可证选填。 + description: Uploading or replacing a qualification keeps the tenant in draft state. This endpoint requires only the business license and unified social credit code; legal-person ID, freight-forwarder certificate and bank account license are optional. / 上传或替换资质不会自动送审;本接口只要求营业执照和统一社会信用代码,法人身份证、货代资质证书和银行开户许可证选填。 responses: '200': {$ref: '#/components/responses/TenantSuccess'} '400': {$ref: '#/components/responses/BadRequest'} @@ -813,8 +813,8 @@ paths: /internal/iam/required-qualification-packages: post: tags: [IAM Resources / IAM 资源] - summary: 商户必填资质包登记 - description: 仅供 File Service 调用;在一个 IAM 事务中登记营业执照、统一社会信用代码和法人身份证。 + summary: 商户营业执照资质包登记 + description: 仅供 File Service 调用;在一个 IAM 事务中登记必填营业执照与统一社会信用代码,法人身份证可选。 security: [] parameters: - {$ref: '#/components/parameters/InternalServiceToken'} @@ -833,8 +833,8 @@ paths: /internal/iam/platform-required-qualification-packages: post: tags: [IAM Resources / IAM 资源] - summary: 平台代客户必填资质包登记 - description: 仅供 File Service 调用;与商户入口使用相同的三项事务保存规则。 + summary: 平台代客户登记营业执照资质包 + description: 仅供 File Service 调用;与商户入口使用相同规则,营业执照与统一社会信用代码必填,法人身份证可选。 security: [] parameters: - {$ref: '#/components/parameters/InternalServiceToken'} @@ -1318,7 +1318,7 @@ components: legalName: {type: string, example: 深圳虫虫出海科技有限公司} legalRepresentative: {type: string, maxLength: 64, example: 张三} contactName: {type: string, example: 李四} - contactPhone: {type: string, example: '13800138000'} + contactPhone: {type: string, pattern: '^1[3-9]\d{9}$', example: '13800138000', description: 创建时同时初始化公司联系电话与首个管理员登录手机号;创建后二者独立维护} contactEmail: {type: string, format: email, example: contact@example.com} contactWechat: {type: string, maxLength: 64} servicePackage: {type: string, maxLength: 64, example: 基础版} @@ -1338,7 +1338,7 @@ components: legalName: {type: string} legalRepresentative: {type: string, maxLength: 64} contactName: {type: string} - contactPhone: {type: string} + contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,不作为登录标识且不要求跨企业唯一} contactEmail: {type: string, format: email} UpdateTenantRequest: type: object @@ -1348,7 +1348,7 @@ components: legalName: {type: string} legalRepresentative: {type: string, maxLength: 64} contactName: {type: string} - contactPhone: {type: string} + contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,与管理员及成员登录手机号独立且不要求跨企业唯一} contactEmail: {type: string, format: email} contactWechat: {type: string, maxLength: 64} servicePackage: {type: string, maxLength: 64, example: 基础版} @@ -1398,7 +1398,7 @@ components: properties: username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'} displayName: {type: string, maxLength: 64} - phone: {type: string, pattern: '^1[3-9]\\d{9}$'} + phone: {type: string, pattern: '^1[3-9]\\d{9}$', description: 平台账号登录手机号;修改时同步 Auth、撤销该账号会话并审计} email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} @@ -1410,7 +1410,7 @@ components: required: [displayName, phone, tenantId, roleIds] properties: displayName: {type: string, maxLength: 64} - phone: {type: string, pattern: '^1[3-9]\\d{9}$'} + phone: {type: string, pattern: '^1[3-9]\\d{9}$', description: 平台账号登录手机号;与任何企业联系电话无关} email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} @@ -1424,7 +1424,7 @@ components: username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} displayName: {type: string, maxLength: 64} - phone: {type: string, pattern: '^1[3-9]\d{9}$'} + phone: {type: string, pattern: '^1[3-9]\d{9}$', description: 企业成员登录手机号;修改时同步 Auth、撤销该账号会话并审计} email: {type: string, format: email, maxLength: 128} memberNo: {type: string, maxLength: 64} roleIds: {type: array, items: {type: integer, format: int64}} @@ -1433,7 +1433,7 @@ components: required: [displayName, memberNo, roleIds] properties: displayName: {type: string, maxLength: 64} - phone: {type: string, pattern: '^1[3-9]\d{9}$'} + phone: {type: string, pattern: '^1[3-9]\d{9}$', description: 企业成员登录手机号;与公司联系电话无关} email: {type: string, format: email, maxLength: 128} memberNo: {type: string, maxLength: 64} roleIds: {type: array, items: {type: integer, format: int64}} diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index c594921df..dcc07c571 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -71,6 +71,10 @@ class IamOpenApiContractTest { .contains("UpdateMemberRequest:") .contains("membershipId: {type: integer, format: int64}") .contains("roleIds: {type: array, items: {type: integer, format: int64}}") + .contains("/api/platform/v1/iam/tenants/administrator-phone-availability:") + .doesNotContain("/api/platform/v1/iam/tenants/contact-phone-availability:") + .contains("公司联系电话,不作为登录标识且不要求跨企业唯一") + .contains("法人身份证、货代资质证书和银行开户许可证选填") .contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}"); Map openApi = new Yaml().load(document); diff --git a/iam/src/test/java/com/baiyue/iam/repository/IdentityContactDecouplingContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/IdentityContactDecouplingContractTest.java new file mode 100644 index 000000000..32a424e52 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/IdentityContactDecouplingContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class IdentityContactDecouplingContractTest { + + @Test + void baselineKeepsCompanyContactNonUniqueAndUserPhoneAsLoginIdentity() throws Exception { + String sql = new ClassPathResource("db/iam.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("公司联系电话,非登录标识且不要求跨企业唯一") + .contains("用户登录手机号,与企业联系电话独立") + .doesNotContain("UNIQUE KEY uk_iam_tenant_contact_phone") + .doesNotContain("UNIQUE KEY uk_tenant_contact_phone"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java index 8e76d8a1d..bb874cce6 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -88,7 +88,7 @@ class TenantAdministratorProvisioningServiceTest { var result = service.initialize(context()); assertTrue(result.created()); - assertEquals("13800138000", result.username()); + assertEquals("merchant_" + result.authUserId(), result.username()); assertNotNull(result.temporaryPassword()); assertEquals(16, result.temporaryPassword().length()); assertTrue(result.temporaryPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z0-9]+$")); @@ -97,6 +97,7 @@ class TenantAdministratorProvisioningServiceTest { ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); verify(auth).create(eq("internal-token"), account.capture()); assertEquals(result.username(), account.getValue().username()); + assertEquals("13800138000", account.getValue().phone()); assertEquals(result.temporaryPassword(), account.getValue().password()); assertEquals("MERCHANT", account.getValue().appCode()); assertEquals(tenant.getId(), account.getValue().tenantId()); @@ -243,7 +244,7 @@ class TenantAdministratorProvisioningServiceTest { } @Test - void usernameConflictStopsProvisioningInsteadOfChangingTheContactPhoneUsername() { + void loginPhoneConflictStopsProvisioningWithoutCouplingCompanyContactToUsername() { org.mockito.Mockito.doReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT, "登录账号已存在", null, null)).when(auth).create(anyString(), any()); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 6233f0642..e25b98c87 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -19,6 +19,7 @@ import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.client.AuthAccountClient; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.TestPropertySource; @@ -60,6 +61,12 @@ class TenantServiceTest { @SpyBean private TenantAdministratorProvisioningService provisioning; @Resource private TenantService service; + @BeforeEach + void allowUnusedAdministratorPhone() { + when(authClient.availability(eq("internal-token"), any())) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(true)); + } + @Test void pageMerchantsMapsTenantEntitiesToApiResponses() { @@ -172,25 +179,25 @@ class TenantServiceTest { } @Test - void activeCustomerCannotChangeTheContactPhoneUsedAsItsLoginUsername() { + void activeCustomerCanChangeCompanyContactPhoneWithoutChangingAnyLoginIdentity() { IamTenant tenant = merchant(10001L, "ACTIVE"); when(repository.findTenant(10001L)).thenReturn(tenant); - BizException exception = assertThrows(BizException.class, () -> service.updateMerchant(10001L, + var updated = service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null, - "基础版", null, null, 30, null, null, null, "CNY", null, null))); + "基础版", null, null, 30, null, null, null, "CNY", null, null)); - assertEquals(ResultCode.CONFLICT, exception.getResultCode()); - assertEquals("13800138000", tenant.getContactPhone()); - verify(repository, never()).saveTenant(tenant); + assertEquals("13900139000", updated.contactPhone()); + verify(repository).saveTenant(tenant); + verify(authClient, never()).phone(any(), any(), any()); } @Test - void contactPhoneAvailabilityAlsoChecksTheAuthUsernameFactSource() { + void administratorPhoneAvailabilityChecksOnlyTheAuthLoginFactSource() { when(authClient.availability("internal-token", "13800138000")) .thenReturn(com.baiyue.common.core.api.ApiResponse.success(false)); - assertEquals(false, service.contactPhoneAndUsernameAvailable("13800138000", null)); + assertEquals(false, service.administratorPhoneAvailable("13800138000")); verify(authClient).availability("internal-token", "13800138000"); } @@ -303,7 +310,7 @@ class TenantServiceTest { @Test void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() { doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created( - 81L, "13800138000", "Password1234")) + 81L, "merchant_81", "Password1234")) .when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); var created = service.register(new RegisterRequest( @@ -316,7 +323,7 @@ class TenantServiceTest { assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("Bob", created.tenant().legalRepresentative()); assertEquals("基础版", created.tenant().servicePackage()); - assertEquals("13800138000", created.administratorUsername()); + assertEquals("merchant_81", created.administratorUsername()); assertEquals("Password1234", created.temporaryPassword()); verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); @@ -342,7 +349,7 @@ class TenantServiceTest { @Test void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() { doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created( - 81L, "13800138000", "Password1234")) + 81L, "merchant_81", "Password1234")) .when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); var created = service.register(new RegisterRequest( diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index 9e1a1b462..140947486 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -36,12 +36,22 @@ public interface FirstlegInboundClient { ApiResponse updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody OrderDeclarationDtos.UpdatedEvent event); + @PostMapping("/order-channel-changes/validate") + ApiResponse validateOrderChannelChange( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody OrderChannelChangeValidationRequest request); + record InboundReferenceRequest(long tenantId, List orderIds) { } record InboundReference(Long orderId, Long inboundId, String inboundCode, String processStatus) { } + record OrderChannelChangeValidationRequest(long tenantId, long orderId, long orderVersion, + String channelCode) {} + record OrderChannelChangeValidationResponse(long orderId, long orderVersion, + String channelCode, String mainStatus) {} + record OrderApprovedInboundCommand( long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java index 3a14fbe13..e866b6d6b 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java @@ -13,7 +13,24 @@ public interface FirstlegPricingClient { ApiResponse validateChannel(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ChannelValidationRequest request); + @PostMapping("/order-channel-changes/validate") + ApiResponse validateOrderChannelChange( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody OrderChannelChangeRequest request); + + @PostMapping("/order-channel-changes/apply") + ApiResponse applyOrderChannelChange( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody OrderChannelChangeRequest request); + record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode, String destinationType, boolean requireInboundCodePrefix) {} record ChannelValidationResponse(String channelCode, String inboundCodePrefix) {} + record OrderChannelChangeRequest(long tenantId, long orderId, long orderVersion, + long originWarehouseId, String channelCode, String channelName, + String destinationType, Long destinationWarehouseId, + String destinationCode, String pricingTaxMode) {} + record OrderChannelChangeResponse(long inboundId, long orderId, long orderVersion, + String channelCode, Long priceBookVersionId, String feeStatus, + boolean calculatedFeeWillBeInvalidated, Long invalidatedFeeId) {} } diff --git a/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java b/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java index 5e048ae89..ef2cb4e37 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java @@ -36,7 +36,7 @@ public class OrderDeclarationUpdateService { @Resource private OrderRepository repository; @Resource private OrderPricingChannelGuard pricingChannelGuard; - @Resource private UnifiedOrderQueryService unifiedOrders; + @Resource private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard; @Resource private ObjectMapper json; @Transactional @@ -70,12 +70,16 @@ public class OrderDeclarationUpdateService { boolean channelChangedNow = !Objects.equals(channelCode, order.getChannelCode()) || !Objects.equals(channelName, order.getChannelName()); if (channelChangedNow) { - pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), channelCode, - order.getDestinationType()); + if (order.getStatus() == OrderStatus.APPROVED) { + long nextVersion = (long) request.expectedVersion() + 1; + fulfillmentChannelGuard.requireEligible(order, nextVersion, channelCode); + pricingChannelGuard.requireOrderChannelChangeEligible(order, nextVersion, channelCode, channelName); + } else { + pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), channelCode, + order.getDestinationType()); + } } - if (order.getStatus() == OrderStatus.APPROVED) requireReversibleFulfillment(tenantId, orderId); - List changes = new ArrayList<>(); change(changes, null, "customerRemark", order.getCustomerRemark(), request.customerRemark()); change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), request.expectedArrivalAt()); @@ -109,17 +113,6 @@ public class OrderDeclarationUpdateService { request.expectedVersion(), version, changedBefore, channelChanged, List.copyOf(changes)); } - private void requireReversibleFulfillment(long tenantId, long orderId) { - var row = unifiedOrders.platformDetail(tenantId, orderId).summary(); - boolean hasIrreversibleFact = row.measurementCompletedAt() != null - || row.preload() != null || row.loading() != null || row.coLoadGroup() != null - || row.feeReviewStatus() != null && !"UNRECORDED".equals(row.feeReviewStatus()); - if (hasIrreversibleFact || !"WAIT_MEASURE".equals(row.mainStatus())) { - throw new BizException(ResultCode.CONFLICT, - "订单已产生测量、分柜、装柜或计费事实,不能修改客户申报资料"); - } - } - private void collect(List changes, OrderCargoEntity before, OrderDeclarationDtos.Cargo after) { int item = after.itemNo(); diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuard.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuard.java new file mode 100644 index 000000000..19368ff3e --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuard.java @@ -0,0 +1,49 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.entity.OrderEntity; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** 审核后渠道变更的 Firstleg 同柜、分柜、预装及装柜可逆性门禁。 */ +@Service +public class OrderFulfillmentChannelChangeGuard { + @Resource private FirstlegInboundClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public void requireEligible(OrderEntity order, long nextOrderVersion, String channelCode) { + try { + ApiResponse response = + client.validateOrderChannelChange(internalToken, + new FirstlegInboundClient.OrderChannelChangeValidationRequest( + order.getTenantId(), order.getId(), nextOrderVersion, channelCode)); + if (response == null) throw unavailable(); + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + String message = response.message() == null || response.message().isBlank() + ? code.message() : response.message(); + throw new BizException(code, message); + } + var data = response.data(); + if (data == null || data.orderId() != order.getId() + || data.orderVersion() != nextOrderVersion || !channelCode.equals(data.channelCode())) { + throw unavailable(); + } + } catch (BizException exception) { + throw exception; + } catch (FeignException exception) { + throw unavailable(); + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + private BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程履约渠道校验服务暂时不可用"); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java index 64417f6a0..e933b22cc 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -4,6 +4,8 @@ import jakarta.annotation.Resource; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.client.FirstlegPricingClient; +import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OutboxEventEntity; import com.baiyue.order.dto.OrderDeclarationDtos; import com.baiyue.order.repository.OrderRepository; @@ -21,6 +23,7 @@ import java.time.LocalDateTime; public class OrderInboundOutboxPublisher { @Resource private OrderRepository repository; @Resource private FirstlegInboundClient client; + @Resource private FirstlegPricingClient pricingClient; @Resource private ObjectMapper json; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -91,6 +94,25 @@ public class OrderInboundOutboxPublisher { failPermanently(event, "declaration event ownership does not match payload"); return; } + if (containsChannelChange(command)) { + OrderEntity order = repository.findOwned(event.getTenantId(), event.getAggregateId()); + if (order == null) { + failPermanently(event, "order snapshot is unavailable for channel change"); + return; + } + var pricingCommand = new FirstlegPricingClient.OrderChannelChangeRequest( + order.getTenantId(), order.getId(), command.version(), order.getReceivingWarehouseId(), + command.channelCode(), command.channelName(), order.getDestinationType(), + order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getPricingTaxMode()); + ApiResponse pricingResponse = + pricingClient.applyOrderChannelChange(internalToken, pricingCommand); + if (pricingResponse == null || !pricingResponse.successful() || pricingResponse.data() == null + || pricingResponse.data().orderId() != order.getId() + || pricingResponse.data().orderVersion() != command.version() + || !command.channelCode().equals(pricingResponse.data().channelCode())) { + throw new IllegalStateException("头程渠道重新计价同步失败"); + } + } ApiResponse response = client.updateDeclaration(internalToken, command); if (response == null || !response.successful()) throw new IllegalStateException("头程申报同步失败"); repository.markOutboxPublished(event.getTenantId(), event.getId(), LocalDateTime.now()); @@ -101,6 +123,11 @@ public class OrderInboundOutboxPublisher { } } + private boolean containsChannelChange(OrderDeclarationDtos.UpdatedEvent command) { + return command.changes() != null && command.changes().stream() + .anyMatch(change -> "channelCode".equals(change.field()) || "channelName".equals(change.field())); + } + private boolean incomplete(FirstlegInboundClient.OrderApprovedInboundCommand command) { return command.orderNo() == null || command.orderNo().isBlank() || command.inboundCode() == null || command.inboundCode().isBlank() diff --git a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java index 8a901f1b3..d86d17b68 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.FirstlegPricingClient; +import com.baiyue.order.entity.OrderEntity; import feign.FeignException; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; @@ -24,6 +25,29 @@ public class OrderPricingChannelGuard { validate(tenantId, originWarehouseId, channelCode, destinationType, false); } + public void requireOrderChannelChangeEligible(OrderEntity order, long nextOrderVersion, + String channelCode, String channelName) { + try { + ApiResponse response = + client.validateOrderChannelChange(internalToken, new FirstlegPricingClient.OrderChannelChangeRequest( + order.getTenantId(), order.getId(), nextOrderVersion, order.getReceivingWarehouseId(), + channelCode, channelName, order.getDestinationType(), order.getDestinationWarehouseId(), + order.getAmazonWarehouseCode(), order.getPricingTaxMode())); + requireSuccessful(response); + if (response.data() == null || response.data().orderId() != order.getId() + || response.data().orderVersion() != nextOrderVersion + || !channelCode.equals(response.data().channelCode())) { + throw unavailable(); + } + } catch (BizException exception) { + throw exception; + } catch (FeignException exception) { + throw unavailable(); + } catch (RuntimeException exception) { + throw unavailable(); + } + } + private String validate(long tenantId, long originWarehouseId, String channelCode, String destinationType, boolean requireInboundCodePrefix) { try { @@ -33,12 +57,7 @@ public class OrderPricingChannelGuard { if (response == null) { throw unavailable(); } - if (!response.successful()) { - ResultCode code = ResultCode.of(response.code()); - String message = response.message() == null || response.message().isBlank() - ? code.message() : response.message(); - throw new BizException(code, message); - } + requireSuccessful(response); if (response.data() == null || requireInboundCodePrefix && (response.data().inboundCodePrefix() == null || response.data().inboundCodePrefix().isBlank())) { throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀"); @@ -53,6 +72,16 @@ public class OrderPricingChannelGuard { } } + private void requireSuccessful(ApiResponse response) { + if (response == null) throw unavailable(); + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + String message = response.message() == null || response.message().isBlank() + ? code.message() : response.message(); + throw new BizException(code, message); + } + } + private BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程价格渠道校验服务暂时不可用"); } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c51731040..a31375333 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -212,8 +212,14 @@ paths: description: >- 需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID; 不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/PDA/设备实测、测量审核或计费事实。 - 审核通过订单仅在尚无测量、同柜组、分柜、装柜或计费事实时受理,并通过 - ORDER_DECLARATION_UPDATED_V1 Outbox 快照同步 Firstleg;消费者按 eventId 幂等、version 顺序应用。 + 已有测量或计费事实不阻断客户申报快照更新,详情继续以最新申报与既有实测分别展示; + 只有渠道发生变化时才在写入前校验同柜/分柜/装柜可逆性和重新计价资格,任何校验失败均不写入。 + 正式门禁分别为 /internal/firstleg/v1/inbounds/order-channel-changes/validate 与 + /internal/firstleg/v1/pricing/order-channel-changes/validate,两个服务均失败关闭。 + 国内收货仓或中转仓如需变化必须重新下单。成功后通过 ORDER_DECLARATION_UPDATED_V1 Outbox + 同步 Firstleg;消费者按 eventId 幂等、version 顺序刷新申报快照,不覆盖实测、箱码或已确认费用。 + 本次 changes 含渠道字段时,同版本 Outbox 还必须调用 + /internal/firstleg/v1/pricing/order-channel-changes/apply,使未确认价格预估显式失效;普通申报更新不得调用。 parameters: - $ref: '#/components/parameters/UnifiedOrderId' - $ref: '#/components/parameters/UnifiedTenantId' @@ -223,7 +229,7 @@ paths: application/json: schema: {$ref: '#/components/schemas/OrderDeclarationUpdateRequest'} responses: - '200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突或存在不可逆履约事实时响应体业务码为 A0409} + '200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突、渠道与不可逆履约或已确认费用不兼容时响应体业务码为 A0409} '403': {description: 无权限或非平台身份} /api/merchant/v1/orders: get: diff --git a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java index 170c3529a..c4e9fca03 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java @@ -1,10 +1,13 @@ package com.baiyue.order.controller; import com.baiyue.order.dto.OrderDeclarationDtos; +import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.client.FirstlegPricingClient; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.yaml.snakeyaml.Yaml; import java.nio.charset.StandardCharsets; @@ -26,7 +29,9 @@ class OrderDeclarationUpdateContractTest { assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents()) .extracting(component -> component.getName()) .containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt", - "channelCode", "channelName", "cargoes"); + "channelCode", "channelName", "cargoes") + .doesNotContain("receivingWarehouseId", "receivingWarehouseName", + "destinationWarehouseId", "destinationWarehouseName", "inboundCode", "orderNo"); assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents()) .extracting(component -> component.getName()) .contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount", @@ -46,7 +51,26 @@ class OrderDeclarationUpdateContractTest { Map row = map(map(schemas.get("UnifiedOrderRow")).get("properties")); assertThat(row).containsKeys("expectedVolumeCbm", "actualVolumeCbm", "expectedArrivalAt", "channelChanged"); assertThat(map(row.get("actualVolumeCbm")).get("description").toString()).contains("仅 Firstleg 实测"); - assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409"); + assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409", + "/internal/firstleg/v1/inbounds/order-channel-changes/validate", + "/internal/firstleg/v1/pricing/order-channel-changes/validate", + "/internal/firstleg/v1/pricing/order-channel-changes/apply"); + } + + @Test + void approvedChannelChangeUsesFormalFulfillmentAndPricingValidationContracts() throws Exception { + var fulfillment = FirstlegInboundClient.class.getDeclaredMethod("validateOrderChannelChange", + String.class, FirstlegInboundClient.OrderChannelChangeValidationRequest.class); + var pricing = FirstlegPricingClient.class.getDeclaredMethod("validateOrderChannelChange", + String.class, FirstlegPricingClient.OrderChannelChangeRequest.class); + var applyPricing = FirstlegPricingClient.class.getDeclaredMethod("applyOrderChannelChange", + String.class, FirstlegPricingClient.OrderChannelChangeRequest.class); + assertThat(fulfillment.getAnnotation(PostMapping.class).value()) + .containsExactly("/order-channel-changes/validate"); + assertThat(pricing.getAnnotation(PostMapping.class).value()) + .containsExactly("/order-channel-changes/validate"); + assertThat(applyPricing.getAnnotation(PostMapping.class).value()) + .containsExactly("/order-channel-changes/apply"); } @Test diff --git a/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java index bfb7fc338..d688ee976 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java @@ -2,7 +2,6 @@ package com.baiyue.order.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.dto.OrderDeclarationDtos; -import com.baiyue.order.dto.UnifiedOrderResponse; import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderStatus; @@ -33,7 +32,7 @@ import static org.mockito.Mockito.when; class OrderDeclarationUpdateServiceTest { @MockBean private OrderRepository repository; @MockBean private OrderPricingChannelGuard pricingChannelGuard; - @MockBean private UnifiedOrderQueryService unifiedOrders; + @MockBean private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard; @MockBean private ObjectMapper json; @Resource private OrderDeclarationUpdateService service; @@ -76,45 +75,59 @@ class OrderDeclarationUpdateServiceTest { } @Test - void approvedOrderWithMeasurementFactIsAtomicallyRejected() { - OrderEntity order = order(OrderStatus.APPROVED, 3); - when(repository.findOwned(100L, 200L)).thenReturn(order); - when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo())); - UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class); - UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class); - when(detail.summary()).thenReturn(row); - when(row.mainStatus()).thenReturn("WAIT_MEASURE"); - when(row.measurementCompletedAt()).thenReturn(LocalDateTime.of(2026, 9, 1, 12, 0)); - when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail); - - assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员", - request(3, "OLD", "旧渠道", "备注"))) - .isInstanceOf(BizException.class).hasMessageContaining("测量、分柜、装柜或计费事实"); - verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), - any(), any(), any(), any(), anyBoolean()); - } - - @Test - void reversibleApprovedUpdateProducesVersionedOutboxSnapshot() throws Exception { + void approvedOrderCanRefreshCustomerDeclarationWithoutTouchingMeasurementFacts() throws Exception { OrderEntity order = order(OrderStatus.APPROVED, 3); when(repository.findOwned(100L, 200L)).thenReturn(order); when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo())); when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(), any(), any(), anyBoolean())).thenReturn(1); when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1); - UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class); - UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class); - when(detail.summary()).thenReturn(row); - when(row.mainStatus()).thenReturn("WAIT_MEASURE"); - when(row.feeReviewStatus()).thenReturn("UNRECORDED"); - when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail); when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}"); - service.update(100L, 200L, 700L, "平台操作员", request(3, "OLD", "旧渠道", "新备注")); + var response = service.update(100L, 200L, 700L, "平台操作员", + request(3, "OLD", "旧渠道", "测量后更新申报")); + assertThat(response.version()).isEqualTo(4); + verify(pricingChannelGuard, never()).requireAvailable(anyLong(), anyLong(), anyString(), anyString()); verify(repository).insertOutbox(any()); } + @Test + void approvedChannelChangePassesBothGatesBeforeVersionedOutboxSnapshot() throws Exception { + OrderEntity order = order(OrderStatus.APPROVED, 3); + when(repository.findOwned(100L, 200L)).thenReturn(order); + when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo())); + when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(), + any(), any(), anyBoolean())).thenReturn(1); + when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1); + when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}"); + + service.update(100L, 200L, 700L, "平台操作员", request(3, "NEW", "新渠道", "新备注")); + + verify(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW"); + verify(pricingChannelGuard).requireOrderChannelChangeEligible(order, 4L, "NEW", "新渠道"); + verify(repository).insertOutbox(any()); + } + + @Test + void approvedChannelChangeRunsBothRemoteGatesBeforeAnyWrite() { + OrderEntity order = order(OrderStatus.APPROVED, 3); + when(repository.findOwned(100L, 200L)).thenReturn(order); + when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo())); + org.mockito.Mockito.doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "装柜已开始,渠道不可变更")) + .when(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW"); + + assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员", + request(3, "NEW", "新渠道", "备注"))) + .isInstanceOf(BizException.class).hasMessage("装柜已开始,渠道不可变更"); + + verify(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW"); + verify(pricingChannelGuard, never()).requireOrderChannelChangeEligible(any(), anyLong(), anyString(), anyString()); + verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), + any(), any(), any(), any(), anyBoolean()); + } + private OrderEntity order(OrderStatus status, int version) { OrderEntity order = new OrderEntity(); order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200"); diff --git a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuardTest.java new file mode 100644 index 000000000..8907ba5bd --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuardTest.java @@ -0,0 +1,52 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.entity.OrderEntity; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(OrderFulfillmentChannelChangeGuard.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=token") +class OrderFulfillmentChannelChangeGuardTest { + @MockBean private FirstlegInboundClient client; + @Resource private OrderFulfillmentChannelChangeGuard guard; + + @Test + void validatesNextOrderVersionAgainstFirstlegFulfillmentFacts() { + OrderEntity order = order(); + var request = new FirstlegInboundClient.OrderChannelChangeValidationRequest(11L, 21L, 5L, "MATSON_TRUCK"); + when(client.validateOrderChannelChange("token", request)).thenReturn(ApiResponse.success( + new FirstlegInboundClient.OrderChannelChangeValidationResponse(21L, 5L, "MATSON_TRUCK", "WAIT_SPLIT"))); + + guard.requireEligible(order, 5L, "MATSON_TRUCK"); + + verify(client).validateOrderChannelChange("token", request); + } + + @Test + void keepsIrreversibleLoadingBusinessErrorAndFailsBeforeOrderWrite() { + OrderEntity order = order(); + var request = new FirstlegInboundClient.OrderChannelChangeValidationRequest(11L, 21L, 5L, "MATSON_TRUCK"); + when(client.validateOrderChannelChange("token", request)).thenReturn( + ApiResponse.failure(ResultCode.CONFLICT, "装柜已开始,渠道不可变更", null, null)); + + assertThatThrownBy(() -> guard.requireEligible(order, 5L, "MATSON_TRUCK")) + .isInstanceOf(BizException.class).hasMessage("装柜已开始,渠道不可变更"); + } + + private OrderEntity order() { + OrderEntity order = new OrderEntity(); + order.setId(21L); order.setTenantId(11L); + return order; + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java index ca93490e5..33285db1b 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java @@ -2,6 +2,7 @@ package com.baiyue.order.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.client.FirstlegPricingClient; import com.baiyue.order.entity.OutboxEventEntity; import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -130,7 +131,7 @@ class OrderInboundOutboxPublisherTest { } @Test - void declarationUpdateEventIsDeliveredByVersionedContract() { + void ordinaryDeclarationUpdateDoesNotInvalidatePricing() { String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-1\",\"tenantId\":11," + "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\"," + "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\"," @@ -140,9 +141,53 @@ class OrderInboundOutboxPublisherTest { fixture.publisher.publishApprovedOrders(); - verify(fixture.client).updateDeclaration(eq("internal-token"), any()); + verify(fixture.client).updateDeclaration(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(command -> + command.version() == 4 && command.channelChanged() + && "SEA".equals(command.channelCode()) && "RK-21".equals(command.inboundCode()))); verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class)); verify(fixture.client, never()).create(any(), any()); + verify(fixture.pricingClient, never()).applyOrderChannelChange(any(), any()); + } + + @Test + void channelChangeAppliesPricingInvalidationBySameOrderVersionBeforeDelivery() { + String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-2\",\"tenantId\":11," + + "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\"," + + "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\"," + + "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[]," + + "\"changes\":[{\"field\":\"channelCode\",\"beforeValue\":\"OLD\",\"afterValue\":\"SEA\"}]}"; + Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload)); + when(fixture.pricingClient.applyOrderChannelChange(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new FirstlegPricingClient.OrderChannelChangeResponse( + 51L, 21L, 4L, "SEA", 61L, "UNPRICED", true, 71L))); + when(fixture.client.updateDeclaration(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + + fixture.publisher.publishApprovedOrders(); + + verify(fixture.pricingClient).applyOrderChannelChange(eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(command -> command.orderVersion() == 4L + && command.orderId() == 21L && "SEA".equals(command.channelCode()))); + verify(fixture.client).updateDeclaration(eq("internal-token"), any()); + verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class)); + } + + @Test + void channelPricingApplyFailureKeepsOutboxPendingAndDoesNotAdvanceFulfillmentSnapshot() { + String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-3\",\"tenantId\":11," + + "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\"," + + "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\"," + + "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[]," + + "\"changes\":[{\"field\":\"channelName\",\"beforeValue\":\"旧渠道\",\"afterValue\":\"海运\"}]}"; + Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload)); + when(fixture.pricingClient.applyOrderChannelChange(eq("internal-token"), any())).thenReturn( + ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT, + "费用已确认或已发布,渠道不可变更", null, null)); + + fixture.publisher.publishApprovedOrders(); + + verify(fixture.repository).markOutboxRetry(eq(11L), eq(41L), any(LocalDateTime.class)); + verify(fixture.client, never()).updateDeclaration(any(), any()); + verify(fixture.repository, never()).markOutboxPublished(anyLong(), anyLong(), any()); } private Fixture fixture(OutboxEventEntity event) { @@ -152,6 +197,7 @@ class OrderInboundOutboxPublisherTest { private Fixture fixture(OutboxEventEntity event, String authoritativeTaxMode) { OrderRepository repository = mock(OrderRepository.class); FirstlegInboundClient client = mock(FirstlegInboundClient.class); + FirstlegPricingClient pricingClient = mock(FirstlegPricingClient.class); when(repository.pendingInboundEvents(50)).thenReturn(List.of(event)); if (event.getTenantId() != null && event.getAggregateId() != null) { var order = new com.baiyue.order.entity.OrderEntity(); @@ -163,6 +209,8 @@ class OrderInboundOutboxPublisherTest { order.setReceivingWarehouseId(31L); order.setReceivingWarehouseName("测试仓"); order.setDestinationType("OVERSEAS"); + order.setDestinationWarehouseId(41L); + order.setAmazonWarehouseCode("ONT8"); order.setTransportMode("SEA"); order.setTaxMode(authoritativeTaxMode); order.setPricingTaxMode("TAX_EXCLUDED"); @@ -177,9 +225,10 @@ class OrderInboundOutboxPublisherTest { OrderInboundOutboxPublisher publisher = new OrderInboundOutboxPublisher(); ReflectionTestUtils.setField(publisher, "repository", repository); ReflectionTestUtils.setField(publisher, "client", client); + ReflectionTestUtils.setField(publisher, "pricingClient", pricingClient); ReflectionTestUtils.setField(publisher, "json", new ObjectMapper()); ReflectionTestUtils.setField(publisher, "internalToken", "internal-token"); - return new Fixture(publisher, repository, client); + return new Fixture(publisher, repository, client, pricingClient); } private OutboxEventEntity event(String eventType, Long tenantId, Long aggregateId, String payload) { @@ -207,6 +256,6 @@ class OrderInboundOutboxPublisherTest { } private record Fixture(OrderInboundOutboxPublisher publisher, OrderRepository repository, - FirstlegInboundClient client) { + FirstlegInboundClient client, FirstlegPricingClient pricingClient) { } } diff --git a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java index d26d9b3e6..b17369765 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.FirstlegPricingClient; +import com.baiyue.order.entity.OrderEntity; import feign.FeignException; import feign.Request; import feign.Response; @@ -65,6 +66,24 @@ class OrderPricingChannelGuardTest { .isEqualTo(ResultCode.CONFLICT); } + @Test + void approvedChannelChangeUsesPricingAndConfirmedFeeGate() { + OrderEntity order = new OrderEntity(); + order.setId(21L); order.setTenantId(11L); order.setReceivingWarehouseId(31L); + order.setDestinationType("FBA"); order.setDestinationWarehouseId(41L); + order.setAmazonWarehouseCode("ONT8"); order.setPricingTaxMode("TAX_INCLUDED"); + var request = new FirstlegPricingClient.OrderChannelChangeRequest( + 11L, 21L, 5L, 31L, "MATSON_TRUCK", "美森海卡", + "FBA", 41L, "ONT8", "TAX_INCLUDED"); + when(client.validateOrderChannelChange("token", request)).thenReturn(ApiResponse.success( + new FirstlegPricingClient.OrderChannelChangeResponse( + 51L, 21L, 5L, "MATSON_TRUCK", 61L, "CALCULATED", true, null))); + + guard.requireOrderChannelChangeEligible(order, 5L, "MATSON_TRUCK", "美森海卡"); + + verify(client).validateOrderChannelChange("token", request); + } + @Test void nullResponseMapsToServiceUnavailable() { var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true);