This commit is contained in:
2026-08-05 16:26:10 +08:00
parent 06f7f9d2f2
commit 5ba9716e12
155 changed files with 2883 additions and 551 deletions
@@ -18,6 +18,6 @@ public class InboundProperties {
private boolean allowRejectedMeasurement = false;
private BigDecimal defaultPalletCapacityCbm = new BigDecimal("1.200000");
private Duration reservationTtl = Duration.ofMinutes(30);
/** 共享收货仓资源所属租户;0 表示与入库业务租户一致。 */
/** 历史配置兼容字段;平台公共仓资源当前固定使用技术作用域 0。 */
private long warehouseResourceTenantId = 0L;
}
@@ -3,10 +3,9 @@ package com.baiyue.firstleg.controller;
import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.MerchantInboundBillingResponse;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.MerchantInboundBillingService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -33,10 +32,7 @@ public class MerchantInboundBillingController {
description = "客户只读查看客服审核通过后发布的费用,不需要确认费用。")
public ApiResponse<MerchantInboundBillingResponse> find(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
if (user == null || !"MERCHANT".equals(user.appCode()) || user.tenantId() == null) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
}
return ApiResponse.success(service.find(user.tenantId(), orderId));
return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), orderId));
}
@GetMapping("/{orderId}/inbound-billing/images/{fileId}")
@@ -46,10 +42,7 @@ public class MerchantInboundBillingController {
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long orderId,
@PathVariable long fileId) {
if (user == null || !"MERCHANT".equals(user.appCode()) || user.tenantId() == null) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
}
var image = service.downloadImage(user.tenantId(), orderId, fileId);
var image = service.downloadImage(FirstlegScopeGuard.merchant(user), orderId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(image.contentType()))
.contentLength(image.content().length).body(image.content());
@@ -4,8 +4,6 @@ import jakarta.annotation.Resource;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
@@ -114,9 +112,7 @@ public class PlatformFbaMasterDataController {
}
private static Long optionalPlatformTenant(AuthenticatedUser user, Long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
FirstlegScopeGuard.platform(user);
return tenantId == null ? null : FirstlegScopeGuard.platform(user, tenantId);
}
}
@@ -3,14 +3,13 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.AssignRequest;
import com.baiyue.firstleg.dto.FulfillmentTaskQuery;
import com.baiyue.firstleg.entity.AssignmentHistoryEntity;
import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
import com.baiyue.firstleg.service.FulfillmentTaskService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -65,8 +64,7 @@ public class PlatformFulfillmentAssignmentController {
@Parameter(description = "First-leg fulfillment task ID / 头程履约任务 ID", example = "190000000000000101", required = true)
@PathVariable long taskId,
@Valid @RequestBody AssignRequest request) {
requirePlatform(user);
requireTenant(tenantId);
FirstlegScopeGuard.platform(user, tenantId);
service.assign(
tenantId,
user.userId(),
@@ -89,8 +87,7 @@ public class PlatformFulfillmentAssignmentController {
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid FulfillmentTaskQuery query) {
requirePlatform(user);
requireTenant(tenantId);
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.page(tenantId, query));
}
@@ -105,8 +102,7 @@ public class PlatformFulfillmentAssignmentController {
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long taskId) {
requirePlatform(user);
requireTenant(tenantId);
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.detail(tenantId, taskId));
}
@@ -121,8 +117,7 @@ public class PlatformFulfillmentAssignmentController {
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long orderId) {
requirePlatform(user);
requireTenant(tenantId);
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.byOrder(tenantId, orderId));
}
@@ -139,20 +134,8 @@ public class PlatformFulfillmentAssignmentController {
@PathVariable long taskId,
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) {
requirePlatform(user);
requireTenant(tenantId);
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.assignmentHistory(tenantId, taskId, page, size));
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
private static void requireTenant(long tenantId) {
if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
}
}
@@ -9,6 +9,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundMeasurementService;
import com.baiyue.firstleg.service.MeasurementSubmissionService;
import io.swagger.v3.oas.annotations.Operation;
@@ -120,10 +121,8 @@ public class PlatformInboundMeasurementController {
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0 || user.userId() == null || user.userId() <= 0) {
FirstlegScopeGuard.platform(user, tenantId);
if (user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户和操作人");
}
}
@@ -5,14 +5,13 @@ import jakarta.annotation.Resource;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.service.MeasurementReviewService;
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -108,11 +107,6 @@ public class PlatformInboundMeasurementReviewController {
}
private static void validateScope(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
FirstlegScopeGuard.platform(user, tenantId);
}
}
@@ -10,6 +10,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundOrderQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -61,15 +62,10 @@ public class PlatformInboundOrderController {
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
requirePlatform(user);
if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
FirstlegScopeGuard.platform(user, tenantId);
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
FirstlegScopeGuard.platform(user);
}
}
@@ -11,7 +11,8 @@ public interface InboundIdempotencyMapper extends BaseMapper<InboundIdempotencyE
@Update("""
UPDATE fl_inbound_idempotency
SET status = 'SUCCESS', result_json = #{resultJson}, completed_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND status = 'PROCESSING'
WHERE tenant_id = #{tenantId} AND id = #{id} AND status = 'PROCESSING'
""")
int complete(@Param("id") long id, @Param("resultJson") String resultJson);
int complete(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("resultJson") String resultJson);
}
@@ -250,6 +250,7 @@ public class FbaPricingRepository {
public int completeIdempotency(InboundPricingIdempotencyEntity value, long resourceId, LocalDateTime now) {
return idempotencies.update(null, new LambdaUpdateWrapper<InboundPricingIdempotencyEntity>()
.eq(InboundPricingIdempotencyEntity::getTenantId, value.getTenantId())
.eq(InboundPricingIdempotencyEntity::getId, value.getId())
.eq(InboundPricingIdempotencyEntity::getStatus, "PROCESSING")
.set(InboundPricingIdempotencyEntity::getStatus, "COMPLETED")
@@ -60,11 +60,15 @@ public class FeeItemRepository {
}
public int updateItem(FeeItemEntity item) {
return feeItems.updateById(item);
return feeItems.update(item, new LambdaUpdateWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, item.getTenantId())
.eq(FeeItemEntity::getId, item.getId()));
}
public int deleteItem(long id) {
return feeItems.deleteById(id);
public int deleteItem(long tenantId, long id) {
return feeItems.delete(new LambdaQueryWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, tenantId)
.eq(FeeItemEntity::getId, id));
}
public long countBindings(long tenantId, long feeItemId) {
@@ -99,11 +103,15 @@ public class FeeItemRepository {
}
public int updateDetail(InboundFeeDetailEntity detail) {
return feeDetails.updateById(detail);
return feeDetails.update(detail, new LambdaUpdateWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, detail.getTenantId())
.eq(InboundFeeDetailEntity::getId, detail.getId()));
}
public int deleteDetail(long detailId) {
return feeDetails.deleteById(detailId);
public int deleteDetail(long tenantId, long detailId) {
return feeDetails.delete(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getId, detailId));
}
public int review(long tenantId, long inboundId, long detailId, String targetStatus,
@@ -5,6 +5,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.FulfillmentTaskQuery;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.entity.AssignmentHistoryEntity;
import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
@@ -53,7 +54,9 @@ public class FulfillmentTaskRepository {
}
public boolean update(FulfillmentTaskEntity task) {
return tasks.updateById(task) == 1;
return tasks.update(task, new LambdaUpdateWrapper<FulfillmentTaskEntity>()
.eq(FulfillmentTaskEntity::getTenantId, task.getTenantId())
.eq(FulfillmentTaskEntity::getId, task.getId())) == 1;
}
public void insertHistory(AssignmentHistoryEntity history) {
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.repository;
import jakarta.annotation.Resource;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
@@ -42,7 +43,9 @@ public class InboundMeasurementRepository {
}
public boolean updateInbound(InboundOrderEntity inbound) {
return inboundOrders.updateById(inbound) == 1;
return inboundOrders.update(inbound, new LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, inbound.getTenantId())
.eq(InboundOrderEntity::getId, inbound.getId())) == 1;
}
public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) {
@@ -72,7 +75,9 @@ public class InboundMeasurementRepository {
}
public boolean updateMeasurement(InboundMeasurementEntity measurement) {
return measurements.updateById(measurement) == 1;
return measurements.update(measurement, new LambdaUpdateWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, measurement.getTenantId())
.eq(InboundMeasurementEntity::getId, measurement.getId())) == 1;
}
public void replaceItems(long tenantId, long measurementId,
@@ -109,7 +114,9 @@ public class InboundMeasurementRepository {
}
public boolean updateWeight(InboundWeightResultEntity weight) {
return weights.updateById(weight) == 1;
return weights.update(weight, new LambdaUpdateWrapper<InboundWeightResultEntity>()
.eq(InboundWeightResultEntity::getTenantId, weight.getTenantId())
.eq(InboundWeightResultEntity::getId, weight.getId())) == 1;
}
@@ -108,8 +108,8 @@ public class InboundWorkflowRepository {
.eq(InboundIdempotencyEntity::getIdempotencyKey, key));
}
public int completeIdempotency(long id, String resultJson) {
return idempotencies.complete(id, resultJson);
public int completeIdempotency(long tenantId, long id, String resultJson) {
return idempotencies.complete(tenantId, id, resultJson);
}
public InboundFeeEntity findConfirmedFee(long tenantId, long inboundId, long feeId) {
@@ -67,7 +67,7 @@ public class FeeItemService {
if (repository.countBindings(tenantId, id) > 0) {
throw conflict("费用项目已被入库费用明细使用,只能停用,不能删除");
}
if (repository.deleteItem(id) != 1) throw conflict("费用项目删除冲突");
if (repository.deleteItem(tenantId, id) != 1) throw conflict("费用项目删除冲突");
}
private FeeItemEntity required(long tenantId, long id) {
@@ -4,17 +4,30 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
/** 头程平台接口租户边界校验。 */
/** 头程平台与客户接口的主体边界校验。 */
public final class FirstlegScopeGuard {
private FirstlegScopeGuard() {}
public static long platform(AuthenticatedUser user, long targetTenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
platform(user);
if (targetTenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
return targetTenantId;
}
public static void platform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())
|| user.tenantId() == null || user.tenantId() != 0L) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
public static long merchant(AuthenticatedUser user) {
if (user == null || !"MERCHANT".equals(user.appCode())
|| user.tenantId() == null || user.tenantId() <= 0L) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
}
return user.tenantId();
}
}
@@ -59,7 +59,7 @@ public class InboundFeeDetailService {
@Transactional
public void delete(long tenantId, long inboundId, long detailId) {
requiredEditable(tenantId, inboundId, detailId);
if (repository.deleteDetail(detailId) != 1) throw conflict("费用明细删除冲突");
if (repository.deleteDetail(tenantId, detailId) != 1) throw conflict("费用明细删除冲突");
}
@Transactional
@@ -44,7 +44,7 @@ public class InboundIdempotencyService {
record.setUpdatedAt(record.getCreatedAt());
try {
repository.insertIdempotency(record);
return new Context(record.getId(), false);
return new Context(tenantId, record.getId(), false);
} catch (DuplicateKeyException duplicate) {
InboundIdempotencyEntity existing = repository.findIdempotency(
tenantId, operation, operatorId, key);
@@ -53,7 +53,7 @@ public class InboundIdempotencyService {
"Idempotency-Key 已用于不同请求");
}
if ("SUCCESS".equals(existing.getStatus())) {
return new Context(existing.getId(), true);
return new Context(tenantId, existing.getId(), true);
}
throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试");
}
@@ -61,7 +61,8 @@ public class InboundIdempotencyService {
public void complete(Context context, long inboundId) {
if (!context.replayed()
&& repository.completeIdempotency(context.id(), "{\"inboundId\":\"" + inboundId + "\"}") != 1) {
&& repository.completeIdempotency(context.tenantId(), context.id(),
"{\"inboundId\":\"" + inboundId + "\"}") != 1) {
throw new BizException(ResultCode.CONFLICT, "幂等记录已被并发修改");
}
}
@@ -75,6 +76,6 @@ public class InboundIdempotencyService {
}
}
public record Context(long id, boolean replayed) {
public record Context(long tenantId, long id, boolean replayed) {
}
}
@@ -20,6 +20,7 @@ import java.time.LocalDateTime;
@Service
public class MeasurementLocationOccupationService {
private static final String SOURCE_SERVICE = "firstleg-service";
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
@Resource
private WarehousePalletLocationClient warehouse;
@Resource
@@ -35,8 +36,7 @@ public class MeasurementLocationOccupationService {
int measurementVersion) {
InboundOrderEntity order = requireOrder(tenantId, inboundId);
long warehouseId = requireWarehouse(order);
long resourceTenantId = properties.getWarehouseResourceTenantId() > 0
? properties.getWarehouseResourceTenantId() : tenantId;
long resourceTenantId = PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID;
BigDecimal volume = requireVolume(order);
String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion;
WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve(
@@ -4,6 +4,7 @@ import jakarta.annotation.Resource;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.config.MeasurementOpenApiProperties;
import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity;
import com.baiyue.firstleg.mapper.ThirdPartyCallLogMapper;
@@ -55,6 +56,10 @@ public class ThirdPartyCallLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void complete(long id, String status, String code, String message, String resultJson) {
Long tenantId = tenantId(id);
if (tenantId == null) {
return;
}
ThirdPartyCallLogEntity update = new ThirdPartyCallLogEntity();
update.setId(id);
update.setProcessingStatus(status);
@@ -62,18 +67,25 @@ public class ThirdPartyCallLogService {
update.setResultMessage(limit(message, 1000));
update.setResultJson(resultJson);
update.setProcessedAt(LocalDateTime.now());
mapper.updateById(update);
mapper.update(update, new LambdaUpdateWrapper<ThirdPartyCallLogEntity>()
.eq(ThirdPartyCallLogEntity::getTenantId, tenantId)
.eq(ThirdPartyCallLogEntity::getId, id));
}
/** 签名通过后原子认领 Idempotency-Key;非法签名不会污染幂等空间。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean claimIdempotency(long logId) {
Long tenantId = tenantId(logId);
if (tenantId == null) {
return false;
}
ThirdPartyCallLogEntity update = new ThirdPartyCallLogEntity();
update.setId(logId);
update.setIdempotencyAnchor(1);
try {
mapper.updateById(update);
return true;
return mapper.update(update, new LambdaUpdateWrapper<ThirdPartyCallLogEntity>()
.eq(ThirdPartyCallLogEntity::getTenantId, tenantId)
.eq(ThirdPartyCallLogEntity::getId, logId)) == 1;
} catch (DuplicateKeyException exception) {
return false;
}
@@ -87,6 +99,11 @@ public class ThirdPartyCallLogService {
.eq(ThirdPartyCallLogEntity::getIdempotencyAnchor, 1));
}
private Long tenantId(long id) {
ThirdPartyCallLogEntity log = mapper.selectById(id);
return log == null ? null : log.getTenantId();
}
private String limit(String value, int max) {
if (value == null || value.length() <= max) {
return value;
@@ -41,17 +41,18 @@ CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3)
CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from");
CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json");
CALL phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id',
"BIGINT UNSIGNED NULL COMMENT '仓储资源所属租户ID' AFTER warehouse_id");
"BIGINT UNSIGNED NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键' AFTER warehouse_id");
-- 旧分配记录创建于共享仓储资源租户拆分前,按原业务租户回填后再收口非空约束。
-- 仓储资源是平台公共主数据;存量分配快照统一迁移到平台作用域 0,再收口非空约束。
UPDATE fl_inbound_pallet_allocation
SET warehouse_resource_tenant_id = tenant_id
WHERE warehouse_resource_tenant_id IS NULL OR warehouse_resource_tenant_id = 0;
SET @resource_tenant_nullable = (SELECT IS_NULLABLE FROM information_schema.COLUMNS
SET warehouse_resource_tenant_id = 0
WHERE warehouse_resource_tenant_id IS NULL OR warehouse_resource_tenant_id <> 0;
SET @resource_tenant_needs_modify = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_inbound_pallet_allocation'
AND COLUMN_NAME = 'warehouse_resource_tenant_id');
SET @sql = IF(@resource_tenant_nullable = 'YES',
"ALTER TABLE fl_inbound_pallet_allocation MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID'",
AND COLUMN_NAME = 'warehouse_resource_tenant_id'
AND (IS_NULLABLE = 'YES' OR COLUMN_COMMENT <> '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键'));
SET @sql = IF(@resource_tenant_needs_modify > 0,
"ALTER TABLE fl_inbound_pallet_allocation MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键'",
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -3,7 +3,7 @@ SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS fl_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用项目ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '账号ID,沿用tenant_id字段名',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户',
category VARCHAR(64) NOT NULL COMMENT '费用分类',
trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际',
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
@@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
id BIGINT UNSIGNED NOT NULL COMMENT '入库费用明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '账号ID,沿用tenant_id字段名',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
@@ -70,3 +70,23 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
CONSTRAINT fk_inbound_fee_detail_order FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_inbound_fee_detail_item FOREIGN KEY (fee_item_id) REFERENCES fl_fee_item (id)
) ENGINE=InnoDB COMMENT='入库单应收应付费用明细';
-- 既有表同步字段注释:tenant_id 当前承载客户 ID,不是上级租户 ID。
SET @fee_tenant_comment = '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户';
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_fee_item'
AND column_name = 'tenant_id' AND column_comment = @fee_tenant_comment) = 1,
'SELECT 1',
'ALTER TABLE fl_fee_item MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT ''当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户'''
);
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
AND column_name = 'tenant_id' AND column_comment = @fee_tenant_comment) = 1,
'SELECT 1',
'ALTER TABLE fl_inbound_fee_detail MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT ''当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户'''
);
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
@@ -423,7 +423,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID',
warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID',
warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照',
@@ -4,7 +4,7 @@ USE baiyue_firstleg;
-- 既有二期数据库增量:先确保 phase2-inbound-schema.sql 中的测量和幂等表已存在。
CREATE TABLE IF NOT EXISTS fl_third_party_call_log (
id BIGINT UNSIGNED NOT NULL COMMENT '第三方调用日志ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '识别出的租户ID,未知应用为0',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权',
app_key VARCHAR(128) NULL COMMENT '第三方应用标识',
signature_subject VARCHAR(128) NULL COMMENT '签名主体快照',
request_method VARCHAR(8) NOT NULL COMMENT 'HTTP方法',
@@ -28,3 +28,14 @@ CREATE TABLE IF NOT EXISTS fl_third_party_call_log (
KEY idx_third_call_nonce (tenant_id, app_key, nonce),
UNIQUE KEY uk_third_call_idempotency_owner (tenant_id, app_key, idempotency_key, idempotency_anchor)
) ENGINE=InnoDB COMMENT='第三方测量开放接口原始请求、签名主体和处理结果留档';
-- 既有表也要同步未知主体 0 的语义,避免历史注释继续误导为真实租户。
SET @third_party_tenant_comment = '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权';
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_third_party_call_log'
AND column_name = 'tenant_id' AND column_comment = @third_party_tenant_comment) = 1,
'SELECT 1',
'ALTER TABLE fl_third_party_call_log MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权'''
);
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
@@ -91,8 +91,8 @@ components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
parameters:
OptionalTenantId: {name: tenantId, in: query, required: false, description: 不传时查询全部企业, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
TenantId: {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
OptionalTenantId: {name: tenantId, in: query, required: false, description: 可选目标客户 ID;参数名为历史兼容,不传时查询全部企业, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
TenantId: {name: tenantId, in: query, required: true, description: 目标客户 ID;参数名为历史兼容, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
InboundId: {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
@@ -133,7 +133,7 @@ paths:
- {name: tab, in: query, schema: {type: string, enum: [WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]}}
responses:
'200':
description: 成功或业务失败,按响应体 code 判断;每条记录携带后续租户隔离操作所需的 tenantId
description: 成功或业务失败,按响应体 code 判断;每条记录携带后续客户隔离操作所需的 tenantId,该字段当前表示客户 ID
content:
application/json:
schema: {$ref: '#/components/schemas/InboundOrderPageEnvelope'}
@@ -184,7 +184,7 @@ paths:
- {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 非本租户或无订单查看权限}
'403': {description: 非当前客户或无订单查看权限}
/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:
post:
summary: 测量异常放行
@@ -276,7 +276,7 @@ components:
required: [id, tenantId]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 记录所属企业租户 ID,用于详情和处理接口的租户隔离}
tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 记录所属客户 ID;字段名为历史兼容,用于详情和处理接口的客户隔离}
orderId: {type: string, nullable: true}
orderNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
@@ -14,6 +14,8 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@SpringJUnitConfig(MerchantInboundBillingController.class)
class MerchantInboundBillingControllerContractTest {
@@ -38,6 +40,17 @@ class MerchantInboundBillingControllerContractTest {
"admin", "PLATFORM", "WEB", Set.of());
assertThatThrownBy(() -> controller.find(platform, 77L)).isInstanceOf(BizException.class);
}
@Test
void merchantTechnicalTenantCannotReadCustomerBilling() {
var invalidMerchant = new AuthenticatedUser(1L, 0L, 0L, 1L,
"merchant", "MERCHANT", "WEB", Set.of());
assertThatThrownBy(() -> controller.find(invalidMerchant, 77L))
.isInstanceOf(BizException.class);
verify(service, never()).find(0L, 77L);
}
@Test
void shouldHandleCase03() throws Exception {
var method = MerchantInboundBillingController.class.getDeclaredMethod(
@@ -1,9 +1,13 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.mapper.CarrierFbaRateMapper;
import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper;
import com.baiyue.firstleg.mapper.InboundFeeItemMapper;
@@ -85,6 +89,26 @@ class FbaPricingRepositoryRuleListTest {
assertThat(result).isEqualTo(4);
}
@Test
void idempotencyCompletionKeepsTenantAndRecordFilters() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
InboundPricingIdempotencyEntity.class);
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
value.setTenantId(10001L);
value.setId(88L);
repository.completeIdempotency(value, 501L, java.time.LocalDateTime.now());
@SuppressWarnings("unchecked")
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<InboundPricingIdempotencyEntity>> wrapper =
ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
verify(idempotency).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
assertThat(wrapper.getValue().getCustomSqlSegment())
.contains("tenant_id")
.containsPattern("(?i)(^|\\W)id\\s*=");
assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L);
}
private Collection<Object> queryValues(Long tenantId, String ruleCode) {
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
@@ -111,4 +135,9 @@ class FbaPricingRepositoryRuleListTest {
return wrapper.getValue().getParamNameValuePairs().values();
}
@SuppressWarnings("rawtypes")
private static java.util.Map<String, Object> params(Wrapper<?> wrapper) {
return ((AbstractWrapper) wrapper).getParamNameValuePairs();
}
}
@@ -35,7 +35,7 @@ class InboundWorkflowSqlContractTest {
assertThat(migration)
.contains("phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'")
.contains("SET warehouse_resource_tenant_id = tenant_id")
.contains("SET warehouse_resource_tenant_id = 0")
.contains("MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL");
}
}
@@ -0,0 +1,117 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.mapper.AssignmentHistoryMapper;
import com.baiyue.firstleg.mapper.FeeItemMapper;
import com.baiyue.firstleg.mapper.FulfillmentTaskMapper;
import com.baiyue.firstleg.mapper.InboundFeeDetailMapper;
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundWeightResultMapper;
import com.baiyue.firstleg.mapper.InboundWeightRevisionMapper;
import com.baiyue.firstleg.mapper.MeasureReviewRecordMapper;
import jakarta.annotation.Resource;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig({FulfillmentTaskRepository.class, InboundMeasurementRepository.class,
FeeItemRepository.class})
class TenantScopedMutationRepositoryTest {
private static final long TENANT = 10001L;
private static final long ID = 88L;
@MockBean private FulfillmentTaskMapper tasks;
@MockBean private AssignmentHistoryMapper histories;
@MockBean private InboundOrderMapper inboundOrders;
@MockBean private InboundMeasurementMapper measurements;
@MockBean private InboundMeasurementItemMapper measurementItems;
@MockBean private InboundWeightResultMapper weights;
@MockBean private InboundWeightRevisionMapper revisions;
@MockBean private MeasureReviewRecordMapper reviews;
@MockBean private FeeItemMapper feeItems;
@MockBean private InboundFeeDetailMapper feeDetails;
@Resource private FulfillmentTaskRepository fulfillmentRepository;
@Resource private InboundMeasurementRepository measurementRepository;
@Resource private FeeItemRepository feeRepository;
@BeforeAll
static void initializeMetadata() {
initialize(FulfillmentTaskEntity.class);
initialize(InboundWeightResultEntity.class);
initialize(InboundFeeDetailEntity.class);
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void fulfillmentUpdateMatchesTenantAndId() {
FulfillmentTaskEntity task = new FulfillmentTaskEntity();
task.setTenantId(TENANT);
task.setId(ID);
when(tasks.update(any(), any(Wrapper.class))).thenReturn(1);
assertThat(fulfillmentRepository.update(task)).isTrue();
ArgumentCaptor<Wrapper<FulfillmentTaskEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
verify(tasks).update(eq(task), wrapper.capture());
assertTenantAndId(wrapper.getValue());
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void measurementUpdateMatchesTenantAndId() {
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setTenantId(TENANT);
weight.setId(ID);
when(weights.update(any(), any(Wrapper.class))).thenReturn(1);
assertThat(measurementRepository.updateWeight(weight)).isTrue();
ArgumentCaptor<Wrapper<InboundWeightResultEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
verify(weights).update(eq(weight), wrapper.capture());
assertTenantAndId(wrapper.getValue());
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void feeDetailDeleteMatchesTenantAndId() {
when(feeDetails.delete(any(Wrapper.class))).thenReturn(1);
assertThat(feeRepository.deleteDetail(TENANT, ID)).isEqualTo(1);
ArgumentCaptor<Wrapper<InboundFeeDetailEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
verify(feeDetails).delete(wrapper.capture());
assertTenantAndId(wrapper.getValue());
}
private static void initialize(Class<?> entityType) {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), "test"), entityType);
}
private static void assertTenantAndId(Wrapper<?> wrapper) {
assertThat(wrapper.getCustomSqlSegment())
.contains("tenant_id")
.containsPattern("(?i)(^|\\W)id\\s*=");
assertThat(((AbstractWrapper<?, ?, ?>) wrapper).getParamNameValuePairs().values())
.contains(TENANT, ID);
}
}
@@ -49,6 +49,18 @@ class FbaPricingServiceTest {
@MockBean private FbaPricingRepository repository;
@Resource private FbaPricingService service;
@Test
@DisplayName("客户B不能读取客户A的费用快照")
void rejectsCrossCustomerFeeLookup() {
assertThatThrownBy(() -> service.latest(10002L, INBOUND))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
verify(repository).findLatestFee(10002L, INBOUND);
verify(repository, never()).findLatestFee(TENANT, INBOUND);
}
@Test
@DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入")
void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() {
@@ -22,6 +22,7 @@ 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.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -35,6 +36,17 @@ class FeeManagementServiceTest {
@Resource
private InboundFeeDetailService details;
@Test
@DisplayName("客户B不能删除客户A的费用明细")
void rejectsCrossCustomerFeeDetailDelete() {
assertThatThrownBy(() -> details.delete(10002L, 21L, 31L))
.isInstanceOf(BizException.class)
.hasMessageContaining("费用明细不存在");
verify(repository).findDetail(10002L, 21L, 31L);
verify(repository, never()).deleteDetail(10002L, 31L);
}
@Test
@DisplayName("费用项目列表返回分页结果")
void pagesItems() {
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FirstlegScopeGuardTest {
@Test
void platformRequiresZeroTenantAndPositiveTargetCustomer() {
AuthenticatedUser platform = user(0L, "PLATFORM");
assertThat(FirstlegScopeGuard.platform(platform, 10001L)).isEqualTo(10001L);
assertForbidden(() -> FirstlegScopeGuard.platform(user(10001L, "PLATFORM"), 10002L));
assertForbidden(() -> FirstlegScopeGuard.platform(user(0L, "MERCHANT"), 10002L));
}
@Test
void merchantRequiresPositiveAuthenticatedCustomer() {
assertThat(FirstlegScopeGuard.merchant(user(10001L, "MERCHANT"))).isEqualTo(10001L);
assertForbidden(() -> FirstlegScopeGuard.merchant(user(0L, "MERCHANT")));
assertForbidden(() -> FirstlegScopeGuard.merchant(user(10001L, "PLATFORM")));
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(9L, tenantId, tenantId, 1L,
"operator", appCode, "WEB", Set.of());
}
private static void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable call) {
assertThatThrownBy(call)
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
}
}
@@ -115,7 +115,7 @@ class InboundCompletionOrchestratorTest {
private Fixture fixture(String reviewStatus, boolean feeRelease) {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(1L, false));
.thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false));
InboundOrderEntity order = new InboundOrderEntity();
order.setProcessStatus("FEE_CONFIRMING");
order.setFeeStatus("CONFIRMED");
@@ -37,7 +37,11 @@ class InboundIdempotencyServiceTest {
InboundIdempotencyService.Context context = service.begin(
10001L, 9L, 10L, "MEASURE_APPROVE", "key-1", "10|20|3|");
assertThat(context.replayed()).isTrue();
assertThat(context)
.extracting(InboundIdempotencyService.Context::tenantId,
InboundIdempotencyService.Context::id,
InboundIdempotencyService.Context::replayed)
.containsExactly(10001L, 1L, true);
}
@Test
@@ -59,11 +63,12 @@ class InboundIdempotencyServiceTest {
@Test
@DisplayName("新请求完成时只允许PROCESSING条件更新一次")
void shouldCompleteProcessingRecordOnce() {
when(repository.completeIdempotency(1L, "{\"inboundId\":\"10\"}")).thenReturn(1);
when(repository.completeIdempotency(10001L, 1L, "{\"inboundId\":\"10\"}"))
.thenReturn(1);
service.complete(new InboundIdempotencyService.Context(1L, false), 10L);
service.complete(new InboundIdempotencyService.Context(10001L, 1L, false), 10L);
verify(repository).completeIdempotency(1L, "{\"inboundId\":\"10\"}");
verify(repository).completeIdempotency(10001L, 1L, "{\"inboundId\":\"10\"}");
}
private InboundIdempotencyEntity existing(String status) {
@@ -69,6 +69,23 @@ class InboundMeasurementOpenApiServiceTest {
@Resource private ObjectMapper objectMapper;
@Resource private InboundMeasurementOpenApiService service;
@Test
@DisplayName("第三方客户A不能访问只存在于客户B的入库单")
void shouldRejectInboundOwnedByAnotherCustomer() {
Fixture fixture = fixture();
when(fixture.repository.findInbound(10002L, "RK202608010001")).thenReturn(inbound());
assertThatThrownBy(() -> fixture.service.receive(headers("nonce-cross"), BODY, image()))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("入仓码不存在或不属于绑定仓库");
verify(fixture.repository).findInbound(10001L, "RK202608010001");
verify(fixture.repository, never()).findInbound(10002L, "RK202608010001");
verify(fixture.images, never()).upload(anyString(),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyLong(), any());
}
@Test
@DisplayName("回调只写批次明细和幂等结果,不存在入库主单update入口")
void shouldPersistMeasurementsWithoutUpdatingInboundOrder() {
@@ -23,6 +23,7 @@ import java.util.List;
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.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -35,6 +36,17 @@ class InboundMeasurementServiceTest {
@MockBean private MeasurementBatchService batchService;
@Resource private InboundMeasurementService service;
@Test
void customerCannotReadAnotherCustomersMeasurement() {
assertThatThrownBy(() -> service.detail(2L, 10L))
.isInstanceOf(BizException.class)
.hasMessageContaining("入库单不存在");
verify(repository).findInbound(2L, 10L);
verify(repository, never()).findInbound(1L, 10L);
verify(repository, never()).listWeights(anyLong(), anyLong());
}
@Test
void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() {
InboundOrderEntity inbound = inbound("MEASURING");
@@ -30,7 +30,7 @@ import static org.mockito.Mockito.when;
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class MeasurementLocationOccupationServiceTest {
private static final long BUSINESS_TENANT = 10001L;
private static final long RESOURCE_TENANT = 90001L;
private static final long PLATFORM_RESOURCE_TENANT = 0L;
private static final long WAREHOUSE = 200L;
private static final long INBOUND = 88L;
private static final long OPERATOR = 9L;
@@ -43,8 +43,8 @@ class MeasurementLocationOccupationServiceTest {
@Resource private MeasurementLocationOccupationService service;
@Test
void sharedWarehouseResourcesUseResourceTenantWhileSnapshotsRemainInBusinessTenant() {
properties.setWarehouseResourceTenantId(RESOURCE_TENANT);
void platformWarehouseResourcesUseZeroWhileSnapshotsRemainInBusinessTenant() {
properties.setWarehouseResourceTenantId(90001L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
@@ -56,27 +56,27 @@ class MeasurementLocationOccupationServiceTest {
ArgumentCaptor<WarehousePalletLocationClient.ReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class);
verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().tenantId()).isEqualTo(RESOURCE_TENANT);
assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
assertThat(reserve.getValue().businessId()).isEqualTo(INBOUND);
assertThat(reserve.getValue().idempotencyKey()).isEqualTo("measure-88-30-3");
ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class);
verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().tenantId()).isEqualTo(RESOURCE_TENANT);
assertThat(confirm.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations, times(2)).replace(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")),
org.mockito.ArgumentMatchers.any());
}
@Test
void zeroResourceTenantFallsBackToBusinessTenant() {
void defaultResourceTenantRemainsPlatformZero() {
properties.setWarehouseResourceTenantId(0L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
@@ -89,17 +89,25 @@ class MeasurementLocationOccupationServiceTest {
ArgumentCaptor<WarehousePalletLocationClient.ReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class);
verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().tenantId()).isEqualTo(BUSINESS_TENANT);
assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class);
verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().tenantId()).isEqualTo(BUSINESS_TENANT);
assertThat(confirm.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
verify(allocations, times(2)).replace(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")),
org.mockito.ArgumentMatchers.any());
}
@Test
void confirmFailureReleasesReservationInResourceTenantAndKeepsOriginalFailure() {
properties.setWarehouseResourceTenantId(RESOURCE_TENANT);
properties.setWarehouseResourceTenantId(90001L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
@@ -115,9 +123,9 @@ class MeasurementLocationOccupationServiceTest {
ArgumentCaptor<WarehousePalletLocationClient.ReleaseRequest> release =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReleaseRequest.class);
verify(warehouse).release(org.mockito.ArgumentMatchers.eq("internal-token"), release.capture());
assertThat(release.getValue().tenantId()).isEqualTo(RESOURCE_TENANT);
assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
assertThat(release.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, RESOURCE_TENANT,
verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT,
new BigDecimal("1.200000"), batch("RESERVED"));
}
@@ -40,6 +40,23 @@ class MeasurementReviewServiceTest {
@MockBean private InboundIdempotencyService idempotency;
@MockBean private InboundProperties properties;
@Resource private MeasurementReviewService service;
@Test
@DisplayName("客户B不能审核客户A的测量")
void shouldRejectCrossCustomerReview() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(10002L, 1L, false));
assertThatThrownBy(() -> service.approve(10002L, 9L, "审核员", 10L, "cross-customer",
new MeasurementReviewRequest(20L, 3, null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
verify(repository).findOwned(10002L, 10L);
verify(repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean());
}
@Test
@DisplayName("审核通过是测量到计费的唯一正常推进关口")
void shouldApproveOnlyPendingSubmittedMeasurement() {
@@ -73,7 +90,7 @@ class MeasurementReviewServiceTest {
void shouldReplaySameReviewRequest() {
Fixture fixture = fixture(false);
when(fixture.idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(1L, true));
.thenReturn(new InboundIdempotencyService.Context(10001L, 1L, true));
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-1",
new MeasurementReviewRequest(20L, 3, null));
@@ -147,7 +164,7 @@ class MeasurementReviewServiceTest {
private Fixture fixture(boolean allowRelease) {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(1L, false));
.thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false));
when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease);
InboundOrderEntity order = new InboundOrderEntity();
order.setProcessStatus("MEASURE_PENDING");