This commit is contained in:
2026-08-17 09:18:03 +08:00
parent 8b768125c6
commit d6fe79b0f2
128 changed files with 7053 additions and 76 deletions
+31
View File
@@ -0,0 +1,31 @@
# Warehouse Service 模块指引
## 职责与边界
- 负责海外仓库、地址、服务能力、库位、托盘及业务资源锁定/释放。
- 尾程入仓计划通过内部网关请求资源;本模块不读取尾程订单库或头程数据库。
## 快速索引
| 功能 | 入口 |
|---|---|
| 平台仓库管理 | `src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java` |
| 商户仓库查询 | `controller/MerchantWarehouseController.java` |
| 平台/商户库位与托盘 | `controller/PlatformWarehouseResourceController.java`、`MerchantWarehouseResourceController.java` |
| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` |
| 仓库与资源服务 | `service/WarehouseApplicationService.java`、`WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 数据库 | `src/main/resources/db/warehouse.sql` |
| 测试 | `src/test/java/com/baiyue/warehouse/` |
## 数据与安全规则
- 平台和商户接口必须分别执行访问域与租户范围校验;客户端传入的 `tenantId` 不能作为唯一授权依据。
- 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。
- 内部资源接口必须校验服务令牌;普通用户不得直接调用。
## 修改与验证
- 字段、注释和索引同步维护基线 SQL 与统一增量 SQL。
- 定向验证:`mvn -pl warehouse -am test`。
@@ -0,0 +1,27 @@
package com.baiyue.warehouse.config;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Prevents the warehouse schema migration from ever running against another database. */
@Configuration(proxyBeanMethods = false)
public class WarehouseDatabaseGuardConfiguration {
private static final Pattern MYSQL_SCHEMA = Pattern.compile(
"^jdbc:mysql://[^/]+/([^?;]+)(?:[?;].*)?$", Pattern.CASE_INSENSITIVE);
@Bean
static BeanFactoryPostProcessor warehouseDatabaseGuard(Environment environment) {
String url = environment.getProperty("spring.datasource.url", "").trim();
Matcher matcher = MYSQL_SCHEMA.matcher(url);
if (!matcher.matches() || !"baiyue_lastlag_warehouse".equalsIgnoreCase(matcher.group(1))) {
throw new IllegalStateException(
"warehouse-service requires spring.datasource.url to target the baiyue_lastlag_warehouse database");
}
return beanFactory -> { };
}
}
@@ -0,0 +1,48 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
import com.baiyue.warehouse.service.WarehouseResourceLockService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/internal/v1/warehouse-resource-locks")
public class InternalWarehouseResourceLockController {
@Resource private WarehouseResourceLockService service;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping
public ApiResponse<WarehouseResourceLockResponse> reserve(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody WarehouseResourceLockRequests.ReserveRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(service.reserve(request));
}
@PostMapping("/{lockId}/release")
public ApiResponse<WarehouseResourceLockResponse> release(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long lockId,
@Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(service.release(request.tenantId(), lockId));
}
@PostMapping("/{lockId}/scrap")
public ApiResponse<WarehouseResourceLockResponse> scrap(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long lockId,
@Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(service.scrap(request.tenantId(), lockId));
}
}
@@ -0,0 +1,109 @@
package com.baiyue.warehouse.controller;
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.warehouse.dto.LocationRequests;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.service.WarehouseResourceService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping("/api/merchant/v1/warehouses/{warehouseId}")
public class MerchantWarehouseResourceController {
@Resource private WarehouseResourceService service;
@PostMapping("/locations")
public ApiResponse<Long> createLocation(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@Valid @RequestBody LocationRequests.SaveRequest request) {
return ApiResponse.success(service.createLocation(tenant(user), warehouseId, request));
}
@PutMapping("/locations/{id}")
public ApiResponse<Void> updateLocation(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id,
@Valid @RequestBody LocationRequests.SaveRequest request) {
service.updateLocation(tenant(user), warehouseId, id, request);
return ApiResponse.success(null);
}
@GetMapping("/locations")
public ApiResponse<PageResult<WarehouseLocationEntity>> locations(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId,
@Valid LocationRequests.QueryRequest query) {
return ApiResponse.success(service.locations(tenant(user), warehouseId, query));
}
@GetMapping("/locations/{id}")
public ApiResponse<WarehouseLocationEntity> location(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@PathVariable long id) {
return ApiResponse.success(service.location(tenant(user), warehouseId, id));
}
@DeleteMapping("/locations/{id}")
public ApiResponse<Void> deleteLocation(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id) {
service.deleteLocation(tenant(user), warehouseId, id);
return ApiResponse.success(null);
}
@PostMapping("/pallets")
public ApiResponse<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@Valid @RequestBody PalletRequests.SaveRequest request) {
return ApiResponse.success(service.createPallet(tenant(user), warehouseId, request));
}
@PutMapping("/pallets/{id}")
public ApiResponse<Void> updatePallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id,
@Valid @RequestBody PalletRequests.SaveRequest request) {
service.updatePallet(tenant(user), warehouseId, id, request);
return ApiResponse.success(null);
}
@GetMapping("/pallets")
public ApiResponse<PageResult<WarehousePalletEntity>> pallets(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId,
@Valid PalletRequests.QueryRequest query) {
return ApiResponse.success(service.pallets(tenant(user), warehouseId, query));
}
@GetMapping("/pallets/{id}")
public ApiResponse<WarehousePalletEntity> pallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id) {
return ApiResponse.success(service.pallet(tenant(user), warehouseId, id));
}
@DeleteMapping("/pallets/{id}")
public ApiResponse<Void> deletePallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id) {
service.deletePallet(tenant(user), warehouseId, id);
return ApiResponse.success(null);
}
private static long tenant(AuthenticatedUser user) {
if (user == null || user.tenantId() == null || user.tenantId() <= 0) {
throw new BizException(ResultCode.UNAUTHORIZED, "认证租户缺失");
}
return user.tenantId();
}
}
@@ -0,0 +1,131 @@
package com.baiyue.warehouse.controller;
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.warehouse.dto.LocationRequests;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.service.WarehouseResourceService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.NotNull;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping("/api/platform/v1/warehouse-admin/{warehouseId}")
public class PlatformWarehouseResourceController {
@Resource private WarehouseResourceService service;
@PostMapping("/locations")
public ApiResponse<Long> createLocation(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long tenantId,
@PathVariable long warehouseId,
@Valid @RequestBody LocationRequests.SaveRequest request) {
requirePlatform(user, tenantId);
return ApiResponse.success(service.createLocation(tenantId, warehouseId, request));
}
@PutMapping("/locations/{id}")
public ApiResponse<Void> updateLocation(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long tenantId,
@PathVariable long warehouseId, @PathVariable long id,
@Valid @RequestBody LocationRequests.SaveRequest request) {
requirePlatform(user, tenantId);
service.updateLocation(tenantId, warehouseId, id, request);
return ApiResponse.success(null);
}
@GetMapping("/locations")
public ApiResponse<PageResult<WarehouseLocationEntity>> locations(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long tenantId,
@PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) {
requirePlatform(user, tenantId);
return ApiResponse.success(service.locations(tenantId, warehouseId, query));
}
@GetMapping("/locations/{id}")
public ApiResponse<WarehouseLocationEntity> location(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long tenantId,
@PathVariable long warehouseId,
@PathVariable long id) {
requirePlatform(user, tenantId);
return ApiResponse.success(service.location(tenantId, warehouseId, id));
}
@DeleteMapping("/locations/{id}")
public ApiResponse<Void> deleteLocation(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long tenantId,
@PathVariable long warehouseId, @PathVariable long id) {
requirePlatform(user, tenantId);
service.deleteLocation(tenantId, warehouseId, id);
return ApiResponse.success(null);
}
@PostMapping("/pallets")
public ApiResponse<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long tenantId,
@PathVariable long warehouseId,
@Valid @RequestBody PalletRequests.SaveRequest request) {
requirePlatform(user, tenantId);
return ApiResponse.success(service.createPallet(tenantId, warehouseId, request));
}
@PutMapping("/pallets/{id}")
public ApiResponse<Void> updatePallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long tenantId,
@PathVariable long warehouseId, @PathVariable long id,
@Valid @RequestBody PalletRequests.SaveRequest request) {
requirePlatform(user, tenantId);
service.updatePallet(tenantId, warehouseId, id, request);
return ApiResponse.success(null);
}
@GetMapping("/pallets")
public ApiResponse<PageResult<WarehousePalletEntity>> pallets(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long tenantId,
@PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) {
requirePlatform(user, tenantId);
return ApiResponse.success(service.pallets(tenantId, warehouseId, query));
}
@GetMapping("/pallets/{id}")
public ApiResponse<WarehousePalletEntity> pallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long tenantId,
@PathVariable long warehouseId, @PathVariable long id) {
requirePlatform(user, tenantId);
return ApiResponse.success(service.pallet(tenantId, warehouseId, id));
}
@DeleteMapping("/pallets/{id}")
public ApiResponse<Void> deletePallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long tenantId,
@PathVariable long warehouseId, @PathVariable long id) {
requirePlatform(user, tenantId);
service.deletePallet(tenantId, warehouseId, id);
return ApiResponse.success(null);
}
private static void requirePlatform(AuthenticatedUser user, Long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId == null || tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
}
}
@@ -0,0 +1,39 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
public final class LocationRequests {
private LocationRequests() {
}
public record SaveRequest(
@NotBlank @Size(max = 50) String zoneCode,
@NotBlank @Size(max = 64) String locationCode,
@NotBlank @Size(max = 32) String locationType,
@NotNull @DecimalMin(value = "0.000", inclusive = false) BigDecimal maxLoadKg,
@NotBlank String status,
@Size(max = 500) String remark
) {
}
public record QueryRequest(
@Min(1) long page,
@Min(1) @Max(200) long size,
String zoneCode,
String locationType,
String status,
String keyword
) {
public QueryRequest {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
}
@@ -0,0 +1,45 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
public final class PalletRequests {
private static final BigDecimal DEFAULT_CAPACITY = new BigDecimal("2.0000");
private PalletRequests() {
}
public record SaveRequest(
@NotBlank @Size(max = 64) String palletCode,
@NotBlank @Size(max = 32) String palletType,
@DecimalMin(value = "0.0000", inclusive = false) BigDecimal capacityCbm,
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@NotBlank String status,
Long locationId,
@Size(max = 500) String remark
) {
public SaveRequest {
if (capacityCbm == null) capacityCbm = DEFAULT_CAPACITY;
}
}
public record QueryRequest(
@Min(1) long page,
@Min(1) @Max(200) long size,
String palletType,
String status,
Long locationId,
String keyword
) {
public QueryRequest {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
}
@@ -0,0 +1,20 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
public final class WarehouseResourceLockRequests {
private WarehouseResourceLockRequests() {}
public record ReserveRequest(
@Positive long tenantId,
@Positive long warehouseId,
@Positive long palletId,
@Positive long locationId,
@NotBlank String businessType,
@NotBlank String businessId,
@NotBlank String reservationKey
) {}
public record CloseRequest(@Positive long tenantId) {}
}
@@ -0,0 +1,25 @@
package com.baiyue.warehouse.dto;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import java.time.LocalDateTime;
public record WarehouseResourceLockResponse(
long lockId,
long tenantId,
long warehouseId,
long palletId,
long locationId,
String businessType,
String businessId,
String reservationKey,
String status,
LocalDateTime lockedAt,
LocalDateTime releasedAt
) {
public static WarehouseResourceLockResponse from(WarehouseResourceLockEntity entity) {
return new WarehouseResourceLockResponse(entity.getId(), entity.getTenantId(), entity.getWarehouseId(),
entity.getPalletId(), entity.getLocationId(), entity.getBusinessType(), entity.getBusinessId(),
entity.getReservationKey(), entity.getStatus(), entity.getLockedAt(), entity.getReleasedAt());
}
}
@@ -0,0 +1,26 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@TableName("wh_location")
public class WarehouseLocationEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long warehouseId;
private String zoneCode;
private String locationCode;
private String locationType;
private BigDecimal maxLoadKg;
private String status;
private String remark;
}
@@ -0,0 +1,27 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@TableName("wh_pallet")
public class WarehousePalletEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long warehouseId;
private Long locationId;
private String palletCode;
private String palletType;
private BigDecimal capacityCbm;
private BigDecimal unitPrice;
private String status;
private String remark;
}
@@ -0,0 +1,29 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("wh_resource_lock")
public class WarehouseResourceLockEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long warehouseId;
private Long palletId;
private Long locationId;
private String businessType;
private String businessId;
private String reservationKey;
private String status;
private LocalDateTime lockedAt;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,7 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
public interface WarehouseLocationMapper extends BaseMapper<WarehouseLocationEntity> {
}
@@ -0,0 +1,7 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
public interface WarehousePalletMapper extends BaseMapper<WarehousePalletEntity> {
}
@@ -0,0 +1,69 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourceLockEntity> {
@Select("""
SELECT * FROM wh_resource_lock
WHERE tenant_id = #{tenantId} AND reservation_key = #{reservationKey}
LIMIT 1
""")
WarehouseResourceLockEntity findByReservationKey(@Param("tenantId") long tenantId,
@Param("reservationKey") String reservationKey);
@Update("""
UPDATE wh_pallet
SET status = 'OCCUPIED', location_id = #{locationId}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{palletId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND status = 'IDLE' AND deleted = 0
""")
int occupyPallet(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId,
@Param("palletId") long palletId, @Param("locationId") long locationId);
@Update("""
UPDATE wh_location
SET status = 'OCCUPIED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{locationId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND status = 'IDLE' AND deleted = 0
""")
int occupyLocation(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId,
@Param("locationId") long locationId);
@Update("""
UPDATE wh_pallet
SET status = 'IDLE', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{palletId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0
""")
int releasePallet(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId,
@Param("palletId") long palletId, @Param("locationId") long locationId);
@Update("""
UPDATE wh_location
SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{locationId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND status = 'OCCUPIED' AND deleted = 0
""")
int releaseLocation(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId,
@Param("locationId") long locationId);
@Update("""
UPDATE wh_pallet
SET status = 'SCRAPPED', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{palletId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0
""")
int scrapPallet(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId,
@Param("palletId") long palletId, @Param("locationId") long locationId);
@Update("""
UPDATE wh_resource_lock
SET status = #{status}, released_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND status = 'ACTIVE'
""")
int closeLock(@Param("id") long id, @Param("status") String status);
}
@@ -12,6 +12,7 @@ import com.baiyue.warehouse.mapper.WarehouseAddressMapper;
import com.baiyue.warehouse.mapper.WarehouseMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import jakarta.annotation.Resource;
import java.util.List;
@@ -20,17 +21,9 @@ import java.util.List;
*/
@Repository
public class WarehouseRepository {
private final WarehouseMapper warehouses;
private final WarehouseAddressMapper addresses;
private final ServiceCapabilityMapper capabilities;
public WarehouseRepository(WarehouseMapper warehouses,
WarehouseAddressMapper addresses,
ServiceCapabilityMapper capabilities) {
this.warehouses = warehouses;
this.addresses = addresses;
this.capabilities = capabilities;
}
@Resource private WarehouseMapper warehouses;
@Resource private WarehouseAddressMapper addresses;
@Resource private ServiceCapabilityMapper capabilities;
public void insertWarehouse(WarehouseEntity warehouse) {
warehouses.insert(warehouse);
@@ -0,0 +1,66 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import com.baiyue.warehouse.mapper.WarehouseLocationMapper;
import com.baiyue.warehouse.mapper.WarehousePalletMapper;
import com.baiyue.warehouse.mapper.WarehouseResourceLockMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
@Repository
public class WarehouseResourceLockRepository {
@Resource private WarehouseResourceLockMapper locks;
@Resource private WarehousePalletMapper pallets;
@Resource private WarehouseLocationMapper locations;
public WarehouseResourceLockEntity findByReservationKey(long tenantId, String reservationKey) {
return locks.findByReservationKey(tenantId, reservationKey);
}
public WarehouseResourceLockEntity find(long tenantId, long lockId) {
return locks.selectOne(new LambdaQueryWrapper<WarehouseResourceLockEntity>()
.eq(WarehouseResourceLockEntity::getTenantId, tenantId)
.eq(WarehouseResourceLockEntity::getId, lockId));
}
public WarehousePalletEntity findPallet(long tenantId, long warehouseId, long palletId) {
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getTenantId, tenantId)
.eq(WarehousePalletEntity::getWarehouseId, warehouseId)
.eq(WarehousePalletEntity::getId, palletId));
}
public WarehouseLocationEntity findLocation(long tenantId, long warehouseId, long locationId) {
return locations.selectOne(new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getTenantId, tenantId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(WarehouseLocationEntity::getId, locationId));
}
public int occupyPallet(long tenantId, long warehouseId, long palletId, long locationId) {
return locks.occupyPallet(tenantId, warehouseId, palletId, locationId);
}
public int occupyLocation(long tenantId, long warehouseId, long locationId) {
return locks.occupyLocation(tenantId, warehouseId, locationId);
}
public void insert(WarehouseResourceLockEntity lock) { locks.insert(lock); }
public int releasePallet(WarehouseResourceLockEntity lock) {
return locks.releasePallet(lock.getTenantId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId());
}
public int scrapPallet(WarehouseResourceLockEntity lock) {
return locks.scrapPallet(lock.getTenantId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId());
}
public int releaseLocation(WarehouseResourceLockEntity lock) {
return locks.releaseLocation(lock.getTenantId(), lock.getWarehouseId(), lock.getLocationId());
}
public int close(WarehouseResourceLockEntity lock, String status) { return locks.closeLock(lock.getId(), status); }
}
@@ -0,0 +1,119 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.warehouse.dto.LocationRequests;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.mapper.WarehouseLocationMapper;
import com.baiyue.warehouse.mapper.WarehouseMapper;
import com.baiyue.warehouse.mapper.WarehousePalletMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import jakarta.annotation.Resource;
@Repository
public class WarehouseResourceRepository {
@Resource private WarehouseMapper warehouses;
@Resource private WarehouseLocationMapper locations;
@Resource private WarehousePalletMapper pallets;
public boolean warehouseOwned(long tenantId, long warehouseId) {
return warehouses.selectCount(new LambdaQueryWrapper<WarehouseEntity>()
.eq(WarehouseEntity::getTenantId, tenantId)
.eq(WarehouseEntity::getId, warehouseId)) > 0;
}
public void insertLocation(WarehouseLocationEntity entity) {
locations.insert(entity);
}
public void updateLocation(WarehouseLocationEntity entity) {
locations.updateById(entity);
}
public WarehouseLocationEntity findLocation(long tenantId, long warehouseId, long id) {
return locations.selectOne(new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getTenantId, tenantId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(WarehouseLocationEntity::getId, id));
}
public boolean locationCodeExists(long tenantId, long warehouseId, String code, Long excludedId) {
return locations.selectCount(new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getTenantId, tenantId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(WarehouseLocationEntity::getLocationCode, code)
.ne(excludedId != null, WarehouseLocationEntity::getId, excludedId)) > 0;
}
public PageResult<WarehouseLocationEntity> pageLocations(long tenantId, long warehouseId,
LocationRequests.QueryRequest query) {
var wrapper = new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getTenantId, tenantId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(StringUtils.hasText(query.zoneCode()), WarehouseLocationEntity::getZoneCode, query.zoneCode())
.eq(StringUtils.hasText(query.locationType()), WarehouseLocationEntity::getLocationType, query.locationType())
.eq(StringUtils.hasText(query.status()), WarehouseLocationEntity::getStatus, query.status())
.and(StringUtils.hasText(query.keyword()), q -> q
.like(WarehouseLocationEntity::getLocationCode, query.keyword())
.or().like(WarehouseLocationEntity::getZoneCode, query.keyword()))
.orderByAsc(WarehouseLocationEntity::getZoneCode)
.orderByAsc(WarehouseLocationEntity::getLocationCode);
return PageResult.from(locations.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public void deleteLocation(long id) {
locations.deleteById(id);
}
public boolean locationHasPallets(long tenantId, long warehouseId, long locationId) {
return pallets.selectCount(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getTenantId, tenantId)
.eq(WarehousePalletEntity::getWarehouseId, warehouseId)
.eq(WarehousePalletEntity::getLocationId, locationId)) > 0;
}
public void insertPallet(WarehousePalletEntity entity) {
pallets.insert(entity);
}
public void updatePallet(WarehousePalletEntity entity) {
pallets.updateById(entity);
}
public WarehousePalletEntity findPallet(long tenantId, long warehouseId, long id) {
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getTenantId, tenantId)
.eq(WarehousePalletEntity::getWarehouseId, warehouseId)
.eq(WarehousePalletEntity::getId, id));
}
public boolean palletCodeExists(long tenantId, long warehouseId, String code, Long excludedId) {
return pallets.selectCount(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getTenantId, tenantId)
.eq(WarehousePalletEntity::getWarehouseId, warehouseId)
.eq(WarehousePalletEntity::getPalletCode, code)
.ne(excludedId != null, WarehousePalletEntity::getId, excludedId)) > 0;
}
public PageResult<WarehousePalletEntity> pagePallets(long tenantId, long warehouseId,
PalletRequests.QueryRequest query) {
var wrapper = new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getTenantId, tenantId)
.eq(WarehousePalletEntity::getWarehouseId, warehouseId)
.eq(StringUtils.hasText(query.palletType()), WarehousePalletEntity::getPalletType, query.palletType())
.eq(StringUtils.hasText(query.status()), WarehousePalletEntity::getStatus, query.status())
.eq(query.locationId() != null, WarehousePalletEntity::getLocationId, query.locationId())
.like(StringUtils.hasText(query.keyword()), WarehousePalletEntity::getPalletCode, query.keyword())
.orderByDesc(WarehousePalletEntity::getCreatedAt);
return PageResult.from(pallets.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public void deletePallet(long id) {
pallets.deleteById(id);
}
}
@@ -0,0 +1,26 @@
package com.baiyue.warehouse.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@Component
public class InternalServiceTokenGuard {
private final byte[] expected;
public InternalServiceTokenGuard(
@Value("${baiyue.internal-service.token:phase1-local-internal-token-at-least-32-bytes}") String token) {
this.expected = token.getBytes(StandardCharsets.UTF_8);
}
public void verify(String provided) {
byte[] actual = provided == null ? new byte[0] : provided.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expected, actual)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid internal service token");
}
}
}
@@ -0,0 +1,121 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import com.baiyue.warehouse.repository.WarehouseResourceLockRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Objects;
@Service
public class WarehouseResourceLockService {
@Resource private WarehouseResourceLockRepository repository;
@Transactional(isolation = Isolation.READ_COMMITTED)
public WarehouseResourceLockResponse reserve(WarehouseResourceLockRequests.ReserveRequest request) {
WarehouseResourceLockEntity existing = repository.findByReservationKey(request.tenantId(), request.reservationKey());
if (existing != null) return idempotentResult(existing, request);
WarehousePalletEntity pallet = repository.findPallet(request.tenantId(), request.warehouseId(), request.palletId());
WarehouseLocationEntity location = repository.findLocation(request.tenantId(), request.warehouseId(), request.locationId());
if (pallet == null || location == null) {
WarehouseResourceLockEntity concurrent =
repository.findByReservationKey(request.tenantId(), request.reservationKey());
if (concurrent != null) return idempotentResult(concurrent, request);
throw new BizException(ResultCode.NOT_FOUND, "Pallet or location does not belong to the tenant warehouse");
}
if (!"IDLE".equals(pallet.getStatus()) || !"IDLE".equals(location.getStatus())) {
WarehouseResourceLockEntity concurrent =
repository.findByReservationKey(request.tenantId(), request.reservationKey());
if (concurrent != null) return idempotentResult(concurrent, request);
throw new BizException(ResultCode.CONFLICT, "Only IDLE pallet and location can be reserved");
}
if (repository.occupyPallet(request.tenantId(), request.warehouseId(), request.palletId(), request.locationId()) != 1) {
return concurrentIdempotentOrConflict(request);
}
if (repository.occupyLocation(request.tenantId(), request.warehouseId(), request.locationId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "Location is no longer IDLE");
}
WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity();
lock.setTenantId(request.tenantId());
lock.setWarehouseId(request.warehouseId());
lock.setPalletId(request.palletId());
lock.setLocationId(request.locationId());
lock.setBusinessType(request.businessType());
lock.setBusinessId(request.businessId());
lock.setReservationKey(request.reservationKey());
lock.setStatus("ACTIVE");
lock.setLockedAt(LocalDateTime.now());
repository.insert(lock);
return WarehouseResourceLockResponse.from(lock);
}
@Transactional
public WarehouseResourceLockResponse release(long tenantId, long lockId) {
WarehouseResourceLockEntity lock = requireLock(tenantId, lockId);
if ("RELEASED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock);
requireActive(lock);
if (repository.releasePallet(lock) != 1 || repository.releaseLocation(lock) != 1
|| repository.close(lock, "RELEASED") != 1) {
throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently");
}
lock.setStatus("RELEASED");
lock.setReleasedAt(LocalDateTime.now());
return WarehouseResourceLockResponse.from(lock);
}
@Transactional
public WarehouseResourceLockResponse scrap(long tenantId, long lockId) {
WarehouseResourceLockEntity lock = requireLock(tenantId, lockId);
if ("SCRAPPED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock);
requireActive(lock);
if (repository.scrapPallet(lock) != 1 || repository.releaseLocation(lock) != 1
|| repository.close(lock, "SCRAPPED") != 1) {
throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently");
}
lock.setStatus("SCRAPPED");
lock.setReleasedAt(LocalDateTime.now());
return WarehouseResourceLockResponse.from(lock);
}
private WarehouseResourceLockResponse concurrentIdempotentOrConflict(
WarehouseResourceLockRequests.ReserveRequest request) {
WarehouseResourceLockEntity existing = repository.findByReservationKey(request.tenantId(), request.reservationKey());
if (existing != null) return idempotentResult(existing, request);
throw new BizException(ResultCode.CONFLICT, "Pallet is no longer IDLE");
}
private WarehouseResourceLockResponse idempotentResult(WarehouseResourceLockEntity existing,
WarehouseResourceLockRequests.ReserveRequest request) {
if (existing.getWarehouseId() != request.warehouseId()
|| existing.getPalletId() != request.palletId()
|| existing.getLocationId() != request.locationId()
|| !Objects.equals(existing.getBusinessType(), request.businessType())
|| !Objects.equals(existing.getBusinessId(), request.businessId())) {
throw new BizException(ResultCode.CONFLICT, "Reservation key was already used with different resources");
}
return WarehouseResourceLockResponse.from(existing);
}
private WarehouseResourceLockEntity requireLock(long tenantId, long lockId) {
WarehouseResourceLockEntity lock = repository.find(tenantId, lockId);
if (lock == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock not found");
return lock;
}
private static void requireActive(WarehouseResourceLockEntity lock) {
if (!"ACTIVE".equals(lock.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Resource lock is not ACTIVE");
}
}
}
@@ -0,0 +1,171 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationRequests;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.repository.WarehouseResourceRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.util.Set;
@Service
public class WarehouseResourceService {
private static final Set<String> LOCATION_STATUSES = Set.of("ACTIVE", "IDLE", "OCCUPIED", "INACTIVE");
private static final Set<String> PALLET_STATUSES = Set.of("IDLE", "OCCUPIED", "SCRAPPED");
@Resource private WarehouseResourceRepository repository;
@Transactional
public Long createLocation(long tenantId, long warehouseId, LocationRequests.SaveRequest request) {
requireWarehouse(tenantId, warehouseId);
validateLocation(request, tenantId, warehouseId, null);
WarehouseLocationEntity entity = locationEntity(tenantId, warehouseId, request);
repository.insertLocation(entity);
return entity.getId();
}
@Transactional
public void updateLocation(long tenantId, long warehouseId, long id, LocationRequests.SaveRequest request) {
WarehouseLocationEntity existing = requireLocation(tenantId, warehouseId, id);
validateLocation(request, tenantId, warehouseId, id);
WarehouseLocationEntity entity = locationEntity(tenantId, warehouseId, request);
entity.setId(existing.getId());
repository.updateLocation(entity);
}
public WarehouseLocationEntity location(long tenantId, long warehouseId, long id) {
requireWarehouse(tenantId, warehouseId);
return requireLocation(tenantId, warehouseId, id);
}
public PageResult<WarehouseLocationEntity> locations(long tenantId, long warehouseId,
LocationRequests.QueryRequest query) {
requireWarehouse(tenantId, warehouseId);
return repository.pageLocations(tenantId, warehouseId, query);
}
@Transactional
public void deleteLocation(long tenantId, long warehouseId, long id) {
requireLocation(tenantId, warehouseId, id);
if (repository.locationHasPallets(tenantId, warehouseId, id)) {
throw new BizException(ResultCode.CONFLICT, "库位仍绑定托盘,不能删除");
}
repository.deleteLocation(id);
}
@Transactional
public Long createPallet(long tenantId, long warehouseId, PalletRequests.SaveRequest request) {
requireWarehouse(tenantId, warehouseId);
validatePallet(request, tenantId, warehouseId, null);
WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request);
repository.insertPallet(entity);
return entity.getId();
}
@Transactional
public void updatePallet(long tenantId, long warehouseId, long id, PalletRequests.SaveRequest request) {
WarehousePalletEntity existing = requirePallet(tenantId, warehouseId, id);
validatePallet(request, tenantId, warehouseId, id);
WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request);
entity.setId(existing.getId());
repository.updatePallet(entity);
}
public WarehousePalletEntity pallet(long tenantId, long warehouseId, long id) {
requireWarehouse(tenantId, warehouseId);
return requirePallet(tenantId, warehouseId, id);
}
public PageResult<WarehousePalletEntity> pallets(long tenantId, long warehouseId,
PalletRequests.QueryRequest query) {
requireWarehouse(tenantId, warehouseId);
return repository.pagePallets(tenantId, warehouseId, query);
}
@Transactional
public void deletePallet(long tenantId, long warehouseId, long id) {
WarehousePalletEntity pallet = requirePallet(tenantId, warehouseId, id);
if ("OCCUPIED".equals(pallet.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "占用中的托盘不能删除");
}
repository.deletePallet(id);
}
private void requireWarehouse(long tenantId, long warehouseId) {
if (!repository.warehouseOwned(tenantId, warehouseId)) {
throw new BizException(ResultCode.NOT_FOUND, "当前租户下仓库不存在");
}
}
private WarehouseLocationEntity requireLocation(long tenantId, long warehouseId, long id) {
requireWarehouse(tenantId, warehouseId);
WarehouseLocationEntity entity = repository.findLocation(tenantId, warehouseId, id);
if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下库位不存在");
return entity;
}
private WarehousePalletEntity requirePallet(long tenantId, long warehouseId, long id) {
requireWarehouse(tenantId, warehouseId);
WarehousePalletEntity entity = repository.findPallet(tenantId, warehouseId, id);
if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下托盘不存在");
return entity;
}
private void validateLocation(LocationRequests.SaveRequest request, long tenantId,
long warehouseId, Long excludedId) {
if (!LOCATION_STATUSES.contains(request.status())) {
throw new BizException("库位状态仅支持 ACTIVE、IDLE、OCCUPIED、INACTIVE");
}
if (repository.locationCodeExists(tenantId, warehouseId, request.locationCode(), excludedId)) {
throw new BizException(ResultCode.CONFLICT, "当前仓库下库位编码已存在");
}
}
private void validatePallet(PalletRequests.SaveRequest request, long tenantId,
long warehouseId, Long excludedId) {
if (!PALLET_STATUSES.contains(request.status())) {
throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED");
}
if (repository.palletCodeExists(tenantId, warehouseId, request.palletCode(), excludedId)) {
throw new BizException(ResultCode.CONFLICT, "当前仓库下托盘编码已存在");
}
if (request.locationId() != null) {
requireLocation(tenantId, warehouseId, request.locationId());
}
}
private WarehouseLocationEntity locationEntity(long tenantId, long warehouseId,
LocationRequests.SaveRequest request) {
WarehouseLocationEntity entity = new WarehouseLocationEntity();
entity.setTenantId(tenantId);
entity.setWarehouseId(warehouseId);
entity.setZoneCode(request.zoneCode());
entity.setLocationCode(request.locationCode());
entity.setLocationType(request.locationType());
entity.setMaxLoadKg(request.maxLoadKg());
entity.setStatus(request.status());
entity.setRemark(request.remark());
return entity;
}
private WarehousePalletEntity palletEntity(long tenantId, long warehouseId,
PalletRequests.SaveRequest request) {
WarehousePalletEntity entity = new WarehousePalletEntity();
entity.setTenantId(tenantId);
entity.setWarehouseId(warehouseId);
entity.setLocationId(request.locationId());
entity.setPalletCode(request.palletCode());
entity.setPalletType(request.palletType());
entity.setCapacityCbm(request.capacityCbm());
entity.setUnitPrice(request.unitPrice());
entity.setStatus(request.status());
entity.setRemark(request.remark());
return entity;
}
}
+16 -1
View File
@@ -1,6 +1,11 @@
server:
port: ${WAREHOUSE_PORT:9305}
spring:
sql:
init:
mode: always
encoding: UTF-8
schema-locations: classpath:db/warehouse.sql
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
@@ -24,7 +29,17 @@ spring:
config.namespace: ${NACOS_NAMESPACE:public}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${WAREHOUSE_DB_USERNAME:root}
password: ${WAREHOUSE_DB_PASSWORD:}
management.endpoints.web.exposure.include: health,info,prometheus
baiyue:
internal-service:
token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
security:
jwt-mode: jwks
session-mode: claims
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
+167 -3
View File
@@ -1,6 +1,72 @@
CREATE DATABASE IF NOT EXISTS baiyue_warehouse
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_warehouse;
-- This script is run through warehouse-service's datasource only.
-- The datasource guard requires the selected schema to be baiyue_lastlag_warehouse;
-- database creation and USE are intentionally forbidden here so a miswired
-- service account cannot create or switch schemas during startup.
CREATE TABLE IF NOT EXISTS wh_location (
id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码',
location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码',
location_type VARCHAR(32) NOT NULL COMMENT '库位类型',
max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_location_code (tenant_id, warehouse_id, location_code, deleted),
KEY idx_location_query (tenant_id, warehouse_id, zone_code, status, deleted)
) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息';
CREATE TABLE IF NOT EXISTS wh_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
location_id BIGINT UNSIGNED DEFAULT NULL COMMENT '同仓库内绑定的库位ID,可为空',
pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码',
pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)',
unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_code (tenant_id, warehouse_id, pallet_code, deleted),
KEY idx_pallet_query (tenant_id, warehouse_id, status, deleted),
KEY idx_pallet_location (tenant_id, warehouse_id, location_id, deleted)
) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态';
CREATE TABLE IF NOT EXISTS wh_resource_lock (
id BIGINT UNSIGNED NOT NULL COMMENT 'Snowflake resource lock ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT 'Owning tenant ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT 'Owning warehouse ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT 'Reserved pallet ID',
location_id BIGINT UNSIGNED NOT NULL COMMENT 'Reserved location ID',
business_type VARCHAR(50) NOT NULL COMMENT 'Calling business type',
business_id VARCHAR(100) NOT NULL COMMENT 'Calling business identifier',
reservation_key VARCHAR(160) NOT NULL COMMENT 'Caller supplied idempotency key',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE, RELEASED or SCRAPPED',
locked_at DATETIME(3) NOT NULL COMMENT 'Reservation time',
released_at DATETIME(3) DEFAULT NULL COMMENT 'Release or scrap time',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_resource_lock_reservation (tenant_id, reservation_key),
KEY idx_resource_lock_pallet (tenant_id, warehouse_id, pallet_id, status),
KEY idx_resource_lock_location (tenant_id, warehouse_id, location_id, status),
KEY idx_resource_lock_business (tenant_id, business_type, business_id)
) ENGINE=InnoDB COMMENT='Atomic pallet and location reservation records';
CREATE TABLE IF NOT EXISTS wh_warehouse (
id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)',
@@ -73,3 +139,101 @@ CREATE TABLE IF NOT EXISTS wh_service_capability (
) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间';
-- 不使用跨服务数据库外键;仓库聚合内的归属由服务代码通过 tenant_id 与 warehouse_id 共同校验。
-- 修复既有数据库中因历史脚本编码错误或英文初始化产生的表与字段注释。
ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息';
ALTER TABLE wh_location
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)',
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码',
MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码',
MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型',
MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)',
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用',
MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注',
MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态';
ALTER TABLE wh_pallet
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)',
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空',
MODIFY COLUMN pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码',
MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)',
MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃',
MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注',
MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
ALTER TABLE wh_warehouse COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态';
ALTER TABLE wh_warehouse
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)',
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID',
MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码',
MODIFY COLUMN warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称',
MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型',
MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '所在国家二字码',
MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区',
MODIFY COLUMN contact_name VARCHAR(64) NULL COMMENT '仓库联系人',
MODIFY COLUMN contact_phone VARCHAR(32) NULL COMMENT '联系电话',
MODIFY COLUMN contact_email VARCHAR(128) NULL COMMENT '联系邮箱',
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态',
MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '仓库备注',
MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
ALTER TABLE wh_warehouse_address COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度';
ALTER TABLE wh_warehouse_address
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID',
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货',
MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '国家二字码',
MODIFY COLUMN state_province VARCHAR(100) NULL COMMENT '省或州',
MODIFY COLUMN city VARCHAR(100) NOT NULL COMMENT '城市',
MODIFY COLUMN district VARCHAR(100) NULL COMMENT '区县',
MODIFY COLUMN postal_code VARCHAR(20) NULL COMMENT '邮政编码',
MODIFY COLUMN address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行',
MODIFY COLUMN address_line2 VARCHAR(255) NULL COMMENT '补充地址行',
MODIFY COLUMN latitude DECIMAL(10,7) NULL COMMENT '纬度',
MODIFY COLUMN longitude DECIMAL(10,7) NULL COMMENT '经度',
MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
ALTER TABLE wh_service_capability COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间';
ALTER TABLE wh_service_capability
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID',
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING收货、STORAGE仓储、PICK_PACK拣包、RETURN退货',
MODIFY COLUMN capability_name VARCHAR(100) NOT NULL COMMENT '能力名称',
MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
MODIFY COLUMN cutoff_time TIME NULL COMMENT '当日作业截单时间',
MODIFY COLUMN description VARCHAR(500) NULL COMMENT '能力说明',
MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
@@ -0,0 +1,29 @@
package com.baiyue.warehouse.config;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class WarehouseDatabaseGuardConfigurationTest {
@Test
void acceptsDedicatedLastlagWarehouseDatabase() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.datasource.url",
"jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?serverTimezone=Asia/Shanghai");
assertThatCode(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment))
.doesNotThrowAnyException();
}
@Test
void rejectsAnyOtherDatabaseBeforeSchemaInitialization() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.datasource.url", "jdbc:mysql://127.0.0.1:3306/baiyue_warehouse");
assertThatThrownBy(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("baiyue_lastlag_warehouse");
}
}
@@ -0,0 +1,69 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.service.WarehouseResourceService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class WarehouseResourceControllerTest {
private final WarehouseResourceService service = mock(WarehouseResourceService.class);
@Test
void merchantTenantAlwaysComesFromAuthenticatedUser() {
MerchantWarehouseResourceController controller = merchantController();
AuthenticatedUser merchant = user(10001L, "MERCHANT");
WarehouseLocationEntity expected = new WarehouseLocationEntity();
when(service.location(10001L, 20001L, 30001L)).thenReturn(expected);
controller.location(merchant, 20001L, 30001L);
verify(service).location(10001L, 20001L, 30001L);
}
@Test
void platformApiRejectsNonPlatformIdentity() {
PlatformWarehouseResourceController controller = platformController();
assertThatThrownBy(() -> controller.location(user(10001L, "MERCHANT"),
10002L, 20001L, 30001L))
.isInstanceOf(BizException.class)
.hasMessageContaining("平台端");
}
@Test
void platformApiUsesExplicitTargetTenant() {
PlatformWarehouseResourceController controller = platformController();
WarehouseLocationEntity expected = new WarehouseLocationEntity();
when(service.location(10002L, 20001L, 30001L)).thenReturn(expected);
controller.location(user(1L, "PLATFORM"), 10002L, 20001L, 30001L);
verify(service).location(10002L, 20001L, 30001L);
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(1L, tenantId, tenantId, 1L,
"tester", appCode, "WEB", Set.of());
}
private MerchantWarehouseResourceController merchantController() {
MerchantWarehouseResourceController controller = new MerchantWarehouseResourceController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
private PlatformWarehouseResourceController platformController() {
PlatformWarehouseResourceController controller = new PlatformWarehouseResourceController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
}
@@ -0,0 +1,24 @@
package com.baiyue.warehouse.schema;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class WarehouseResourceLockSchemaTest {
@Test
void schemaContainsIdempotentResourceLockContract() throws IOException {
String sql;
try (var stream = getClass().getResourceAsStream("/db/warehouse.sql")) {
assertThat(stream).isNotNull();
sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_resource_lock");
assertThat(sql).contains("UNIQUE KEY uk_resource_lock_reservation (tenant_id, reservation_key)");
assertThat(sql).contains("KEY idx_resource_lock_pallet (tenant_id, warehouse_id, pallet_id, status)");
assertThat(sql).contains("KEY idx_resource_lock_location (tenant_id, warehouse_id, location_id, status)");
}
}
@@ -0,0 +1,32 @@
package com.baiyue.warehouse.security;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class InternalServiceTokenGuardTest {
private final InternalServiceTokenGuard guard =
new InternalServiceTokenGuard("phase1-local-internal-token-at-least-32-bytes");
@Test
void acceptsConfiguredToken() {
assertThatCode(() -> guard.verify("phase1-local-internal-token-at-least-32-bytes"))
.doesNotThrowAnyException();
}
@Test
void rejectsMissingOrWrongToken() {
assertUnauthorized(null);
assertUnauthorized("wrong");
}
private void assertUnauthorized(String token) {
assertThatThrownBy(() -> guard.verify(token))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED));
}
}
@@ -0,0 +1,194 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import com.baiyue.warehouse.repository.WarehouseResourceLockRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
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;
@ExtendWith(MockitoExtension.class)
class WarehouseResourceLockServiceTest {
private static final long TENANT = 101L;
private static final long WAREHOUSE = 201L;
private static final long PALLET = 301L;
private static final long LOCATION = 401L;
@Mock WarehouseResourceLockRepository repository;
private WarehouseResourceLockService service;
@BeforeEach
void setUp() {
service = new WarehouseResourceLockService();
ReflectionTestUtils.setField(service, "repository", repository);
}
@Test
void atomicallyOccupiesIdlePalletAndLocation() {
var request = request("reserve-1");
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE"));
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE"));
when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1);
when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(1);
doAnswer(call -> {
((WarehouseResourceLockEntity) call.getArgument(0)).setId(501L);
return null;
}).when(repository).insert(any());
var result = service.reserve(request);
assertThat(result.lockId()).isEqualTo(501L);
assertThat(result.status()).isEqualTo("ACTIVE");
ArgumentCaptor<WarehouseResourceLockEntity> lock = ArgumentCaptor.forClass(WarehouseResourceLockEntity.class);
verify(repository).insert(lock.capture());
assertThat(lock.getValue().getReservationKey()).isEqualTo("reserve-1");
assertThat(lock.getValue().getPalletId()).isEqualTo(PALLET);
assertThat(lock.getValue().getLocationId()).isEqualTo(LOCATION);
}
@Test
void duplicateReservationReturnsExistingLockWithoutMutatingResources() {
WarehouseResourceLockEntity existing = activeLock();
when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing);
var result = service.reserve(request("reserve-1"));
assertThat(result.lockId()).isEqualTo(existing.getId());
verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION);
verify(repository, never()).insert(any());
}
@Test
void concurrentDuplicateReturnsExistingAfterResourcesBecomeOccupied() {
WarehouseResourceLockEntity existing = activeLock();
when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(null, existing);
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED"));
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED"));
var result = service.reserve(request("reserve-1"));
assertThat(result.lockId()).isEqualTo(existing.getId());
verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION);
}
@Test
void reservationKeyCannotBeReusedForDifferentResources() {
WarehouseResourceLockEntity existing = activeLock();
existing.setPalletId(999L);
when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing);
assertThatThrownBy(() -> service.reserve(request("reserve-1")))
.isInstanceOf(BizException.class)
.hasMessageContaining("different resources");
}
@Test
void rejectsResourceOutsideTenantWarehouse() {
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(null);
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE"));
assertThatThrownBy(() -> service.reserve(request("reserve-1")))
.isInstanceOf(BizException.class)
.hasMessageContaining("tenant warehouse");
verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION);
}
@Test
void rejectsNonIdleResources() {
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED"));
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE"));
assertThatThrownBy(() -> service.reserve(request("reserve-1")))
.isInstanceOf(BizException.class)
.hasMessageContaining("Only IDLE");
}
@Test
void releaseRestoresBothResourcesAndClosesLock() {
WarehouseResourceLockEntity lock = activeLock();
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
when(repository.releasePallet(lock)).thenReturn(1);
when(repository.releaseLocation(lock)).thenReturn(1);
when(repository.close(lock, "RELEASED")).thenReturn(1);
var result = service.release(TENANT, lock.getId());
assertThat(result.status()).isEqualTo("RELEASED");
verify(repository).releasePallet(lock);
verify(repository).releaseLocation(lock);
}
@Test
void scrapDiscardsPalletAndReleasesLocation() {
WarehouseResourceLockEntity lock = activeLock();
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
when(repository.scrapPallet(lock)).thenReturn(1);
when(repository.releaseLocation(lock)).thenReturn(1);
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
var result = service.scrap(TENANT, lock.getId());
assertThat(result.status()).isEqualTo("SCRAPPED");
verify(repository).scrapPallet(lock);
verify(repository).releaseLocation(lock);
}
@Test
void concurrentLocationClaimFailsWholeOperation() {
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE"));
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE"));
when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1);
when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(0);
assertThatThrownBy(() -> service.reserve(request("reserve-1")))
.isInstanceOf(BizException.class)
.hasMessageContaining("no longer IDLE");
verify(repository, never()).insert(any());
}
private static WarehouseResourceLockRequests.ReserveRequest request(String key) {
return new WarehouseResourceLockRequests.ReserveRequest(TENANT, WAREHOUSE, PALLET, LOCATION,
"INBOUND_PLAN_PALLET", "plan-pallet-1", key);
}
private static WarehousePalletEntity pallet(String status) {
WarehousePalletEntity entity = new WarehousePalletEntity();
entity.setStatus(status);
return entity;
}
private static WarehouseLocationEntity location(String status) {
WarehouseLocationEntity entity = new WarehouseLocationEntity();
entity.setStatus(status);
return entity;
}
private static WarehouseResourceLockEntity activeLock() {
WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity();
lock.setId(501L);
lock.setTenantId(TENANT);
lock.setWarehouseId(WAREHOUSE);
lock.setPalletId(PALLET);
lock.setLocationId(LOCATION);
lock.setBusinessType("INBOUND_PLAN_PALLET");
lock.setBusinessId("plan-pallet-1");
lock.setReservationKey("reserve-1");
lock.setStatus("ACTIVE");
return lock;
}
}
@@ -0,0 +1,164 @@
package com.baiyue.warehouse.service;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationRequests;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.repository.WarehouseResourceRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class WarehouseResourceServiceTest {
private static final long TENANT = 10001L;
private static final long WAREHOUSE = 20001L;
@Mock
private WarehouseResourceRepository repository;
private WarehouseResourceService service;
@BeforeEach
void setUp() {
service = new WarehouseResourceService();
ReflectionTestUtils.setField(service, "repository", repository);
}
@Test
void resourceIdsUseMybatisSnowflakeAssignment() throws NoSuchFieldException {
assertThat(WarehouseLocationEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type())
.isEqualTo(IdType.ASSIGN_ID);
assertThat(WarehousePalletEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type())
.isEqualTo(IdType.ASSIGN_ID);
}
@Test
void createsLocationWithSnowflakeAssignedByMapper() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
doAnswer(invocation -> {
((WarehouseLocationEntity) invocation.getArgument(0)).setId(2089000000000000001L);
return null;
}).when(repository).insertLocation(org.mockito.ArgumentMatchers.any());
Long id = service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01"));
assertThat(id).isEqualTo(2089000000000000001L);
ArgumentCaptor<WarehouseLocationEntity> captor = ArgumentCaptor.forClass(WarehouseLocationEntity.class);
verify(repository).insertLocation(captor.capture());
assertThat(captor.getValue().getTenantId()).isEqualTo(TENANT);
assertThat(captor.getValue().getWarehouseId()).isEqualTo(WAREHOUSE);
}
@Test
void rejectsResourceAccessWhenWarehouseDoesNotBelongToTenant() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(false);
assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01")))
.isInstanceOf(BizException.class)
.hasMessageContaining("仓库不存在");
verify(repository, never()).insertLocation(org.mockito.ArgumentMatchers.any());
}
@Test
void rejectsDuplicateLocationCodeWithinWarehouse() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
when(repository.locationCodeExists(TENANT, WAREHOUSE, "A-01", null)).thenReturn(true);
assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01")))
.isInstanceOf(BizException.class)
.hasMessageContaining("库位编码已存在");
}
@Test
void palletDefaultsToTwoCubicMetresAndBindsOwnedLocation() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
WarehouseLocationEntity location = new WarehouseLocationEntity();
location.setId(30001L);
when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(location);
doAnswer(invocation -> {
((WarehousePalletEntity) invocation.getArgument(0)).setId(2089000000000000002L);
return null;
}).when(repository).insertPallet(org.mockito.ArgumentMatchers.any());
Long id = service.createPallet(TENANT, WAREHOUSE,
new PalletRequests.SaveRequest("PLT-01", "STANDARD", null,
new BigDecimal("12.50"), "IDLE", 30001L, null));
assertThat(id).isEqualTo(2089000000000000002L);
ArgumentCaptor<WarehousePalletEntity> captor = ArgumentCaptor.forClass(WarehousePalletEntity.class);
verify(repository).insertPallet(captor.capture());
assertThat(captor.getValue().getCapacityCbm()).isEqualByComparingTo("2.0000");
assertThat(captor.getValue().getLocationId()).isEqualTo(30001L);
}
@Test
void rejectsLocationFromAnotherWarehouseWhenBindingPallet() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
when(repository.findLocation(TENANT, WAREHOUSE, 30002L)).thenReturn(null);
assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE,
new PalletRequests.SaveRequest("PLT-01", "STANDARD", null,
BigDecimal.ONE, "IDLE", 30002L, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("库位不存在");
verify(repository, never()).insertPallet(org.mockito.ArgumentMatchers.any());
}
@Test
void preventsDeletingLocationThatStillHasPallets() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(new WarehouseLocationEntity());
when(repository.locationHasPallets(TENANT, WAREHOUSE, 30001L)).thenReturn(true);
assertThatThrownBy(() -> service.deleteLocation(TENANT, WAREHOUSE, 30001L))
.isInstanceOf(BizException.class)
.hasMessageContaining("仍绑定托盘");
verify(repository, never()).deleteLocation(30001L);
}
@Test
void preventsDeletingOccupiedPallet() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
WarehousePalletEntity pallet = new WarehousePalletEntity();
pallet.setStatus("OCCUPIED");
when(repository.findPallet(TENANT, WAREHOUSE, 40001L)).thenReturn(pallet);
assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40001L))
.isInstanceOf(BizException.class)
.hasMessageContaining("占用中的托盘");
verify(repository, never()).deletePallet(40001L);
}
@Test
void rejectsUnknownPalletStatus() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE,
new PalletRequests.SaveRequest("PLT-01", "STANDARD", null,
BigDecimal.ONE, "BROKEN", null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("IDLE、OCCUPIED、SCRAPPED");
}
private static LocationRequests.SaveRequest locationRequest(String code) {
return new LocationRequests.SaveRequest("A", code, "STORAGE",
new BigDecimal("1000.000"), "ACTIVE", null);
}
}