This commit is contained in:
2026-09-02 10:34:02 +08:00
44 changed files with 1003 additions and 172 deletions
@@ -22,7 +22,7 @@ public interface IamOnboardingClient {
@PathVariable("authUserId") Long authUserId); @PathVariable("authUserId") Long authUserId);
record OnboardingRequest(Long authUserId, String displayName, String companyName, record OnboardingRequest(Long authUserId, String displayName, String companyName,
String legalPerson, String legalPhone, String contactEmail, String legalPerson, String administratorPhone, String contactEmail,
String countryCode, String address) { } String countryCode, String address) { }
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus, record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
@@ -77,7 +77,7 @@ public class AuthController {
@SecurityRequirements @SecurityRequirements
@PostMapping("/register") @PostMapping("/register")
@Operation(summary = "Merchant registration / 客户注册", @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<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) { public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
return ApiResponse.success(registrationService.register(request)); return ApiResponse.success(registrationService.register(request));
} }
@@ -45,7 +45,7 @@ public final class AccountManagementDtos {
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@NotBlank String currentPassword) { } @NotBlank String currentPassword) { }
public record UpdateAccountPhoneRequest( 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 UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
public record UpdateAccountStatusRequest(@NotBlank String status) { } public record UpdateAccountStatusRequest(@NotBlank String status) { }
public record RevokeAccountSessionsRequest( public record RevokeAccountSessionsRequest(
@@ -13,7 +13,8 @@ public record MerchantRegisterRequest(
@NotBlank @Size(max = 64) String displayName, @NotBlank @Size(max = 64) String displayName,
@NotBlank @Size(max = 128) String companyName, @NotBlank @Size(max = 128) String companyName,
@NotBlank @Size(max = 64) String legalPerson, @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, @Email @Size(max = 128) String contactEmail,
@Size(min = 2, max = 2) String countryCode, @Size(min = 2, max = 2) String countryCode,
@Size(max = 255) String address @Size(max = 255) String address
@@ -151,9 +151,9 @@ public class AccountManagementService {
description = "同步受管账号手机号", captureAfter = false) description = "同步受管账号手机号", captureAfter = false)
public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) { public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) {
AuthAccount account = required(accountId); 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 (Objects.equals(account.getPhone(), phone)) return;
if (repository.existsByPhoneForOtherAccount(phone, accountId)) { if (phone != null && repository.existsByPhoneForOtherAccount(phone, accountId)) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在"); throw new BizException(ResultCode.CONFLICT, "手机号已存在");
} }
account.setPhone(phone); account.setPhone(phone);
@@ -38,7 +38,7 @@ public class MerchantRegistrationService {
if (accountRepository.existsByUsername(request.username())) { if (accountRepository.existsByUsername(request.username())) {
throw new BizException(ResultCode.CONFLICT, "用户名已存在"); throw new BizException(ResultCode.CONFLICT, "用户名已存在");
} }
if (accountRepository.existsByPhone(request.legalPhone())) { if (accountRepository.existsByPhone(request.phone())) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在"); throw new BizException(ResultCode.CONFLICT, "手机号已存在");
} }
long accountId = IdWorker.getId(); long accountId = IdWorker.getId();
@@ -49,7 +49,7 @@ public class MerchantRegistrationService {
try { try {
var response = onboardingClient.register(internalToken, var response = onboardingClient.register(internalToken,
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(), new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
request.companyName(), request.legalPerson(), request.legalPhone(), request.companyName(), request.legalPerson(), request.phone(),
request.contactEmail(), request.countryCode(), request.address())); request.contactEmail(), request.countryCode(), request.address()));
if (response == null) { if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
@@ -84,7 +84,7 @@ public class MerchantRegistrationService {
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
account.setId(accountId); account.setId(accountId);
account.setUsername(request.username()); account.setUsername(request.username());
account.setPhone(request.legalPhone()); account.setPhone(request.phone());
account.setPasswordHash(passwordHash); account.setPasswordHash(passwordHash);
account.setDisplayName(request.displayName()); account.setDisplayName(request.displayName());
account.setAccountStatus("ACTIVE"); account.setAccountStatus("ACTIVE");
@@ -7,11 +7,21 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class LoginIdentifierContractTest { class LoginIdentifierContractTest {
@Test
void registrationExposesAnAccountPhoneInsteadOfALegalPersonPhoneAlias() {
var componentNames = Arrays.stream(MerchantRegisterRequest.class.getRecordComponents())
.map(component -> component.getName())
.toList();
assertThat(componentNames).contains("phone").doesNotContain("legalPhone");
}
@Test @Test
void mainlandMobileRuleRejectsPlaceholderNumbersAcrossJavaAndSql() throws Exception { void mainlandMobileRuleRejectsPlaceholderNumbersAcrossJavaAndSql() throws Exception {
try (var factory = Validation.buildDefaultValidatorFactory()) { try (var factory = Validation.buildDefaultValidatorFactory()) {
@@ -4,6 +4,7 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest; import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest; import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest; 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.UpdateAccountStatusRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest; import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
@@ -173,6 +174,17 @@ class AccountManagementServiceTest {
verify(sessions).revokeAll(10L); 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() { @Test void duplicatePhoneDoesNotChangeLoginIdentifiers() {
account.setUsername("old.user"); account.setUsername("old.user");
account.setPhone("13800138000"); account.setPhone("13800138000");
@@ -54,6 +54,8 @@ class MerchantRegistrationServiceTest {
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId()); assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode()); assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus()); assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
assertEquals("13800138000", storedAccount.getValue().getPhone());
assertEquals("13800138000", onboardingRequest.getValue().administratorPhone());
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash()); assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep()); assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
assertEquals("PENDING", result.dimensionInitializationStatus()); assertEquals("PENDING", result.dimensionInitializationStatus());
@@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.PriceBookChannelValidation; import com.baiyue.firstleg.dto.PriceBookChannelValidation;
import com.baiyue.firstleg.dto.OrderChannelChangePricing;
import com.baiyue.firstleg.service.MerchantPriceBookService; import com.baiyue.firstleg.service.MerchantPriceBookService;
import com.baiyue.firstleg.service.OrderChannelChangePricingService;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -22,6 +24,7 @@ import java.security.MessageDigest;
@RequestMapping("/internal/firstleg/v1/pricing") @RequestMapping("/internal/firstleg/v1/pricing")
public class InternalPriceBookController { public class InternalPriceBookController {
@Resource private MerchantPriceBookService service; @Resource private MerchantPriceBookService service;
@Resource private OrderChannelChangePricingService channelChanges;
@Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expectedToken; private byte[] expectedToken;
@@ -30,6 +33,22 @@ public class InternalPriceBookController {
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
} }
@PostMapping("/order-channel-changes/validate")
public ApiResponse<OrderChannelChangePricing.Response> 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<OrderChannelChangePricing.Response> 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") @PostMapping("/channels/validate")
public ApiResponse<PriceBookChannelValidation.Response> validateChannel( public ApiResponse<PriceBookChannelValidation.Response> validateChannel(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -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
) {}
}
@@ -17,6 +17,7 @@ public class InboundPricingEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long tenantId; private Long tenantId;
private Long orderId;
private String inboundCode; private String inboundCode;
private String processStatus; private String processStatus;
private String measureReviewStatus; private String measureReviewStatus;
@@ -27,6 +28,7 @@ public class InboundPricingEntity extends BaseEntity {
private String warehouseName; private String warehouseName;
private String businessType; private String businessType;
private String channelCode; private String channelCode;
private String channelName;
private Long carrierId; private Long carrierId;
private String carrierName; private String carrierName;
private String taxMode; private String taxMode;
@@ -119,6 +119,19 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getId, inboundId)); .eq(InboundPricingEntity::getId, inboundId));
} }
public InboundPricingEntity findInboundByOrderForUpdate(long tenantId, long orderId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getOrderId, orderId)
.last("FOR UPDATE"));
}
public InboundPricingEntity findInboundByOrder(long tenantId, long orderId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getOrderId, orderId));
}
public List<CustomerChargeRuleEntity> matchRules(InboundPricingEntity inbound, LocalDateTime at) { public List<CustomerChargeRuleEntity> matchRules(InboundPricingEntity inbound, LocalDateTime at) {
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>() return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId()) .eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
@@ -178,6 +191,31 @@ public class FbaPricingRepository {
.orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1")); .orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1"));
} }
public InboundFeeEntity findLatestFeeForUpdate(long tenantId, long inboundId) {
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
.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<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.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) { public InboundFeeItemEntity findBaseItem(long tenantId, long feeId) {
return items.selectOne(new LambdaQueryWrapper<InboundFeeItemEntity>() return items.selectOne(new LambdaQueryWrapper<InboundFeeItemEntity>()
.eq(InboundFeeItemEntity::getTenantId, tenantId) .eq(InboundFeeItemEntity::getTenantId, tenantId)
@@ -302,6 +340,16 @@ public class FbaPricingRepository {
public void insertIdempotency(InboundPricingIdempotencyEntity value) { idempotencies.insert(value); } public void insertIdempotency(InboundPricingIdempotencyEntity value) { idempotencies.insert(value); }
public InboundPricingIdempotencyEntity findLatestCompletedOrderChannelChange(long tenantId, long inboundId) {
return idempotencies.selectOne(new LambdaQueryWrapper<InboundPricingIdempotencyEntity>()
.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) { public int completeIdempotency(InboundPricingIdempotencyEntity value, long resourceId, LocalDateTime now) {
return idempotencies.update(null, new LambdaUpdateWrapper<InboundPricingIdempotencyEntity>() return idempotencies.update(null, new LambdaUpdateWrapper<InboundPricingIdempotencyEntity>()
.eq(InboundPricingIdempotencyEntity::getTenantId, value.getTenantId()) .eq(InboundPricingIdempotencyEntity::getTenantId, value.getTenantId())
@@ -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);
}
}
@@ -70,6 +70,39 @@ paths:
requireInboundCodePrefix: {type: boolean} requireInboundCodePrefix: {type: boolean}
responses: responses:
'200': {description: 成功时 data 返回 channelCode;requireInboundCodePrefix=true 时同时返回 inboundCodePrefix} '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: /internal/firstleg/v1/inbounds/{inboundId}/fees/{feeId}/source-snapshot:
get: get:
summary: Finance 按入库费用来源版本精确回查不可变快照 summary: Finance 按入库费用来源版本精确回查不可变快照
@@ -1009,6 +1042,33 @@ components:
required: true required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'} schema: {type: string, pattern: '^[1-9][0-9]*$'}
schemas: 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: UnifiedMainStatus:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
@@ -120,6 +120,10 @@ class PriceBookContractTest {
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
assertThat(firstlegOpenapi) assertThat(firstlegOpenapi)
.contains("/internal/firstleg/v1/pricing/channels/validate:") .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("required: [tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix]")
.contains("LEGACY_MANUAL 模式仅校验渠道可用性"); .contains("LEGACY_MANUAL 模式仅校验渠道可用性");
} }
@@ -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);
}
}
}
@@ -96,15 +96,15 @@ public class PlatformTenantController {
return ApiResponse.success(service.dimensionInitializationStatus(tenantId)); return ApiResponse.success(service.dimensionInitializationStatus(tenantId));
} }
@GetMapping("/tenants/contact-phone-availability") @GetMapping("/tenants/administrator-phone-availability")
@PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") @PreAuthorize("hasAuthority('platform:customer:create')")
@Operation(summary = "校验客户联系电话唯一性") @Operation(summary = "校验首个管理员登录手机号唯一性",
public ApiResponse<Boolean> contactPhoneAvailability( description = "仅用于创建客户前校验 Auth 登录手机号;企业联系电话本身不要求唯一。")
@RequestParam String contactPhone, public ApiResponse<Boolean> administratorPhoneAvailability(
@RequestParam(required = false) Long excludeTenantId, @RequestParam @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM"); TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.contactPhoneAndUsernameAvailable(contactPhone, excludeTenantId)); return ApiResponse.success(service.administratorPhoneAvailable(phone));
} }
@GetMapping("/tenants/{tenantId:\\d+}/members") @GetMapping("/tenants/{tenantId:\\d+}/members")
@@ -1,5 +1,6 @@
package com.baiyue.iam.dto; package com.baiyue.iam.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
@@ -18,6 +19,7 @@ public final class MemberDtos {
@NotBlank @Size(min = 8, max = 64) @NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank @Size(max = 64) String displayName, @NotBlank @Size(max = 64) String displayName,
@Schema(description = "成员登录手机号;同步 Auth 且与公司联系电话独立")
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@Email @Size(max = 128) String email, @Email @Size(max = 128) String email,
@NotBlank String memberType, @NotBlank String memberType,
@@ -30,6 +32,7 @@ public final class MemberDtos {
public record UpdateMemberRequest( public record UpdateMemberRequest(
@NotBlank @Size(max = 64) String displayName, @NotBlank @Size(max = 64) String displayName,
@Schema(description = "成员登录手机号;变更时同步 Auth、撤销该账号全部会话并审计")
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@Email @Size(max = 128) String email, @Email @Size(max = 128) String email,
@NotBlank String memberType, @NotBlank String memberType,
@@ -14,7 +14,7 @@ public final class OnboardingDtos {
@NotBlank String displayName, @NotBlank String displayName,
@NotBlank String companyName, @NotBlank String companyName,
@NotBlank String legalPerson, @NotBlank String legalPerson,
@NotBlank String legalPhone, @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String administratorPhone,
String contactEmail, String contactEmail,
String countryCode, String countryCode,
String address String address
@@ -1,5 +1,6 @@
package com.baiyue.iam.dto; package com.baiyue.iam.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
@@ -16,6 +17,7 @@ public final class PlatformUserManagementDtos {
public record CreateUserRequest( public record CreateUserRequest(
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username, @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Size(max = 64) String displayName, @NotBlank @Size(max = 64) String displayName,
@Schema(description = "平台账号登录手机号;与企业联系电话独立")
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone, @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone,
@Email @Size(max = 128) String email, @Email @Size(max = 128) String email,
@NotNull Long tenantId, @NotNull Long tenantId,
@@ -32,6 +34,7 @@ public final class PlatformUserManagementDtos {
public record UpdateUserRequest( public record UpdateUserRequest(
@NotBlank @Size(max = 64) String displayName, @NotBlank @Size(max = 64) String displayName,
@Schema(description = "平台账号登录手机号;变更时同步 Auth、撤销该账号全部会话并审计")
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone, @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone,
@Email @Size(max = 128) String email, @Email @Size(max = 128) String email,
@NotNull Long tenantId, @NotNull Long tenantId,
@@ -27,8 +27,8 @@ public final class TenantDtos {
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative, @Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName, @Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
@Schema(description = "Contact phone and administrator username / 联系电话及管理员用户名", example = "13800138000") @Schema(description = "Company contact phone; also initializes the first administrator login phone at creation, then maintained independently / 公司联系电话;创建时同时初始化首个管理员登录手机号,之后独立维护", example = "13800138000")
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone, @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String contactPhone,
@Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail, @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail,
@NotBlank @Size(max = 64) String servicePackage, @NotBlank @Size(max = 64) String servicePackage,
@Size(max = 64) String contactWechat, @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 entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative, @Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
@Schema(description = "Primary contact / 联系人", example = "李四") @NotBlank String contactName, @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 @Schema(description = "Contact email / 联系邮箱", example = "service@example.com") @Email String contactEmail
) { } ) { }
@@ -59,6 +60,7 @@ public final class TenantDtos {
@NotBlank String legalName, @NotBlank String legalName,
@NotBlank @Size(max = 64) String legalRepresentative, @NotBlank @Size(max = 64) String legalRepresentative,
@NotBlank String contactName, @NotBlank String contactName,
@Schema(description = "Company contact phone, independent from every member login phone / 公司联系电话,与所有成员登录手机号独立")
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone, @NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
@Email String contactEmail, @Email String contactEmail,
@NotBlank @Size(max = 64) String servicePackage, @NotBlank @Size(max = 64) String servicePackage,
@@ -120,14 +122,14 @@ public final class TenantDtos {
public record ApprovalResponse( public record ApprovalResponse(
@Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId, @Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId,
@Schema(description = "Whether this approval created the administrator / 本次审核是否新建管理员") boolean administratorCreated, @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(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 / 新建企业草稿及一次性管理员凭据") @Schema(name = "TenantCreationResponse", description = "Created enterprise draft and one-time administrator credentials / 新建企业草稿及一次性管理员凭据")
public record CreationResponse( public record CreationResponse(
@Schema(description = "Created enterprise draft / 新建企业草稿") TenantResponse tenant, @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 @Schema(description = "One-time temporary password / 一次性初始密码", format = "password") String temporaryPassword
) { } ) { }
@@ -35,19 +35,6 @@ public class TenantRepository {
.ne(IamTenant::getId, tenantId)) > 0; .ne(IamTenant::getId, tenantId)) > 0;
} }
public boolean existsByContactPhone(String contactPhone) {
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
.eq(IamTenant::getContactPhone, contactPhone)) > 0;
}
public boolean existsByContactPhoneForOtherTenant(String contactPhone, long tenantId) {
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
.eq(IamTenant::getContactPhone, contactPhone)
.ne(IamTenant::getId, tenantId)) > 0;
}
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); } public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
public List<IamTenant> findMerchantsByIds(Collection<Long> tenantIds) { public List<IamTenant> findMerchantsByIds(Collection<Long> tenantIds) {
if (tenantIds == null || tenantIds.isEmpty()) return List.of(); if (tenantIds == null || tenantIds.isEmpty()) return List.of();
@@ -74,7 +74,7 @@ public class MerchantOnboardingService {
tenant.setLegalName(request.companyName()); tenant.setLegalName(request.companyName());
tenant.setLegalRepresentative(request.legalPerson()); tenant.setLegalRepresentative(request.legalPerson());
tenant.setContactName(request.displayName()); tenant.setContactName(request.displayName());
tenant.setContactPhone(request.legalPhone()); tenant.setContactPhone(request.administratorPhone());
tenant.setContactEmail(request.contactEmail()); tenant.setContactEmail(request.contactEmail());
tenant.setDeleted(false); tenant.setDeleted(false);
tenant.setVersion(0); tenant.setVersion(0);
@@ -86,7 +86,7 @@ public class MerchantOnboardingService {
user.setAuthUserId(request.authUserId()); user.setAuthUserId(request.authUserId());
user.setUserType("ENTERPRISE"); user.setUserType("ENTERPRISE");
user.setDisplayName(request.displayName()); user.setDisplayName(request.displayName());
user.setPhone(request.legalPhone()); user.setPhone(request.administratorPhone());
user.setEmail(request.contactEmail()); user.setEmail(request.contactEmail());
user.setStatus("ACTIVE"); user.setStatus("ACTIVE");
userMapper.insert(user); userMapper.insert(user);
@@ -88,7 +88,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
long userId = IdWorker.getId(); long userId = IdWorker.getId();
long membershipId = IdWorker.getId(); long membershipId = IdWorker.getId();
String password = temporaryPassword(); 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); ProvisioningResult result = ProvisioningResult.created(authUserId, username, password);
try { try {
@@ -153,8 +154,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
AuthAccountClient.AccountSummary account = findAuthAccount(user.getAuthUserId()); AuthAccountClient.AccountSummary account = findAuthAccount(user.getAuthUserId());
if (account == null) { if (account == null) {
String password = temporaryPassword(); String password = temporaryPassword();
String username = createAuthAccountWithUniqueUsername(user.getAuthUserId(), password, String username = createAuthAccount(user.getAuthUserId(), password, tenant, owner.getId(),
tenant, owner.getId()); user.getPhone(), user.getDisplayName());
publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId()); publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId());
return ProvisioningResult.created(user.getAuthUserId(), username, password); return ProvisioningResult.created(user.getAuthUserId(), username, password);
} }
@@ -204,19 +205,13 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
if (result.created()) compensate(result.authUserId()); if (result.created()) compensate(result.authUserId());
} }
private String createAuthAccountWithUniqueUsername(long authUserId, String password, private String createAuthAccount(long authUserId, String password, IamTenant tenant, long membershipId,
IamTenant tenant, long membershipId) { String loginPhone, String displayName) {
String username = username(tenant.getContactPhone()); String username = generatedUsername(authUserId);
createAuthAccount(authUserId, username, password, tenant, membershipId);
return username;
}
private void createAuthAccount(long authUserId, String username, String password,
IamTenant tenant, long membershipId) {
try { try {
ApiResponse<AuthAccountClient.AccountSummary> response = authClient.create(internalToken, ApiResponse<AuthAccountClient.AccountSummary> response = authClient.create(internalToken,
new AuthAccountClient.CreateAccountRequest(authUserId, username, tenant.getContactPhone(), password, new AuthAccountClient.CreateAccountRequest(authUserId, username, loginPhone, password,
tenant.getContactName(), tenant.getId(), membershipId, "MERCHANT", "ACTIVE")); displayName, tenant.getId(), membershipId, "MERCHANT", "ACTIVE"));
if (response == null) throw unavailable(); if (response == null) throw unavailable();
if (!response.successful()) { if (!response.successful()) {
ResultCode code = ResultCode.of(response.code()); ResultCode code = ResultCode.of(response.code());
@@ -224,9 +219,10 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
? code.message() : response.message()); ? code.message() : response.message());
} }
if (response.data() == null) throw unavailable(); if (response.data() == null) throw unavailable();
return username;
} catch (FeignException exception) { } catch (FeignException exception) {
if (exception.status() == 409) { if (exception.status() == 409) {
throw new BizException(ResultCode.CONFLICT, "联系电话已被其他账号使用,不能作为企业管理员登录用户名"); throw new BizException(ResultCode.CONFLICT, "管理员用户名或登录手机号已被其他账号使用");
} }
throw unavailable(); throw unavailable();
} catch (BizException exception) { } catch (BizException exception) {
@@ -267,8 +263,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
return new String(value); return new String(value);
} }
private String username(String contactPhone) { private String generatedUsername(long authUserId) {
return contactPhone == null ? "" : contactPhone.trim(); return "merchant_" + authUserId;
} }
private char pick(char[] source) { return source[random.nextInt(source.length)]; } private char pick(char[] source) { return source[random.nextInt(source.length)]; }
@@ -162,7 +162,7 @@ public class TenantService {
@Transactional @Transactional
public CreationResponse register(RegisterRequest request, long operatorId) { public CreationResponse register(RegisterRequest request, long operatorId) {
String contactPhone = normalizeContactPhone(request.contactPhone()); String contactPhone = normalizeContactPhone(request.contactPhone());
ensureContactPhoneAvailable(contactPhone, null); ensureAdministratorPhoneAvailable(contactPhone);
IamTenant tenant = new IamTenant(); IamTenant tenant = new IamTenant();
tenant.setId(IdWorker.getId()); tenant.setId(IdWorker.getId());
tenant.setTenantCode("TEN" + tenant.getId()); tenant.setTenantCode("TEN" + tenant.getId());
@@ -364,24 +364,17 @@ public class TenantService {
return dimensionRuleInitialization.status(tenantId); return dimensionRuleInitialization.status(tenantId);
} }
public boolean contactPhoneAvailable(String contactPhone, Long tenantId) { /** Platform creation uses the same initial value for company contact and owner login phone. */
String normalized = normalizeContactPhone(contactPhone); public boolean administratorPhoneAvailable(String phone) {
return tenantId == null String normalized = normalizeContactPhone(phone);
? !repository.existsByContactPhone(normalized)
: !repository.existsByContactPhoneForOtherTenant(normalized, tenantId);
}
public boolean contactPhoneAndUsernameAvailable(String contactPhone, Long tenantId) {
String normalized = normalizeContactPhone(contactPhone);
if (!contactPhoneAvailable(normalized, tenantId)) return false;
ApiResponse<Boolean> response; ApiResponse<Boolean> response;
try { try {
response = authClient.availability(internalToken, normalized); response = authClient.availability(internalToken, normalized);
} catch (FeignException exception) { } catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验管理员登录手机号");
} }
if (response == null || !response.successful() || response.data() == null) { if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验管理员登录手机号");
} }
return response.data(); return response.data();
} }
@@ -393,13 +386,6 @@ public class TenantService {
IamTenant tenant = requireMerchant(tenantId); IamTenant tenant = requireMerchant(tenantId);
ensureNotArchived(tenant); ensureNotArchived(tenant);
String contactPhone = normalizeContactPhone(request.contactPhone()); 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.setTenantName(request.tenantName());
tenant.setLegalName(request.legalName()); tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative()); tenant.setLegalRepresentative(request.legalRepresentative());
@@ -446,7 +432,6 @@ public class TenantService {
if (repository.findPendingReviewByTenant(tenantId) != null) { if (repository.findPendingReviewByTenant(tenantId) != null) {
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中"); throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中");
} }
ensureContactPhoneAvailable(normalizeContactPhone(tenant.getContactPhone()), tenantId);
if (tenant.getUnifiedSocialCreditCode() == null if (tenant.getUnifiedSocialCreditCode() == null
|| !tenant.getUnifiedSocialCreditCode().matches("[0-9A-Z]{18}")) { || !tenant.getUnifiedSocialCreditCode().matches("[0-9A-Z]{18}")) {
throw new BizException(ResultCode.BAD_REQUEST, throw new BizException(ResultCode.BAD_REQUEST,
@@ -477,7 +462,7 @@ public class TenantService {
tenant.setLegalName(request.legalName()); tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative()); tenant.setLegalRepresentative(request.legalRepresentative());
tenant.setContactName(request.contactName()); tenant.setContactName(request.contactName());
tenant.setContactPhone(request.contactPhone()); tenant.setContactPhone(normalizeContactPhone(request.contactPhone()));
tenant.setContactEmail(request.contactEmail()); tenant.setContactEmail(request.contactEmail());
repository.saveTenant(tenant); repository.saveTenant(tenant);
return merchantVisibleResponse(tenant); return merchantVisibleResponse(tenant);
@@ -497,9 +482,9 @@ public class TenantService {
return contactPhone == null ? "" : contactPhone.trim(); return contactPhone == null ? "" : contactPhone.trim();
} }
private void ensureContactPhoneAvailable(String contactPhone, Long tenantId) { private void ensureAdministratorPhoneAvailable(String phone) {
if (!contactPhoneAvailable(contactPhone, tenantId)) { if (!administratorPhoneAvailable(phone)) {
throw new BizException(ResultCode.CONFLICT, "联系电话已被其他客户使用,不能作为登录用户名"); throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用");
} }
} }
+2 -2
View File
@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
legal_representative VARCHAR(64) NULL COMMENT '法定代表人', legal_representative VARCHAR(64) NULL COMMENT '法定代表人',
unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码',
contact_name VARCHAR(64) 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 '联系邮箱', contact_email VARCHAR(128) NULL COMMENT '联系邮箱',
service_package VARCHAR(64) NOT NULL DEFAULT '基础版' COMMENT '客户服务套餐', service_package VARCHAR(64) NOT NULL DEFAULT '基础版' COMMENT '客户服务套餐',
contact_wechat VARCHAR(64) NULL 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', auth_user_id BIGINT NOT NULL COMMENT '认证服务账号逻辑ID',
user_type VARCHAR(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型:ENTERPRISE企业用户、PLATFORM平台用户', user_type VARCHAR(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型:ENTERPRISE企业用户、PLATFORM平台用户',
display_name VARCHAR(64) NOT NULL COMMENT '用户展示名称', display_name VARCHAR(64) NOT NULL COMMENT '用户展示名称',
phone VARCHAR(32) NULL COMMENT '手机号', phone VARCHAR(32) NULL COMMENT '用户登录手机号,与企业联系电话独立',
email VARCHAR(128) NULL COMMENT '邮箱', email VARCHAR(128) NULL COMMENT '邮箱',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '用户资料状态:ACTIVE正常、DISABLED停用', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '用户资料状态:ACTIVE正常、DISABLED停用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+18 -18
View File
@@ -67,16 +67,16 @@ paths:
'401': {$ref: '#/components/responses/Unauthorized'} '401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'} '403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'} '409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/tenants/contact-phone-availability: /api/platform/v1/iam/tenants/administrator-phone-availability:
get: get:
tags: [Platform Tenant / 平台租户] tags: [Platform Tenant / 平台租户]
summary: 校验客户联系电话唯一性 summary: 校验首个管理员登录手机号唯一性
description: 仅在创建客户前校验 Auth 登录手机号;公司联系电话不要求跨企业唯一。
parameters: parameters:
- {name: contactPhone, in: query, required: true, schema: {type: string}} - {name: phone, in: query, required: true, schema: {type: string, pattern: '^1[3-9]\d{9}$'}}
- {name: excludeTenantId, in: query, required: false, schema: {type: integer, format: int64}}
responses: responses:
'200': '200':
description: 返回联系电话是否可作为登录用户名 description: 返回手机号是否可作为首个管理员登录手机号
content: content:
application/json: application/json:
schema: schema:
@@ -211,7 +211,7 @@ paths:
post: post:
tags: [Platform Tenant / 平台租户] tags: [Platform Tenant / 平台租户]
summary: 提交企业客户审核 summary: 提交企业客户审核
description: 校验营业执照和法人身份证已上传后创建审核单,并把客户状态改为 `PENDING`;保存资料和上传文件均不会自动调用本接口。 description: 校验营业执照和统一社会信用代码后创建审核单,并把客户状态改为 `PENDING`;法人身份证选填,保存资料和上传文件均不会自动调用本接口。
responses: responses:
'200': {$ref: '#/components/responses/TenantSuccess'} '200': {$ref: '#/components/responses/TenantSuccess'}
'400': {$ref: '#/components/responses/BadRequest'} '400': {$ref: '#/components/responses/BadRequest'}
@@ -683,7 +683,7 @@ paths:
post: post:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: Submit enterprise qualifications for review / 提交企业资质审核 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: responses:
'200': {$ref: '#/components/responses/TenantSuccess'} '200': {$ref: '#/components/responses/TenantSuccess'}
'400': {$ref: '#/components/responses/BadRequest'} '400': {$ref: '#/components/responses/BadRequest'}
@@ -813,8 +813,8 @@ paths:
/internal/iam/required-qualification-packages: /internal/iam/required-qualification-packages:
post: post:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: 商户必填资质包登记 summary: 商户营业执照资质包登记
description: 仅供 File Service 调用;在一个 IAM 事务中登记营业执照、统一社会信用代码和法人身份证。 description: 仅供 File Service 调用;在一个 IAM 事务中登记必填营业执照与统一社会信用代码,法人身份证可选。
security: [] security: []
parameters: parameters:
- {$ref: '#/components/parameters/InternalServiceToken'} - {$ref: '#/components/parameters/InternalServiceToken'}
@@ -833,8 +833,8 @@ paths:
/internal/iam/platform-required-qualification-packages: /internal/iam/platform-required-qualification-packages:
post: post:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: 平台代客户必填资质包登记 summary: 平台代客户登记营业执照资质包
description: 仅供 File Service 调用;与商户入口使用相同的三项事务保存规则。 description: 仅供 File Service 调用;与商户入口使用相同规则,营业执照与统一社会信用代码必填,法人身份证可选。
security: [] security: []
parameters: parameters:
- {$ref: '#/components/parameters/InternalServiceToken'} - {$ref: '#/components/parameters/InternalServiceToken'}
@@ -1318,7 +1318,7 @@ components:
legalName: {type: string, example: 深圳虫虫出海科技有限公司} legalName: {type: string, example: 深圳虫虫出海科技有限公司}
legalRepresentative: {type: string, maxLength: 64, example: 张三} legalRepresentative: {type: string, maxLength: 64, example: 张三}
contactName: {type: string, 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} contactEmail: {type: string, format: email, example: contact@example.com}
contactWechat: {type: string, maxLength: 64} contactWechat: {type: string, maxLength: 64}
servicePackage: {type: string, maxLength: 64, example: 基础版} servicePackage: {type: string, maxLength: 64, example: 基础版}
@@ -1338,7 +1338,7 @@ components:
legalName: {type: string} legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64} legalRepresentative: {type: string, maxLength: 64}
contactName: {type: string} contactName: {type: string}
contactPhone: {type: string} contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,不作为登录标识且不要求跨企业唯一}
contactEmail: {type: string, format: email} contactEmail: {type: string, format: email}
UpdateTenantRequest: UpdateTenantRequest:
type: object type: object
@@ -1348,7 +1348,7 @@ components:
legalName: {type: string} legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64} legalRepresentative: {type: string, maxLength: 64}
contactName: {type: string} contactName: {type: string}
contactPhone: {type: string} contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,与管理员及成员登录手机号独立且不要求跨企业唯一}
contactEmail: {type: string, format: email} contactEmail: {type: string, format: email}
contactWechat: {type: string, maxLength: 64} contactWechat: {type: string, maxLength: 64}
servicePackage: {type: string, maxLength: 64, example: 基础版} servicePackage: {type: string, maxLength: 64, example: 基础版}
@@ -1398,7 +1398,7 @@ components:
properties: properties:
username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'} username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
displayName: {type: string, maxLength: 64} 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} email: {type: string, format: email, maxLength: 128}
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0}
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
@@ -1410,7 +1410,7 @@ components:
required: [displayName, phone, tenantId, roleIds] required: [displayName, phone, tenantId, roleIds]
properties: properties:
displayName: {type: string, maxLength: 64} 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} email: {type: string, format: email, maxLength: 128}
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0}
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} 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}$'} username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
displayName: {type: string, maxLength: 64} 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} email: {type: string, format: email, maxLength: 128}
memberNo: {type: string, maxLength: 64} memberNo: {type: string, maxLength: 64}
roleIds: {type: array, items: {type: integer, format: int64}} roleIds: {type: array, items: {type: integer, format: int64}}
@@ -1433,7 +1433,7 @@ components:
required: [displayName, memberNo, roleIds] required: [displayName, memberNo, roleIds]
properties: properties:
displayName: {type: string, maxLength: 64} 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} email: {type: string, format: email, maxLength: 128}
memberNo: {type: string, maxLength: 64} memberNo: {type: string, maxLength: 64}
roleIds: {type: array, items: {type: integer, format: int64}} roleIds: {type: array, items: {type: integer, format: int64}}
@@ -71,6 +71,10 @@ class IamOpenApiContractTest {
.contains("UpdateMemberRequest:") .contains("UpdateMemberRequest:")
.contains("membershipId: {type: integer, format: int64}") .contains("membershipId: {type: integer, format: int64}")
.contains("roleIds: {type: array, items: {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'}"); .contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}");
Map<String, Object> openApi = new Yaml().load(document); Map<String, Object> openApi = new Yaml().load(document);
@@ -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");
}
}
@@ -88,7 +88,7 @@ class TenantAdministratorProvisioningServiceTest {
var result = service.initialize(context()); var result = service.initialize(context());
assertTrue(result.created()); assertTrue(result.created());
assertEquals("13800138000", result.username()); assertEquals("merchant_" + result.authUserId(), result.username());
assertNotNull(result.temporaryPassword()); assertNotNull(result.temporaryPassword());
assertEquals(16, result.temporaryPassword().length()); assertEquals(16, result.temporaryPassword().length());
assertTrue(result.temporaryPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z0-9]+$")); assertTrue(result.temporaryPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z0-9]+$"));
@@ -97,6 +97,7 @@ class TenantAdministratorProvisioningServiceTest {
ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class);
verify(auth).create(eq("internal-token"), account.capture()); verify(auth).create(eq("internal-token"), account.capture());
assertEquals(result.username(), account.getValue().username()); assertEquals(result.username(), account.getValue().username());
assertEquals("13800138000", account.getValue().phone());
assertEquals(result.temporaryPassword(), account.getValue().password()); assertEquals(result.temporaryPassword(), account.getValue().password());
assertEquals("MERCHANT", account.getValue().appCode()); assertEquals("MERCHANT", account.getValue().appCode());
assertEquals(tenant.getId(), account.getValue().tenantId()); assertEquals(tenant.getId(), account.getValue().tenantId());
@@ -243,7 +244,7 @@ class TenantAdministratorProvisioningServiceTest {
} }
@Test @Test
void usernameConflictStopsProvisioningInsteadOfChangingTheContactPhoneUsername() { void loginPhoneConflictStopsProvisioningWithoutCouplingCompanyContactToUsername() {
org.mockito.Mockito.doReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT, org.mockito.Mockito.doReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT,
"登录账号已存在", null, null)).when(auth).create(anyString(), any()); "登录账号已存在", null, null)).when(auth).create(anyString(), any());
@@ -19,6 +19,7 @@ import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.client.AuthAccountClient;
import org.junit.jupiter.api.Test; 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.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
@@ -60,6 +61,12 @@ class TenantServiceTest {
@SpyBean private TenantAdministratorProvisioningService provisioning; @SpyBean private TenantAdministratorProvisioningService provisioning;
@Resource private TenantService service; @Resource private TenantService service;
@BeforeEach
void allowUnusedAdministratorPhone() {
when(authClient.availability(eq("internal-token"), any()))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(true));
}
@Test @Test
void pageMerchantsMapsTenantEntitiesToApiResponses() { void pageMerchantsMapsTenantEntitiesToApiResponses() {
@@ -172,25 +179,25 @@ class TenantServiceTest {
} }
@Test @Test
void activeCustomerCannotChangeTheContactPhoneUsedAsItsLoginUsername() { void activeCustomerCanChangeCompanyContactPhoneWithoutChangingAnyLoginIdentity() {
IamTenant tenant = merchant(10001L, "ACTIVE"); IamTenant tenant = merchant(10001L, "ACTIVE");
when(repository.findTenant(10001L)).thenReturn(tenant); 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, 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("13900139000", updated.contactPhone());
assertEquals("13800138000", tenant.getContactPhone()); verify(repository).saveTenant(tenant);
verify(repository, never()).saveTenant(tenant); verify(authClient, never()).phone(any(), any(), any());
} }
@Test @Test
void contactPhoneAvailabilityAlsoChecksTheAuthUsernameFactSource() { void administratorPhoneAvailabilityChecksOnlyTheAuthLoginFactSource() {
when(authClient.availability("internal-token", "13800138000")) when(authClient.availability("internal-token", "13800138000"))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(false)); .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"); verify(authClient).availability("internal-token", "13800138000");
} }
@@ -303,7 +310,7 @@ class TenantServiceTest {
@Test @Test
void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() { void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() {
doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created( doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created(
81L, "13800138000", "Password1234")) 81L, "merchant_81", "Password1234"))
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); .when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
var created = service.register(new RegisterRequest( var created = service.register(new RegisterRequest(
@@ -316,7 +323,7 @@ class TenantServiceTest {
assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("PENDING", created.tenant().dimensionInitializationStatus());
assertEquals("Bob", created.tenant().legalRepresentative()); assertEquals("Bob", created.tenant().legalRepresentative());
assertEquals("基础版", created.tenant().servicePackage()); assertEquals("基础版", created.tenant().servicePackage());
assertEquals("13800138000", created.administratorUsername()); assertEquals("merchant_81", created.administratorUsername());
assertEquals("Password1234", created.temporaryPassword()); assertEquals("Password1234", created.temporaryPassword());
verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy())
&& Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); && Long.valueOf(9001L).equals(tenant.getUpdatedBy())));
@@ -342,7 +349,7 @@ class TenantServiceTest {
@Test @Test
void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() { void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() {
doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created( doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created(
81L, "13800138000", "Password1234")) 81L, "merchant_81", "Password1234"))
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); .when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
var created = service.register(new RegisterRequest( var created = service.register(new RegisterRequest(
@@ -36,12 +36,22 @@ public interface FirstlegInboundClient {
ApiResponse<Void> updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<Void> updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderDeclarationDtos.UpdatedEvent event); @RequestBody OrderDeclarationDtos.UpdatedEvent event);
@PostMapping("/order-channel-changes/validate")
ApiResponse<OrderChannelChangeValidationResponse> validateOrderChannelChange(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderChannelChangeValidationRequest request);
record InboundReferenceRequest(long tenantId, List<Long> orderIds) { record InboundReferenceRequest(long tenantId, List<Long> orderIds) {
} }
record InboundReference(Long orderId, Long inboundId, String inboundCode, String processStatus) { 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( record OrderApprovedInboundCommand(
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName,
@@ -13,7 +13,24 @@ public interface FirstlegPricingClient {
ApiResponse<ChannelValidationResponse> validateChannel(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<ChannelValidationResponse> validateChannel(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ChannelValidationRequest request); @RequestBody ChannelValidationRequest request);
@PostMapping("/order-channel-changes/validate")
ApiResponse<OrderChannelChangeResponse> validateOrderChannelChange(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderChannelChangeRequest request);
@PostMapping("/order-channel-changes/apply")
ApiResponse<OrderChannelChangeResponse> applyOrderChannelChange(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderChannelChangeRequest request);
record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode, record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode,
String destinationType, boolean requireInboundCodePrefix) {} String destinationType, boolean requireInboundCodePrefix) {}
record ChannelValidationResponse(String channelCode, String inboundCodePrefix) {} 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) {}
} }
@@ -36,7 +36,7 @@ public class OrderDeclarationUpdateService {
@Resource private OrderRepository repository; @Resource private OrderRepository repository;
@Resource private OrderPricingChannelGuard pricingChannelGuard; @Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private UnifiedOrderQueryService unifiedOrders; @Resource private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard;
@Resource private ObjectMapper json; @Resource private ObjectMapper json;
@Transactional @Transactional
@@ -70,12 +70,16 @@ public class OrderDeclarationUpdateService {
boolean channelChangedNow = !Objects.equals(channelCode, order.getChannelCode()) boolean channelChangedNow = !Objects.equals(channelCode, order.getChannelCode())
|| !Objects.equals(channelName, order.getChannelName()); || !Objects.equals(channelName, order.getChannelName());
if (channelChangedNow) { if (channelChangedNow) {
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), channelCode, if (order.getStatus() == OrderStatus.APPROVED) {
order.getDestinationType()); 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<OrderDeclarationDtos.FieldChange> changes = new ArrayList<>(); List<OrderDeclarationDtos.FieldChange> changes = new ArrayList<>();
change(changes, null, "customerRemark", order.getCustomerRemark(), request.customerRemark()); change(changes, null, "customerRemark", order.getCustomerRemark(), request.customerRemark());
change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), request.expectedArrivalAt()); change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), request.expectedArrivalAt());
@@ -109,17 +113,6 @@ public class OrderDeclarationUpdateService {
request.expectedVersion(), version, changedBefore, channelChanged, List.copyOf(changes)); 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<OrderDeclarationDtos.FieldChange> changes, OrderCargoEntity before, private void collect(List<OrderDeclarationDtos.FieldChange> changes, OrderCargoEntity before,
OrderDeclarationDtos.Cargo after) { OrderDeclarationDtos.Cargo after) {
int item = after.itemNo(); int item = after.itemNo();
@@ -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<FirstlegInboundClient.OrderChannelChangeValidationResponse> 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, "头程履约渠道校验服务暂时不可用");
}
}
@@ -4,6 +4,8 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.order.client.FirstlegInboundClient; 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.entity.OutboxEventEntity;
import com.baiyue.order.dto.OrderDeclarationDtos; import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.repository.OrderRepository; import com.baiyue.order.repository.OrderRepository;
@@ -21,6 +23,7 @@ import java.time.LocalDateTime;
public class OrderInboundOutboxPublisher { public class OrderInboundOutboxPublisher {
@Resource private OrderRepository repository; @Resource private OrderRepository repository;
@Resource private FirstlegInboundClient client; @Resource private FirstlegInboundClient client;
@Resource private FirstlegPricingClient pricingClient;
@Resource private ObjectMapper json; @Resource private ObjectMapper json;
@Value("${baiyue.security.internal-service-token}") private String internalToken; @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"); failPermanently(event, "declaration event ownership does not match payload");
return; 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<FirstlegPricingClient.OrderChannelChangeResponse> 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<Void> response = client.updateDeclaration(internalToken, command); ApiResponse<Void> response = client.updateDeclaration(internalToken, command);
if (response == null || !response.successful()) throw new IllegalStateException("头程申报同步失败"); if (response == null || !response.successful()) throw new IllegalStateException("头程申报同步失败");
repository.markOutboxPublished(event.getTenantId(), event.getId(), LocalDateTime.now()); 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) { private boolean incomplete(FirstlegInboundClient.OrderApprovedInboundCommand command) {
return command.orderNo() == null || command.orderNo().isBlank() return command.orderNo() == null || command.orderNo().isBlank()
|| command.inboundCode() == null || command.inboundCode().isBlank() || command.inboundCode() == null || command.inboundCode().isBlank()
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegPricingClient; import com.baiyue.order.client.FirstlegPricingClient;
import com.baiyue.order.entity.OrderEntity;
import feign.FeignException; import feign.FeignException;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -24,6 +25,29 @@ public class OrderPricingChannelGuard {
validate(tenantId, originWarehouseId, channelCode, destinationType, false); validate(tenantId, originWarehouseId, channelCode, destinationType, false);
} }
public void requireOrderChannelChangeEligible(OrderEntity order, long nextOrderVersion,
String channelCode, String channelName) {
try {
ApiResponse<FirstlegPricingClient.OrderChannelChangeResponse> 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, private String validate(long tenantId, long originWarehouseId, String channelCode,
String destinationType, boolean requireInboundCodePrefix) { String destinationType, boolean requireInboundCodePrefix) {
try { try {
@@ -33,12 +57,7 @@ public class OrderPricingChannelGuard {
if (response == null) { if (response == null) {
throw unavailable(); throw unavailable();
} }
if (!response.successful()) { requireSuccessful(response);
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
throw new BizException(code, message);
}
if (response.data() == null || requireInboundCodePrefix if (response.data() == null || requireInboundCodePrefix
&& (response.data().inboundCodePrefix() == null || response.data().inboundCodePrefix().isBlank())) { && (response.data().inboundCodePrefix() == null || response.data().inboundCodePrefix().isBlank())) {
throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀"); 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() { private BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程价格渠道校验服务暂时不可用"); return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程价格渠道校验服务暂时不可用");
} }
@@ -212,8 +212,14 @@ paths:
description: >- description: >-
需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID; 需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID;
不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/PDA/设备实测、测量审核或计费事实。 不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/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: parameters:
- $ref: '#/components/parameters/UnifiedOrderId' - $ref: '#/components/parameters/UnifiedOrderId'
- $ref: '#/components/parameters/UnifiedTenantId' - $ref: '#/components/parameters/UnifiedTenantId'
@@ -223,7 +229,7 @@ paths:
application/json: application/json:
schema: {$ref: '#/components/schemas/OrderDeclarationUpdateRequest'} schema: {$ref: '#/components/schemas/OrderDeclarationUpdateRequest'}
responses: responses:
'200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突或存在不可逆履约事实时响应体业务码为 A0409} '200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突、渠道与不可逆履约或已确认费用不兼容时响应体业务码为 A0409}
'403': {description: 无权限或非平台身份} '403': {description: 无权限或非平台身份}
/api/merchant/v1/orders: /api/merchant/v1/orders:
get: get:
@@ -1,10 +1,13 @@
package com.baiyue.order.controller; package com.baiyue.order.controller;
import com.baiyue.order.dto.OrderDeclarationDtos; 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.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -26,7 +29,9 @@ class OrderDeclarationUpdateContractTest {
assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents()) assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents())
.extracting(component -> component.getName()) .extracting(component -> component.getName())
.containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt", .containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt",
"channelCode", "channelName", "cargoes"); "channelCode", "channelName", "cargoes")
.doesNotContain("receivingWarehouseId", "receivingWarehouseName",
"destinationWarehouseId", "destinationWarehouseName", "inboundCode", "orderNo");
assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents()) assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents())
.extracting(component -> component.getName()) .extracting(component -> component.getName())
.contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount", .contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount",
@@ -46,7 +51,26 @@ class OrderDeclarationUpdateContractTest {
Map<String, Object> row = map(map(schemas.get("UnifiedOrderRow")).get("properties")); Map<String, Object> row = map(map(schemas.get("UnifiedOrderRow")).get("properties"));
assertThat(row).containsKeys("expectedVolumeCbm", "actualVolumeCbm", "expectedArrivalAt", "channelChanged"); assertThat(row).containsKeys("expectedVolumeCbm", "actualVolumeCbm", "expectedArrivalAt", "channelChanged");
assertThat(map(row.get("actualVolumeCbm")).get("description").toString()).contains("仅 Firstleg 实测"); 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 @Test
@@ -2,7 +2,6 @@ package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDeclarationDtos; import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.entity.OrderStatus;
@@ -33,7 +32,7 @@ import static org.mockito.Mockito.when;
class OrderDeclarationUpdateServiceTest { class OrderDeclarationUpdateServiceTest {
@MockBean private OrderRepository repository; @MockBean private OrderRepository repository;
@MockBean private OrderPricingChannelGuard pricingChannelGuard; @MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private UnifiedOrderQueryService unifiedOrders; @MockBean private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard;
@MockBean private ObjectMapper json; @MockBean private ObjectMapper json;
@Resource private OrderDeclarationUpdateService service; @Resource private OrderDeclarationUpdateService service;
@@ -76,45 +75,59 @@ class OrderDeclarationUpdateServiceTest {
} }
@Test @Test
void approvedOrderWithMeasurementFactIsAtomicallyRejected() { 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()));
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 {
OrderEntity order = order(OrderStatus.APPROVED, 3); OrderEntity order = order(OrderStatus.APPROVED, 3);
when(repository.findOwned(100L, 200L)).thenReturn(order); when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo())); when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(), when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(),
any(), any(), anyBoolean())).thenReturn(1); any(), any(), anyBoolean())).thenReturn(1);
when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).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}"); 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()); 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) { private OrderEntity order(OrderStatus status, int version) {
OrderEntity order = new OrderEntity(); OrderEntity order = new OrderEntity();
order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200"); order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200");
@@ -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;
}
}
@@ -2,6 +2,7 @@ package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.order.client.FirstlegInboundClient; import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.client.FirstlegPricingClient;
import com.baiyue.order.entity.OutboxEventEntity; import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.repository.OrderRepository; import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -130,7 +131,7 @@ class OrderInboundOutboxPublisherTest {
} }
@Test @Test
void declarationUpdateEventIsDeliveredByVersionedContract() { void ordinaryDeclarationUpdateDoesNotInvalidatePricing() {
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-1\",\"tenantId\":11," String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-1\",\"tenantId\":11,"
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\"," + "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\"," + "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
@@ -140,9 +141,53 @@ class OrderInboundOutboxPublisherTest {
fixture.publisher.publishApprovedOrders(); 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.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
verify(fixture.client, never()).create(any(), any()); 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) { private Fixture fixture(OutboxEventEntity event) {
@@ -152,6 +197,7 @@ class OrderInboundOutboxPublisherTest {
private Fixture fixture(OutboxEventEntity event, String authoritativeTaxMode) { private Fixture fixture(OutboxEventEntity event, String authoritativeTaxMode) {
OrderRepository repository = mock(OrderRepository.class); OrderRepository repository = mock(OrderRepository.class);
FirstlegInboundClient client = mock(FirstlegInboundClient.class); FirstlegInboundClient client = mock(FirstlegInboundClient.class);
FirstlegPricingClient pricingClient = mock(FirstlegPricingClient.class);
when(repository.pendingInboundEvents(50)).thenReturn(List.of(event)); when(repository.pendingInboundEvents(50)).thenReturn(List.of(event));
if (event.getTenantId() != null && event.getAggregateId() != null) { if (event.getTenantId() != null && event.getAggregateId() != null) {
var order = new com.baiyue.order.entity.OrderEntity(); var order = new com.baiyue.order.entity.OrderEntity();
@@ -163,6 +209,8 @@ class OrderInboundOutboxPublisherTest {
order.setReceivingWarehouseId(31L); order.setReceivingWarehouseId(31L);
order.setReceivingWarehouseName("测试仓"); order.setReceivingWarehouseName("测试仓");
order.setDestinationType("OVERSEAS"); order.setDestinationType("OVERSEAS");
order.setDestinationWarehouseId(41L);
order.setAmazonWarehouseCode("ONT8");
order.setTransportMode("SEA"); order.setTransportMode("SEA");
order.setTaxMode(authoritativeTaxMode); order.setTaxMode(authoritativeTaxMode);
order.setPricingTaxMode("TAX_EXCLUDED"); order.setPricingTaxMode("TAX_EXCLUDED");
@@ -177,9 +225,10 @@ class OrderInboundOutboxPublisherTest {
OrderInboundOutboxPublisher publisher = new OrderInboundOutboxPublisher(); OrderInboundOutboxPublisher publisher = new OrderInboundOutboxPublisher();
ReflectionTestUtils.setField(publisher, "repository", repository); ReflectionTestUtils.setField(publisher, "repository", repository);
ReflectionTestUtils.setField(publisher, "client", client); ReflectionTestUtils.setField(publisher, "client", client);
ReflectionTestUtils.setField(publisher, "pricingClient", pricingClient);
ReflectionTestUtils.setField(publisher, "json", new ObjectMapper()); ReflectionTestUtils.setField(publisher, "json", new ObjectMapper());
ReflectionTestUtils.setField(publisher, "internalToken", "internal-token"); 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) { private OutboxEventEntity event(String eventType, Long tenantId, Long aggregateId, String payload) {
@@ -207,6 +256,6 @@ class OrderInboundOutboxPublisherTest {
} }
private record Fixture(OrderInboundOutboxPublisher publisher, OrderRepository repository, private record Fixture(OrderInboundOutboxPublisher publisher, OrderRepository repository,
FirstlegInboundClient client) { FirstlegInboundClient client, FirstlegPricingClient pricingClient) {
} }
} }
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegPricingClient; import com.baiyue.order.client.FirstlegPricingClient;
import com.baiyue.order.entity.OrderEntity;
import feign.FeignException; import feign.FeignException;
import feign.Request; import feign.Request;
import feign.Response; import feign.Response;
@@ -65,6 +66,24 @@ class OrderPricingChannelGuardTest {
.isEqualTo(ResultCode.CONFLICT); .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 @Test
void nullResponseMapsToServiceUnavailable() { void nullResponseMapsToServiceUnavailable() {
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true); var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true);