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