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
+4 -4
View File
@@ -33,14 +33,14 @@
## 修改与验证
- 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。
- 平台仓库是平台公共主数据:列表接口默认查询全部仓库,并可用兼容字段 `tenantId` 做可选客户范围筛选;不得要求 Admin 先选择客户才能取得仓库候选。详情和写操作现阶段仍按数据表兼容字段校验范围。接口同时校验平台访问域和按钮权限:列表/详情使用 `platform:warehouse:list`,新增使用 `platform:warehouse:create`,编辑使用 `platform:warehouse:edit`,启停使用 `platform:warehouse:status`;OpenAPI 的 403 响应说明必须与权限码同步。
- 往来单位、仓库、快递公司、托盘、库位等均是平台公共主数据,统一归属平台技术作用域 `tenant_id=0`,不得绑定任一客户;客户只读取平台共享往来单位。平台启停必须检查业务约束,不能只改状态字段。列表接口默认查询全部公共资源,不得要求 Admin 先选择客户,也不得让客户筛选控制仓库、往来单位、快递、托盘或库位候选范围。历史接口或字段仍出现 `tenantId` 时只保留兼容,只允许传平台公共作用域 `0`,不得传客户 ID 作为资源归属。接口同时校验平台访问域和按钮权限:列表/详情使用 `platform:warehouse:list`,新增使用 `platform:warehouse:create`,编辑使用 `platform:warehouse:edit`,启停使用 `platform:warehouse:status`;OpenAPI 的 403 响应说明必须与权限码同步。
- 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。
- 往来单位类型以 `types` 多值集合和 `wh_partner_type` 为准,允许同一单位同时作为客户、供应商等;`wh_partner.partner_type` 与 DTO 的 `partnerType` 仅保留旧数据兼容。写入时 Entity、Mapper、Repository、平台保存/导入和 Order 客户端必须一起核对。
- 往来单位与快递公司导入只接收标准 XLSX 模板,文件上限 5MB、最多 1000 行;先完成整表头、字段、文件内重复和数据库重复校验,再原子写入,任一行失败整批不落库。权限分别是 `platform:counterparty:import` 与 `platform:express:import`。
- 模板与导入 DTO 在 `dto/MasterDataQueries.java`,Apache POI 依赖在 `pom.xml`;调整模板版本、列顺序或枚举时同步修改生成、解析、错误行号和两组 ImportService 测试。
- Warehouse 内部仓库/往来单位接口使用 `X-Internal-Service-Token`;不存在或跨租户使用 HTTP 200 + body code `A0404`,已停用使用 HTTP 200 + `A0409`,`WarehouseDataAccessExceptionHandler` 捕获的数据库或结构故障保留真实 HTTP 503 + `S0503`。内部路径不加入 Gateway 公网路由。
- 托盘和库位平台接口必须执行平台访问域、目标租户和对应权限校验;预占、确认、释放只通过 `InternalPalletLocationController` 提供给 Firstleg,不加入 Gateway 公网路由。
- Warehouse 内部仓库/往来单位接口使用 `X-Internal-Service-Token`:公共仓库和平台共享往来单位均按 `tenant_id=0` 校验;往来单位不存在使用 HTTP 200 + body code `A0404`,资源已停用使用 HTTP 200 + `A0409`。`WarehouseDataAccessExceptionHandler` 捕获的数据库或结构故障保留真实 HTTP 503 + `S0503`。内部路径不加入 Gateway 公网路由。
- 托盘和库位平台接口必须执行平台访问域、平台技术作用域 `tenant_id=0` 和对应权限校验,不得要求目标客户;预占、确认、释放只通过 `InternalPalletLocationController` 提供给 Firstleg,不加入 Gateway 公网路由。Firstleg 的入库单仍按客户 `tenant_id>0` 隔离,调用公共仓储资源时必须把业务客户归属与公共资源归属分开校验。
- 公共资源与客户数据必须分开:公共仓库、往来单位、快递公司、托盘、库位使用 `tenant_id=0`;客户订单、文件和费用使用所属客户 `tenant_id>0`。`10001` 是客户 ID,不得再用于平台公共资源归属。
- 库位容量、共享溢出、预占和占用必须在数据库事务及条件更新中保持一致;修改分配算法时同步覆盖并发冲突、容量不足、重复确认、释放和共享溢出测试。
- Firstleg 只保存托盘/库位逻辑引用和入库快照。内部 DTO、预占状态或容量口径变化时同步检查 `firstleg/client/WarehousePalletLocationClient.java`、`MeasurementLocationOccupationService.java` 和双方测试。
- 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。
@@ -31,6 +31,8 @@ public class InternalPartnerController {
@PathVariable long tenantId,@PathVariable long id) {
if (token==null || !MessageDigest.isEqual(expected,token.getBytes(StandardCharsets.UTF_8)))
throw new BizException(ResultCode.FORBIDDEN,"内部服务凭证无效");
if (tenantId != 0L)
throw new BizException(ResultCode.BAD_REQUEST,"平台共享往来单位作用域必须为0");
return ApiResponse.success(service.active(tenantId,id));
}
}
@@ -1,12 +1,15 @@
package com.baiyue.warehouse.controller;
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.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.service.PartnerQueryService;
import jakarta.annotation.Resource;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.beans.factory.annotation.Value;
import java.util.List;
@RestController
@@ -14,14 +17,25 @@ import java.util.List;
public class MerchantPartnerController {
@Resource
private PartnerQueryService service;
@Value("${baiyue.warehouse.public-tenant-id:0}")
private long publicTenantId;
@GetMapping("/candidates")
public ApiResponse<List<PartnerEntity>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required=false) String keyword,@RequestParam(required=false) String type) {
return ApiResponse.success(service.candidates(user.tenantId(),keyword,type));
requireMerchant(user);
return ApiResponse.success(service.candidates(publicTenantId,keyword,type));
}
@GetMapping("/{id}")
public ApiResponse<PartnerEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
return ApiResponse.success(service.active(user.tenantId(),id));
requireMerchant(user);
return ApiResponse.success(service.active(publicTenantId,id));
}
private static void requireMerchant(AuthenticatedUser user) {
if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() <= 0L) {
throw new BizException(ResultCode.FORBIDDEN, "需要客户身份");
}
}
}
@@ -2,6 +2,8 @@ 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.WarehouseDetailResponse;
import com.baiyue.warehouse.dto.WarehouseRequests;
@@ -33,7 +35,7 @@ public class MerchantWarehouseController {
@Resource
private WarehouseApplicationService service;
@Value("${baiyue.warehouse.public-tenant-id:10001}")
@Value("${baiyue.warehouse.public-tenant-id:0}")
private long publicTenantId;
@Operation(
@@ -48,6 +50,7 @@ public class MerchantWarehouseController {
public ApiResponse<PageResult<WarehouseEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid WarehouseRequests.QueryRequest query) {
requireMerchant(user);
return ApiResponse.success(service.merchantPage(publicTenantId, query));
}
@@ -64,6 +67,14 @@ public class MerchantWarehouseController {
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
@PathVariable long id) {
requireMerchant(user);
return ApiResponse.success(service.activeDetail(publicTenantId, id));
}
private static void requireMerchant(AuthenticatedUser user) {
if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() <= 0L) {
throw new BizException(ResultCode.FORBIDDEN, "需要客户身份");
}
}
}
@@ -44,7 +44,7 @@ import java.nio.charset.StandardCharsets;
/** 平台快递公司管理接口。 */
@Validated
@Tag(name = "平台快递公司", description = "跨企业分页查询和按目标租户维护快递公司主数据")
@Tag(name = "平台快递公司", description = "平台统一维护、供全部客户共享的快递公司主数据")
@RestController
@RequestMapping("/api/platform/v1/express-companies")
public class PlatformExpressCompanyController {
@@ -55,7 +55,7 @@ public class PlatformExpressCompanyController {
@GetMapping
@PreAuthorize("hasAuthority('platform:express:list')")
@Operation(summary = "分页查询快递公司", description = "默认查询全部企业;可按目标租户、关键字和状态筛选")
@Operation(summary = "分页查询快递公司", description = "查询平台共享快递公司;可按关键字和状态筛选")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"),
@@ -64,11 +64,12 @@ public class PlatformExpressCompanyController {
})
public ApiResponse<PageResult<ExpressCompanyEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
@RequestParam(required = false) @Min(1) Long tenantId,
@Parameter(description = "兼容参数,只允许平台公共作用域 0")
@RequestParam(required = false) @Min(0) Long tenantId,
@Valid @ParameterObject ExpressCompanyPageQuery query) {
requirePlatform(user);
return ApiResponse.success(service.page(tenantId, query));
requirePlatformScope(tenantId);
return ApiResponse.success(service.page(0L, query));
}
@GetMapping("/import-template")
@@ -88,9 +89,10 @@ public class PlatformExpressCompanyController {
@Operation(summary = "批量导入快递公司", description = "使用标准 XLSX 模板整批校验并新增快递公司")
public ApiResponse<ExpressCompanyImportResult> importCompanies(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@RequestPart("file") MultipartFile file) {
requirePlatform(user);
requirePlatformScope(tenantId);
try {
return ApiResponse.success(importService.importFile(tenantId, file.getOriginalFilename(), file.getBytes()));
} catch (IOException exception) {
@@ -100,35 +102,38 @@ public class PlatformExpressCompanyController {
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:express:list')")
@Operation(summary = "查询快递公司详情", description = "查询目标租户内的快递公司详情")
@Operation(summary = "查询快递公司详情", description = "查询平台共享快递公司详情")
public ApiResponse<ExpressCompanyEntity> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@PathVariable long id) {
requirePlatform(user);
requirePlatformScope(tenantId);
return ApiResponse.success(service.detail(tenantId, id));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:express:create')")
@Operation(summary = "新增快递公司", description = "在目标租户内新增快递公司")
@Operation(summary = "新增快递公司", description = "新增平台共享快递公司")
public ApiResponse<Long> create(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@Valid @RequestBody ExpressCompanySaveRequest request) {
requirePlatform(user);
requirePlatformScope(tenantId);
return ApiResponse.success(service.create(tenantId, request));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:express:edit')")
@Operation(summary = "编辑快递公司", description = "编辑目标租户内的快递公司")
@Operation(summary = "编辑快递公司", description = "编辑平台共享快递公司")
public ApiResponse<Void> update(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@PathVariable long id,
@Valid @RequestBody ExpressCompanySaveRequest request) {
requirePlatform(user);
requirePlatformScope(tenantId);
service.update(tenantId, id, request);
return ApiResponse.success(null);
}
@@ -138,17 +143,25 @@ public class PlatformExpressCompanyController {
@Operation(summary = "启用或停用快递公司", description = "通过状态变更停用主数据,不物理删除历史记录")
public ApiResponse<Void> changeStatus(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@PathVariable long id,
@Valid @RequestBody StatusRequest request) {
requirePlatform(user);
requirePlatformScope(tenantId);
service.changeStatus(tenantId, id, request.status());
return ApiResponse.success(null);
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() != 0L) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
private static void requirePlatformScope(Long tenantId) {
if (tenantId != null && tenantId != 0L) {
throw new BizException(ResultCode.BAD_REQUEST, "平台公共资源作用域必须为0");
}
}
}
@@ -56,10 +56,11 @@ public class PlatformLocationController {
public ApiResponse<PageResult<LocationEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
@RequestParam(required = false) @Min(1) Long tenantId,
@RequestParam(required = false) @Min(0) Long tenantId,
@Valid @ParameterObject PageQuery query) {
requirePlatform(user);
return ApiResponse.success(service.page(tenantId, query));
requirePlatformScope(tenantId);
return ApiResponse.success(service.page(0L, query));
}
@GetMapping("/{id}")
@@ -71,10 +72,11 @@ public class PlatformLocationController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "库位不存在")
})
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id) {
requirePlatform(user);
requirePlatformScope(tenantId);
return ApiResponse.success(service.detail(tenantId, warehouseId, id));
}
@@ -86,10 +88,11 @@ public class PlatformLocationController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限或仓库超出数据范围")
})
public ApiResponse<List<LocationEntity>> available(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@RequestParam @Min(1) long warehouseId,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) int limit) {
requirePlatform(user);
requirePlatformScope(tenantId);
return ApiResponse.success(service.findAvailable(tenantId, warehouseId, limit));
}
@@ -102,9 +105,10 @@ public class PlatformLocationController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "库位编码重复")
})
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@Valid @RequestBody SaveRequest request) {
requirePlatform(user);
requirePlatformScope(tenantId);
return ApiResponse.success(service.create(tenantId, request));
}
@@ -117,11 +121,12 @@ public class PlatformLocationController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "版本冲突、编码重复或库位正在被使用")
})
public ApiResponse<Void> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id,
@Valid @RequestBody SaveRequest request) {
requirePlatform(user);
requirePlatformScope(tenantId);
service.update(tenantId, warehouseId, id, request);
return ApiResponse.success(null);
}
@@ -135,11 +140,12 @@ public class PlatformLocationController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "版本冲突或库位正在被使用")
})
public ApiResponse<Void> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id,
@Valid @RequestBody StatusRequest request) {
requirePlatform(user);
requirePlatformScope(tenantId);
service.changeStatus(tenantId, warehouseId, id, request.status(), request.version(), requireUserId(user));
return ApiResponse.success(null);
}
@@ -153,18 +159,20 @@ public class PlatformLocationController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "库位未停用、正在被使用或版本冲突")
})
public ApiResponse<Void> delete(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id,
@Parameter(description = "乐观锁版本号", required = true)
@RequestParam @Min(0) int version) {
requirePlatform(user);
requirePlatformScope(tenantId);
service.delete(tenantId, warehouseId, id, version, requireUserId(user));
return ApiResponse.success(null);
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() != 0L) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
@@ -175,4 +183,10 @@ public class PlatformLocationController {
}
return user.userId();
}
private static void requirePlatformScope(Long tenantId) {
if (tenantId != null && tenantId != 0L) {
throw new BizException(ResultCode.BAD_REQUEST, "平台公共资源作用域必须为0");
}
}
}
@@ -86,7 +86,8 @@ public class PlatformOverseasStorageFeeController {
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() != 0L) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
@@ -43,20 +43,22 @@ public class PlatformPalletController {
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:pallet:list 权限"))
public ApiResponse<PageResult<PalletRequests.View>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "目标企业租户ID;不传时查询全部企业")
@RequestParam(required = false) @Min(1) Long tenantId,
@RequestParam(required = false) @Min(0) Long tenantId,
@Valid @ParameterObject PalletRequests.PageQuery query) {
requirePlatform(user);
return ApiResponse.success(service.page(tenantId, query));
requirePlatformScope(tenantId);
return ApiResponse.success(service.page(0L, query));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:pallet:list')")
@Operation(summary = "查询托盘详情")
public ApiResponse<PalletRequests.View> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id) {
requirePlatform(user);
requirePlatformScope(tenantId);
return ApiResponse.success(service.detail(tenantId, warehouseId, id));
}
@@ -64,9 +66,10 @@ public class PlatformPalletController {
@PreAuthorize("hasAuthority('platform:pallet:create')")
@Operation(summary = "新增托盘")
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@Valid @RequestBody PalletRequests.SaveRequest request) {
requirePlatform(user);
requirePlatformScope(tenantId);
return ApiResponse.success(service.create(tenantId, request));
}
@@ -74,11 +77,12 @@ public class PlatformPalletController {
@PreAuthorize("hasAuthority('platform:pallet:edit')")
@Operation(summary = "编辑托盘")
public ApiResponse<Void> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id,
@Valid @RequestBody PalletRequests.SaveRequest request) {
requirePlatform(user);
requirePlatformScope(tenantId);
service.update(tenantId, warehouseId, id, request);
return ApiResponse.success(null);
}
@@ -87,17 +91,25 @@ public class PlatformPalletController {
@PreAuthorize("hasAuthority('platform:pallet:delete')")
@Operation(summary = "删除托盘", description = "仅可删除未预占、未占用的托盘")
public ApiResponse<Void> delete(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id) {
requirePlatform(user);
requirePlatformScope(tenantId);
service.delete(tenantId, warehouseId, id);
return ApiResponse.success(null);
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() != 0L) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
private static void requirePlatformScope(Long tenantId) {
if (tenantId != null && tenantId != 0L) {
throw new BizException(ResultCode.BAD_REQUEST, "平台公共资源作用域必须为0");
}
}
}
@@ -42,9 +42,9 @@ import org.springframework.http.ResponseEntity;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/** 平台往来单位管理接口。 */
/** 平台共享往来单位管理接口。 */
@Validated
@Tag(name = "平台往来单位", description = "跨企业分页查询和按目标租户维护往来单位主数据")
@Tag(name = "平台往来单位", description = "平台统一维护、供全部客户共享的往来单位主数据")
@RestController
@RequestMapping("/api/platform/v1/partners")
public class PlatformPartnerController {
@@ -70,11 +70,12 @@ public class PlatformPartnerController {
@Operation(summary = "批量导入往来单位", description = "使用标准 XLSX 模板整批校验并新增往来单位")
public ApiResponse<PartnerImportResult> importPartners(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@RequestPart("file") MultipartFile file) {
requirePlatform(user);
requirePlatformScope(tenantId);
try {
return ApiResponse.success(importService.importFile(tenantId, file.getOriginalFilename(), file.getBytes()));
return ApiResponse.success(importService.importFile(0L, file.getOriginalFilename(), file.getBytes()));
} catch (IOException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败");
}
@@ -83,7 +84,7 @@ public class PlatformPartnerController {
@GetMapping
@PreAuthorize("hasAuthority('platform:counterparty:list')")
@Operation(summary = "分页查询往来单位",
description = "默认分页查询全部企业往来单位;可按目标租户、关键字、类型和状态筛选")
description = "查询平台统一维护、供全部客户共享的往来单位;可按关键字、类型和状态筛选")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"),
@@ -92,45 +93,49 @@ public class PlatformPartnerController {
})
public ApiResponse<PageResult<PartnerEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
@RequestParam(required = false) @Min(1) Long tenantId,
@Parameter(description = "兼容参数,只允许平台公共作用域 0")
@RequestParam(required = false) @Min(0) Long tenantId,
@Valid @ParameterObject PartnerPageQuery query) {
requirePlatform(user);
return ApiResponse.success(service.page(tenantId, query));
requirePlatformScope(tenantId);
return ApiResponse.success(service.page(0L, query));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:counterparty:list')")
@Operation(summary = "查询往来单位详情", description = "查询目标租户内的往来单位详情")
@Operation(summary = "查询往来单位详情", description = "查询平台共享往来单位详情")
public ApiResponse<PartnerEntity> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@PathVariable long id) {
requirePlatform(user);
return ApiResponse.success(service.detail(tenantId, id));
requirePlatformScope(tenantId);
return ApiResponse.success(service.detail(0L, id));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:counterparty:create')")
@Operation(summary = "新增往来单位", description = "在目标租户内新增往来单位")
@Operation(summary = "新增往来单位", description = "新增平台共享往来单位")
public ApiResponse<Long> create(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@Valid @RequestBody PartnerSaveRequest request) {
requirePlatform(user);
return ApiResponse.success(service.create(tenantId, request));
requirePlatformScope(tenantId);
return ApiResponse.success(service.create(0L, request));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:counterparty:edit')")
@Operation(summary = "编辑往来单位", description = "编辑目标租户内的往来单位")
@Operation(summary = "编辑往来单位", description = "编辑平台共享往来单位")
public ApiResponse<Void> update(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@PathVariable long id,
@Valid @RequestBody PartnerSaveRequest request) {
requirePlatform(user);
service.update(tenantId, id, request);
requirePlatformScope(tenantId);
service.update(0L, id, request);
return ApiResponse.success(null);
}
@@ -139,17 +144,25 @@ public class PlatformPartnerController {
@Operation(summary = "启用或停用往来单位", description = "通过状态变更停用主数据,不物理删除历史记录")
public ApiResponse<Void> changeStatus(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(0) long tenantId,
@PathVariable long id,
@Valid @RequestBody StatusRequest request) {
requirePlatform(user);
service.changeStatus(tenantId, id, request.status());
requirePlatformScope(tenantId);
service.changeStatus(0L, id, request.status());
return ApiResponse.success(null);
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() != 0L) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
private static void requirePlatformScope(Long tenantId) {
if (tenantId != null && tenantId != 0L) {
throw new BizException(ResultCode.BAD_REQUEST, "平台共享往来单位作用域必须为0");
}
}
}
@@ -106,7 +106,7 @@ public class PlatformWarehouseController {
@Valid WarehouseRequests.QueryRequest request) {
requirePlatform(user);
if (tenantId != null) requireTenant(tenantId);
return ApiResponse.success(service.page(tenantId, request));
return ApiResponse.success(service.page(0L, request));
}
@Operation(
@@ -158,14 +158,15 @@ public class PlatformWarehouseController {
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() != 0L) {
throw new BizException(ResultCode.FORBIDDEN, "Platform application required / 仅允许平台端访问");
}
}
private static void requireTenant(Long tenantId) {
if (tenantId == null || tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "Target tenant is required / 必须指定有效目标租户");
if (tenantId == null || tenantId != 0L) {
throw new BizException(ResultCode.BAD_REQUEST, "Platform resource scope must be 0 / 平台公共资源作用域必须为0");
}
}
}
@@ -62,7 +62,7 @@ public final class MasterDataQueries {
@Schema(name = "PartnerSaveRequest", description = "平台往来单位保存请求")
public record PartnerSaveRequest(
@Schema(description = "租户内唯一往来单位编码", example = "CUS-001")
@Schema(description = "平台内唯一往来单位编码", example = "CUS-001")
@NotBlank @Size(max = 50) String partnerCode,
@Schema(description = "往来单位名称", example = "深圳示例贸易有限公司")
@NotBlank @Size(max = 128) String partnerName,
@@ -110,7 +110,7 @@ public final class MasterDataQueries {
@Schema(name = "ExpressCompanySaveRequest", description = "平台快递公司保存请求")
public record ExpressCompanySaveRequest(
@Schema(description = "租户内唯一快递公司编码", example = "SF")
@Schema(description = "平台内唯一快递公司编码", example = "SF")
@NotBlank @Size(max = 50) String companyCode,
@Schema(description = "快递公司名称", example = "顺丰速运")
@NotBlank @Size(max = 128) String companyName,
@@ -8,8 +8,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
/** 租户维度快递公司主数据。 */
@Schema(name = "ExpressCompany", description = "租户维度快递公司主数据")
/** 平台统一维护、供全部客户共享的快递公司主数据。 */
@Schema(name = "ExpressCompany", description = "平台共享快递公司主数据")
@Getter
@Setter
@TableName("wh_express_company")
@@ -17,9 +17,9 @@ public class ExpressCompanyEntity extends BaseEntity {
@Schema(description = "快递公司记录 ID", example = "192000000000000001")
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@Schema(description = "所属租户 ID", example = "10001")
@Schema(description = "平台公共作用域 ID,固定为 0", example = "0")
private Long tenantId;
@Schema(description = "租户内唯一快递公司编码", example = "SF")
@Schema(description = "平台内唯一快递公司编码", example = "SF")
private String companyCode;
@Schema(description = "快递公司名称", example = "顺丰速运")
private String companyName;
@@ -11,18 +11,18 @@ import lombok.Setter;
import java.util.List;
/** 仓储域内的租户往来单位配置。 */
@Schema(name = "Partner", description = "租户维度往来单位主数据")
/** 仓储域内由平台统一维护、供客户共享的往来单位主数据。 */
@Schema(name = "Partner", description = "平台共享往来单位主数据")
@Getter @Setter
@TableName("wh_partner")
public class PartnerEntity extends BaseEntity {
@Schema(description = "往来单位记录 ID", example = "191000000000000001")
@TableId(type = IdType.ASSIGN_ID) private Long id;
@Schema(description = "所属租户 ID", example = "10001")
@Schema(description = "平台公共作用域 ID,固定为 0", example = "0")
private Long tenantId;
@Schema(description = "所属业务数据范围 ID,由服务端维护", example = "10001")
@Schema(description = "平台公共数据范围 ID,固定为 0", example = "0")
private Long dataScopeId;
@Schema(description = "租户内唯一往来单位编码", example = "CUS-001")
@Schema(description = "平台内唯一往来单位编码", example = "CUS-001")
private String partnerCode;
@Schema(description = "往来单位名称", example = "深圳示例贸易有限公司")
private String partnerName;
@@ -7,6 +7,7 @@ import lombok.Setter;
@Getter
@Setter
public class PartnerTypeEntity {
private Long tenantId;
private Long partnerId;
private String partnerType;
}
@@ -14,14 +14,17 @@ import java.util.List;
public interface PartnerMapper extends BaseMapper<PartnerEntity> {
@Select("""
<script>
SELECT partner_id AS partnerId, partner_type AS partnerType
SELECT tenant_id AS tenantId, partner_id AS partnerId, partner_type AS partnerType
FROM wh_partner_type
WHERE partner_id IN
WHERE tenant_id IN
<foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">#{tenantId}</foreach>
AND partner_id IN
<foreach collection="partnerIds" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY partner_id, partner_type
ORDER BY tenant_id, partner_id, partner_type
</script>
""")
List<PartnerTypeEntity> selectTypes(@Param("partnerIds") Collection<Long> partnerIds);
List<PartnerTypeEntity> selectTypes(@Param("tenantIds") Collection<Long> tenantIds,
@Param("partnerIds") Collection<Long> partnerIds);
@Select("SELECT partner_id FROM wh_partner_type WHERE tenant_id = #{tenantId} AND partner_type = #{type}")
List<Long> selectPartnerIdsByType(@Param("tenantId") long tenantId, @Param("type") String type);
@@ -76,8 +79,8 @@ public interface PartnerMapper extends BaseMapper<PartnerEntity> {
@Param("status") String status,
@Param("keyword") String keyword);
@Delete("DELETE FROM wh_partner_type WHERE partner_id = #{partnerId}")
void deleteTypes(@Param("partnerId") long partnerId);
@Delete("DELETE FROM wh_partner_type WHERE tenant_id = #{tenantId} AND partner_id = #{partnerId}")
void deleteTypes(@Param("tenantId") long tenantId, @Param("partnerId") long partnerId);
@Insert("""
<script>
@@ -10,7 +10,7 @@ import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
/** 快递公司主数据仓储;平台分页租户条件可选,资源读写仍强制租户边界。 */
/** 快递公司主数据仓储;当前生产入口固定使用平台公共作用域 0,保留作用域参数仅为兼容。 */
@Repository
public class ExpressCompanyRepository {
@Resource
@@ -96,7 +96,7 @@ public class PartnerRepository {
}
private void replaceTypes(PartnerEntity entity) {
mapper.deleteTypes(entity.getId());
mapper.deleteTypes(entity.getTenantId(), entity.getId());
Collection<String> types = entity.getTypes();
if (types != null && !types.isEmpty()) {
mapper.insertTypes(entity.getId(), entity.getTenantId(), entity.getDataScopeId(), types);
@@ -105,16 +105,20 @@ public class PartnerRepository {
private void hydrateTypes(List<PartnerEntity> records) {
if (records.isEmpty()) return;
Map<Long, List<String>> grouped = new LinkedHashMap<>();
mapper.selectTypes(records.stream().map(PartnerEntity::getId).toList())
.forEach(item -> grouped.computeIfAbsent(item.getPartnerId(), ignored -> new java.util.ArrayList<>())
Map<PartnerKey, List<String>> grouped = new LinkedHashMap<>();
mapper.selectTypes(records.stream().map(PartnerEntity::getTenantId).distinct().toList(),
records.stream().map(PartnerEntity::getId).toList())
.forEach(item -> grouped.computeIfAbsent(new PartnerKey(item.getTenantId(), item.getPartnerId()),
ignored -> new java.util.ArrayList<>())
.add(item.getPartnerType()));
records.forEach(record -> {
List<String> types = grouped.get(record.getId());
List<String> types = grouped.get(new PartnerKey(record.getTenantId(), record.getId()));
record.setTypes(types == null || types.isEmpty() ? legacyTypes(record.getPartnerType()) : List.copyOf(types));
});
}
private record PartnerKey(Long tenantId, Long partnerId) { }
private static List<String> legacyTypes(String type) {
if ("BOTH".equals(type)) return List.of("CUSTOMER", "SUPPLIER");
return StringUtils.hasText(type) ? List.of(type) : List.of();
@@ -28,6 +28,7 @@ import org.springframework.transaction.interceptor.TransactionAspectSupport;
/** 标准模板和快递公司批量导入。 */
@Service
public class ExpressCompanyImportService {
private static final long PUBLIC_TENANT_ID = 0L;
private static final int MAX_FILE_BYTES = 5 * 1024 * 1024;
private static final int MAX_ROWS = 1000;
private static final String TEMPLATE_VERSION = "1";
@@ -60,6 +61,10 @@ public class ExpressCompanyImportService {
@Transactional
@BusinessAudit(event = "EXPRESS_COMPANY_IMPORT", resourceType = "EXPRESS_COMPANY_IMPORT", description = "批量导入快递公司", captureBefore = false)
public ExpressCompanyImportResult importFile(long tenantId, String fileName, byte[] content) {
if (tenantId != PUBLIC_TENANT_ID) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "平台公共快递公司作用域必须为0");
}
String safeFileName = fileName == null || fileName.isBlank() ? "未命名文件.xlsx" : fileName;
if (content == null || content.length == 0) {
return failure(safeFileName, 0, new ExpressCompanyImportError(0, "文件", "文件不能为空"));
@@ -16,24 +16,28 @@ import org.springframework.transaction.annotation.Transactional;
/** 平台快递公司管理服务。 */
@Service
public class ExpressCompanyQueryService {
private static final long PUBLIC_TENANT_ID = 0L;
@Resource
private ExpressCompanyRepository repository;
public PageResult<ExpressCompanyEntity> page(Long tenantId, ExpressCompanyPageQuery query) {
return repository.page(tenantId, query);
requirePublicScope(tenantId);
return repository.page(PUBLIC_TENANT_ID, query);
}
public ExpressCompanyEntity detail(long tenantId, long id) {
return owned(tenantId, id);
requirePublicScope(tenantId);
return owned(PUBLIC_TENANT_ID, id);
}
@Transactional
@BusinessAudit(event = "EXPRESS_COMPANY_CREATE", resourceType = "EXPRESS_COMPANY", description = "新增快递公司", captureBefore = false)
public Long create(long tenantId, ExpressCompanySaveRequest request) {
ensureCodeUnique(tenantId, request.companyCode(), null);
ensureNameUnique(tenantId, request.companyName(), null);
requirePublicScope(tenantId);
ensureCodeUnique(PUBLIC_TENANT_ID, request.companyCode(), null);
ensureNameUnique(PUBLIC_TENANT_ID, request.companyName(), null);
ExpressCompanyEntity entity = new ExpressCompanyEntity();
entity.setTenantId(tenantId);
entity.setTenantId(PUBLIC_TENANT_ID);
copy(entity, request);
try {
repository.insert(entity);
@@ -46,9 +50,10 @@ public class ExpressCompanyQueryService {
@Transactional
@BusinessAudit(event = "EXPRESS_COMPANY_UPDATE", resourceType = "EXPRESS_COMPANY", resourceId = "#id", description = "编辑快递公司")
public void update(long tenantId, long id, ExpressCompanySaveRequest request) {
ExpressCompanyEntity entity = owned(tenantId, id);
ensureCodeUnique(tenantId, request.companyCode(), id);
ensureNameUnique(tenantId, request.companyName(), id);
requirePublicScope(tenantId);
ExpressCompanyEntity entity = owned(PUBLIC_TENANT_ID, id);
ensureCodeUnique(PUBLIC_TENANT_ID, request.companyCode(), id);
ensureNameUnique(PUBLIC_TENANT_ID, request.companyName(), id);
copy(entity, request);
try {
repository.save(entity);
@@ -60,8 +65,9 @@ public class ExpressCompanyQueryService {
@Transactional
@BusinessAudit(event = "EXPRESS_COMPANY_STATUS_CHANGE", resourceType = "EXPRESS_COMPANY", resourceId = "#id", description = "启停快递公司")
public void changeStatus(long tenantId, long id, String status) {
requirePublicScope(tenantId);
requireStatus(status);
ExpressCompanyEntity entity = owned(tenantId, id);
ExpressCompanyEntity entity = owned(PUBLIC_TENANT_ID, id);
entity.setStatus(status);
repository.save(entity);
}
@@ -74,6 +80,12 @@ public class ExpressCompanyQueryService {
return entity;
}
private static void requirePublicScope(long tenantId) {
if (tenantId != PUBLIC_TENANT_ID) {
throw new BizException(ResultCode.BAD_REQUEST, "平台公共快递公司作用域必须为0");
}
}
private void ensureCodeUnique(long tenantId, String code, Long excludedId) {
if (repository.existsCode(tenantId, code, excludedId)) {
throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在");
@@ -29,6 +29,7 @@ import java.util.regex.Pattern;
/** 往来单位标准模板生成与原子批量导入。 */
@Service
public class PartnerImportService {
private static final long PUBLIC_TENANT_ID = 0L;
private static final int MAX_FILE_BYTES = 5 * 1024 * 1024;
private static final int MAX_ROWS = 1000;
private static final String TEMPLATE_VERSION = "1";
@@ -68,6 +69,10 @@ public class PartnerImportService {
@Transactional
@BusinessAudit(event = "PARTNER_IMPORT", resourceType = "PARTNER_IMPORT", description = "批量导入往来单位", captureBefore = false)
public PartnerImportResult importFile(long tenantId, String fileName, byte[] content) {
if (tenantId != PUBLIC_TENANT_ID) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "平台共享往来单位作用域必须为0");
}
String safeFileName = safeFileName(fileName);
if (content == null || content.length == 0) return failure(safeFileName, 0, 0, "文件", "文件不能为空");
if (content.length > MAX_FILE_BYTES) return failure(safeFileName, 0, 0, "文件", "文件不能超过 5MB");
@@ -152,8 +157,8 @@ public class PartnerImportService {
private static PartnerEntity toEntity(long tenantId, ImportRow row) {
PartnerEntity entity = new PartnerEntity();
entity.setTenantId(tenantId);
entity.setDataScopeId(tenantId);
entity.setTenantId(PUBLIC_TENANT_ID);
entity.setDataScopeId(PUBLIC_TENANT_ID);
entity.setPartnerCode(row.code);
entity.setPartnerName(row.name);
entity.setTypes(row.types);
@@ -10,19 +10,28 @@ import java.util.List;
@Service
public class PartnerQueryService {
private static final long PUBLIC_TENANT_ID = 0L;
@Resource
private PartnerRepository repository;
public List<PartnerEntity> candidates(long tenantId, String keyword, String type) {
requirePublicScope(tenantId);
if (type != null && !type.isBlank() && !"SHIPPER".equals(type) && !"CONSIGNEE".equals(type)) {
throw new BizException(ResultCode.BAD_REQUEST, "候选类型只能是 SHIPPER 或 CONSIGNEE");
}
return repository.activeCandidates(tenantId, keyword, type);
}
public PartnerEntity active(long tenantId, long id) {
requirePublicScope(tenantId);
PartnerEntity value=repository.findOwned(tenantId,id);
if (value==null) throw new BizException(ResultCode.NOT_FOUND,"往来单位不存在或不属于当前企业");
if (value==null) throw new BizException(ResultCode.NOT_FOUND,"平台共享往来单位不存在或不可用");
if (!"ACTIVE".equals(value.getStatus()))
throw new BizException(ResultCode.CONFLICT,"往来单位已停用");
return value;
}
private static void requirePublicScope(long tenantId) {
if (tenantId != PUBLIC_TENANT_ID) {
throw new BizException(ResultCode.BAD_REQUEST, "平台共享往来单位作用域必须为0");
}
}
}
@@ -20,24 +20,28 @@ import java.util.Set;
/** 平台往来单位管理服务。 */
@Service
public class PlatformPartnerQueryService {
private static final long PUBLIC_TENANT_ID = 0L;
@Resource
private PartnerRepository repository;
public PageResult<PartnerEntity> page(Long tenantId, PartnerPageQuery query) {
return repository.page(tenantId, query);
requirePublicScope(tenantId);
return repository.page(PUBLIC_TENANT_ID, query);
}
public PartnerEntity detail(long tenantId, long id) {
return owned(tenantId, id);
requirePublicScope(tenantId);
return owned(PUBLIC_TENANT_ID, id);
}
@Transactional
@BusinessAudit(event = "PARTNER_CREATE", resourceType = "PARTNER", description = "新增往来单位", captureBefore = false)
public Long create(long tenantId, PartnerSaveRequest request) {
ensureCodeUnique(tenantId, request.partnerCode(), null);
requirePublicScope(tenantId);
ensureCodeUnique(PUBLIC_TENANT_ID, request.partnerCode(), null);
PartnerEntity entity = new PartnerEntity();
entity.setTenantId(tenantId);
entity.setDataScopeId(tenantId);
entity.setTenantId(PUBLIC_TENANT_ID);
entity.setDataScopeId(PUBLIC_TENANT_ID);
copy(entity, request);
try {
repository.insert(entity);
@@ -50,8 +54,9 @@ public class PlatformPartnerQueryService {
@Transactional
@BusinessAudit(event = "PARTNER_UPDATE", resourceType = "PARTNER", resourceId = "#id", description = "编辑往来单位")
public void update(long tenantId, long id, PartnerSaveRequest request) {
PartnerEntity entity = owned(tenantId, id);
ensureCodeUnique(tenantId, request.partnerCode(), id);
requirePublicScope(tenantId);
PartnerEntity entity = owned(PUBLIC_TENANT_ID, id);
ensureCodeUnique(PUBLIC_TENANT_ID, request.partnerCode(), id);
copy(entity, request);
try {
repository.save(entity);
@@ -63,8 +68,9 @@ public class PlatformPartnerQueryService {
@Transactional
@BusinessAudit(event = "PARTNER_STATUS_CHANGE", resourceType = "PARTNER", resourceId = "#id", description = "启停往来单位")
public void changeStatus(long tenantId, long id, String status) {
requirePublicScope(tenantId);
requireStatus(status);
PartnerEntity entity = owned(tenantId, id);
PartnerEntity entity = owned(PUBLIC_TENANT_ID, id);
entity.setStatus(status);
repository.save(entity);
}
@@ -77,6 +83,12 @@ public class PlatformPartnerQueryService {
return entity;
}
private static void requirePublicScope(long tenantId) {
if (tenantId != PUBLIC_TENANT_ID) {
throw new BizException(ResultCode.BAD_REQUEST, "平台共享往来单位作用域必须为0");
}
}
private void ensureCodeUnique(long tenantId, String code, Long excludedId) {
if (repository.existsCode(tenantId, code, excludedId)) {
throw new BizException(ResultCode.CONFLICT, "往来单位编码已存在");
@@ -119,6 +119,54 @@ CALL add_column_if_missing('wh_express_company', 'updated_at', 'DATETIME(3) NOT
CALL add_column_if_missing('wh_express_company', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_express_company', 'version', 'INT NOT NULL DEFAULT 0');
-- 平台公共仓储主数据和平台往来单位统一归平台作用域 0;订单自身仍按客户 tenant_id 隔离。
UPDATE wh_warehouse SET tenant_id = 0 WHERE tenant_id <> 0;
UPDATE wh_warehouse_address a
JOIN wh_warehouse w ON w.id = a.warehouse_id
SET a.tenant_id = w.tenant_id
WHERE a.tenant_id <> w.tenant_id;
UPDATE wh_service_capability c
JOIN wh_warehouse w ON w.id = c.warehouse_id
SET c.tenant_id = w.tenant_id
WHERE c.tenant_id <> w.tenant_id;
UPDATE wh_warehouse_address SET tenant_id = 0 WHERE tenant_id <> 0;
UPDATE wh_service_capability SET tenant_id = 0 WHERE tenant_id <> 0;
UPDATE wh_express_company SET tenant_id = 0 WHERE tenant_id <> 0;
UPDATE wh_partner SET tenant_id = 0, data_scope_id = 0
WHERE tenant_id <> 0 OR data_scope_id <> 0;
UPDATE wh_partner_type SET tenant_id = 0, data_scope_id = 0
WHERE tenant_id <> 0 OR data_scope_id <> 0;
DROP PROCEDURE IF EXISTS assert_platform_public_resource_scope;
DELIMITER $$
CREATE PROCEDURE assert_platform_public_resource_scope()
BEGIN
IF (SELECT COUNT(*) FROM wh_warehouse WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM wh_warehouse_address WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM wh_service_capability WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM wh_express_company WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM wh_partner WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0
OR (SELECT COUNT(*) FROM wh_partner_type WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Warehouse 迁移失败:平台公共仓储或往来单位仍有非0作用域';
END IF;
IF EXISTS (
SELECT 1 FROM wh_warehouse_address a
JOIN wh_warehouse w ON w.id = a.warehouse_id
WHERE a.tenant_id <> w.tenant_id
) OR EXISTS (
SELECT 1 FROM wh_service_capability c
JOIN wh_warehouse w ON w.id = c.warehouse_id
WHERE c.tenant_id <> w.tenant_id
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Warehouse 迁移失败:仓库子表与父仓库平台归属不一致';
END IF;
END$$
DELIMITER ;
CALL assert_platform_public_resource_scope();
DROP PROCEDURE IF EXISTS assert_platform_public_resource_scope;
DROP PROCEDURE IF EXISTS add_column_if_missing;
-- MySQL 8 的 ALTER ADD INDEX 不在所有小版本都支持 IF NOT EXISTS,使用元数据判断以保证重复执行安全。
@@ -35,6 +35,12 @@ PREPARE statement FROM @sql;
EXECUTE statement;
DEALLOCATE PREPARE statement;
-- 托盘、库位、预占和占用均为平台公共仓储资源,存量测试数据统一迁移到平台作用域 0。
UPDATE wh_pallet SET tenant_id = 0 WHERE tenant_id <> 0;
UPDATE wh_location SET tenant_id = 0 WHERE tenant_id <> 0;
UPDATE wh_location_reservation SET tenant_id = 0 WHERE tenant_id <> 0;
UPDATE wh_location_occupancy SET tenant_id = 0 WHERE tenant_id <> 0;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'wh_location_reservation'
@@ -44,3 +50,43 @@ SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics
PREPARE statement FROM @sql;
EXECUTE statement;
DEALLOCATE PREPARE statement;
DROP PROCEDURE IF EXISTS assert_phase2_public_resource_scope;
DELIMITER $$
CREATE PROCEDURE assert_phase2_public_resource_scope()
BEGIN
IF (SELECT COUNT(*) FROM wh_pallet WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM wh_location WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM wh_location_reservation WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM wh_location_occupancy WHERE tenant_id <> 0) > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Warehouse 二期迁移失败:托盘、库位、预占或占用仍有 tenant_id<>0';
END IF;
IF EXISTS (
SELECT 1 FROM wh_pallet p JOIN wh_warehouse w ON w.id = p.warehouse_id
WHERE p.tenant_id <> w.tenant_id
) OR EXISTS (
SELECT 1 FROM wh_location l JOIN wh_warehouse w ON w.id = l.warehouse_id
WHERE l.tenant_id <> w.tenant_id
) OR EXISTS (
SELECT 1 FROM wh_location_reservation r
JOIN wh_warehouse w ON w.id = r.warehouse_id
JOIN wh_pallet p ON p.id = r.pallet_id
JOIN wh_location l ON l.id = r.location_id
WHERE r.tenant_id <> w.tenant_id OR r.tenant_id <> p.tenant_id OR r.tenant_id <> l.tenant_id
) OR EXISTS (
SELECT 1 FROM wh_location_occupancy o
JOIN wh_warehouse w ON w.id = o.warehouse_id
JOIN wh_pallet p ON p.id = o.pallet_id
JOIN wh_location l ON l.id = o.location_id
JOIN wh_location_reservation r ON r.id = o.reservation_id
WHERE o.tenant_id <> w.tenant_id OR o.tenant_id <> p.tenant_id
OR o.tenant_id <> l.tenant_id OR o.tenant_id <> r.tenant_id
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Warehouse 二期迁移失败:公共仓储资源父子归属不一致';
END IF;
END$$
DELIMITER ;
CALL assert_phase2_public_resource_scope();
DROP PROCEDURE IF EXISTS assert_phase2_public_resource_scope;
@@ -9,9 +9,9 @@ USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共托盘固定使用 tenant_id=0',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一托盘编码',
pallet_code VARCHAR(50) NOT NULL COMMENT '平台仓库内唯一托盘编码',
pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
pallet_name VARCHAR(100) NULL COMMENT '托盘名称',
capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM',
@@ -33,11 +33,11 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
CREATE TABLE IF NOT EXISTS wh_location (
id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共库位固定使用 tenant_id=0',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
zone_code VARCHAR(50) NULL COMMENT '库区编码',
zone_name VARCHAR(100) NULL COMMENT '库区名称',
location_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一库位编码',
location_code VARCHAR(50) NOT NULL COMMENT '平台仓库内唯一库位编码',
location_name VARCHAR(100) NULL COMMENT '库位名称',
location_type VARCHAR(32) NOT NULL DEFAULT 'PALLET' COMMENT '库位类型,二期为PALLET',
max_weight_kg DECIMAL(18,3) NULL COMMENT '最大承重展示值,二期不作容量拦截',
@@ -60,8 +60,8 @@ CREATE TABLE IF NOT EXISTS wh_location (
CREATE TABLE IF NOT EXISTS wh_location_reservation (
id BIGINT UNSIGNED NOT NULL COMMENT '库位预占ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
reservation_no VARCHAR(40) NOT NULL COMMENT '租户内唯一预占业务号',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共库位预占固定使用 tenant_id=0',
reservation_no VARCHAR(40) NOT NULL COMMENT '平台内唯一预占业务号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键',
source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID',
@@ -98,8 +98,8 @@ CREATE TABLE IF NOT EXISTS wh_location_reservation (
CREATE TABLE IF NOT EXISTS wh_location_occupancy (
id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
occupancy_no VARCHAR(40) NOT NULL COMMENT '租户内唯一占用业务号',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共库位占用固定使用 tenant_id=0',
occupancy_no VARCHAR(40) NOT NULL COMMENT '平台内唯一占用业务号',
reservation_id BIGINT UNSIGNED NOT NULL COMMENT '来源预占ID',
source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID',
@@ -3,8 +3,8 @@ USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_express_company (
id BIGINT UNSIGNED NOT NULL COMMENT '快递公司记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所属租户ID',
company_code VARCHAR(50) NOT NULL COMMENT '租户内唯一快递公司编码',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共快递公司固定使用 tenant_id=0',
company_code VARCHAR(50) NOT NULL COMMENT '平台内唯一快递公司编码',
company_name VARCHAR(128) NOT NULL COMMENT '快递公司名称',
service_phone VARCHAR(32) DEFAULT NULL COMMENT '客服电话',
website VARCHAR(255) DEFAULT NULL COMMENT '官方网站',
@@ -21,4 +21,4 @@ CREATE TABLE IF NOT EXISTS wh_express_company (
UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted),
UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted),
KEY idx_wh_express_tenant_status (tenant_id, status, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='租户维度快递公司主数据';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共快递公司主数据,固定归属 tenant_id=0';
@@ -16,18 +16,18 @@ CREATE TABLE IF NOT EXISTS wh_partner (
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted),
KEY idx_wh_partner_tenant_status(tenant_id,status,deleted)
) ENGINE=InnoDB COMMENT='仓储域内的租户往来单位配置';
) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据';
CREATE TABLE IF NOT EXISTS wh_partner_type (
partner_id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL,
data_scope_id BIGINT UNSIGNED NOT NULL, partner_type VARCHAR(32) NOT NULL,
PRIMARY KEY(partner_id,partner_type),
KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id)
) ENGINE=InnoDB COMMENT='往来单位稳定业务类型';
) ENGINE=InnoDB COMMENT='平台共享往来单位稳定业务类型';
INSERT INTO wh_partner(id,tenant_id,data_scope_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status)
VALUES(191000000000000001,10001,10001,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE')
ON DUPLICATE KEY UPDATE partner_name=VALUES(partner_name),status='ACTIVE';
VALUES(191000000000000001,0,0,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE')
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),data_scope_id=VALUES(data_scope_id),partner_name=VALUES(partner_name),status='ACTIVE';
INSERT INTO wh_partner_type(partner_id,tenant_id,data_scope_id,partner_type)
VALUES
(191000000000000001,10001,10001,'CUSTOMER'),
(191000000000000001,10001,10001,'SHIPPER')
(191000000000000001,0,0,'CUSTOMER'),
(191000000000000001,0,0,'SHIPPER')
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),data_scope_id=VALUES(data_scope_id);
@@ -2,16 +2,16 @@ SET NAMES utf8mb4;
USE baiyue_warehouse;
-- 一期联调初始化仓库,重复执行不会产生重复数据。
INSERT INTO wh_warehouse(id,tenant_id,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,contact_name,contact_phone,status,remark)
VALUES(190000000000000001,10001,'CN-SZX-01','深圳头程收货仓','DOMESTIC','CN','Asia/Shanghai','仓库收货组','0755-88886666','ACTIVE','工作日预约收货')
ON DUPLICATE KEY UPDATE warehouse_name=VALUES(warehouse_name),status='ACTIVE';
VALUES(190000000000000001,0,'CN-SZX-01','深圳头程收货仓','DOMESTIC','CN','Asia/Shanghai','仓库收货组','0755-88886666','ACTIVE','工作日预约收货')
ON DUPLICATE KEY UPDATE tenant_id=0,warehouse_name=VALUES(warehouse_name),status='ACTIVE';
INSERT INTO wh_warehouse_address(id,tenant_id,warehouse_id,address_type,country_code,state_province,city,district,postal_code,address_line1)
VALUES(190000000000000011,10001,190000000000000001,'OPERATING','CN','广东省','深圳市','宝安区','518000','航城街道示例物流园1号仓')
ON DUPLICATE KEY UPDATE address_line1=VALUES(address_line1);
VALUES(190000000000000011,0,190000000000000001,'OPERATING','CN','广东省','深圳市','宝安区','518000','航城街道示例物流园1号仓')
ON DUPLICATE KEY UPDATE tenant_id=0,address_line1=VALUES(address_line1);
INSERT INTO wh_service_capability(id,tenant_id,warehouse_id,capability_code,capability_name,enabled,cutoff_time,description)
VALUES(190000000000000021,10001,190000000000000001,'RECEIVING','头程收货',1,'16:00:00','工作日16点前预约可当日收货')
ON DUPLICATE KEY UPDATE enabled=1,cutoff_time=VALUES(cutoff_time);
VALUES(190000000000000021,0,190000000000000001,'RECEIVING','头程收货',1,'16:00:00','工作日16点前预约可当日收货')
ON DUPLICATE KEY UPDATE tenant_id=0,enabled=1,cutoff_time=VALUES(cutoff_time);
-- 一期联调快递公司主数据,供平台维护和订单履约回显验证。
INSERT INTO wh_express_company(id,tenant_id,company_code,company_name,service_phone,website,tracking_url_template,status,remark)
VALUES(192000000000000001,10001,'SF','顺丰速运','95338','https://www.sf-express.com','https://www.sf-express.com/chn/sc/dynamic_function/waybill/#search/bill-number/{trackingNo}','ACTIVE','一期联调样本,可按租户回收')
ON DUPLICATE KEY UPDATE company_name=VALUES(company_name),status='ACTIVE';
VALUES(192000000000000001,0,'SF','顺丰速运','95338','https://www.sf-express.com','https://www.sf-express.com/chn/sc/dynamic_function/waybill/#search/bill-number/{trackingNo}','ACTIVE','平台公共快递公司样本')
ON DUPLICATE KEY UPDATE tenant_id=0,company_name=VALUES(company_name),status='ACTIVE';
@@ -5,8 +5,8 @@ USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_warehouse (
id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID',
warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库固定使用 tenant_id=0',
warehouse_code VARCHAR(50) NOT NULL COMMENT '平台内唯一仓库编码',
warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称',
warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC、FBA、OVERSEAS',
country_code CHAR(2) NOT NULL COMMENT '所在国家二字码',
@@ -27,11 +27,11 @@ CREATE TABLE IF NOT EXISTS wh_warehouse (
KEY idx_wh_tenant_status (tenant_id, status, deleted),
KEY idx_wh_tenant_type_status (tenant_id, warehouse_type, status, deleted),
KEY idx_wh_country (tenant_id, country_code, deleted)
) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态';
) ENGINE=InnoDB COMMENT='平台公共仓库主数据表:统一归属平台作用域 tenant_id=0';
CREATE TABLE IF NOT EXISTS wh_warehouse_address (
id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库地址固定使用 tenant_id=0',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型,例如OPERATING或RETURN',
country_code CHAR(2) NOT NULL COMMENT '国家二字码',
@@ -56,7 +56,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse_address (
CREATE TABLE IF NOT EXISTS wh_service_capability (
id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库服务能力固定使用 tenant_id=0',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等',
capability_name VARCHAR(100) NOT NULL COMMENT '能力名称',
@@ -0,0 +1,40 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MerchantControllerIdentityContractTest {
@Test
void merchantControllersRequirePositiveCustomerScope() throws Exception {
var valid = user("MERCHANT", 10001L);
for (Class<?> controller : Set.of(MerchantWarehouseController.class, MerchantPartnerController.class)) {
Method guard = controller.getDeclaredMethod("requireMerchant", AuthenticatedUser.class);
guard.setAccessible(true);
guard.invoke(null, valid);
for (AuthenticatedUser invalid : new AuthenticatedUser[] {
null, user("PLATFORM", 0L), user("MERCHANT", 0L), user("MERCHANT", -1L)}) {
assertThatThrownBy(() -> guard.invoke(null, invalid))
.hasCauseInstanceOf(BizException.class);
}
}
}
@Test
void merchantWarehouseDefaultsToPlatformResourceScope() throws Exception {
var value = MerchantWarehouseController.class.getDeclaredField("publicTenantId")
.getAnnotation(org.springframework.beans.factory.annotation.Value.class);
assertThat(value.value()).isEqualTo("${baiyue.warehouse.public-tenant-id:0}");
}
private static AuthenticatedUser user(String appCode, Long tenantId) {
return new AuthenticatedUser(1L, tenantId, tenantId, 1L,
"user", appCode, "WEB", Set.of());
}
}
@@ -3,6 +3,8 @@ package com.baiyue.warehouse.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.validation.Validation;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PlatformMasterDataControllerContractTest {
@Test
@@ -77,6 +80,36 @@ class PlatformMasterDataControllerContractTest {
assertMethod(PlatformExpressCompanyController.class, "importCompanies", PostMapping.class, "platform:express:import");
}
@Test
void platformResourcesUseScopeZeroAndRejectCustomerScope() throws Exception {
for (Class<?> controller : java.util.List.of(
PlatformWarehouseController.class, PlatformPartnerController.class, PlatformExpressCompanyController.class,
PlatformPalletController.class, PlatformLocationController.class)) {
var scope = java.util.Arrays.stream(controller.getDeclaredMethods())
.filter(method -> method.getName().equals("requireTenant")
|| method.getName().equals("requirePlatformScope"))
.findFirst().orElseThrow();
scope.setAccessible(true);
scope.invoke(null, 0L);
assertThatThrownBy(() -> scope.invoke(null, 10001L))
.hasCauseInstanceOf(BizException.class);
}
}
@Test
void platformIdentityMustCarryTenantZero() throws Exception {
var malformed = new AuthenticatedUser(1L, 10001L, 10001L, 1L,
"admin", "PLATFORM", "WEB", java.util.Set.of());
for (Class<?> controller : java.util.List.of(
PlatformWarehouseController.class, PlatformPartnerController.class, PlatformExpressCompanyController.class,
PlatformPalletController.class, PlatformLocationController.class)) {
var guard = controller.getDeclaredMethod("requirePlatform", AuthenticatedUser.class);
guard.setAccessible(true);
assertThatThrownBy(() -> guard.invoke(null, malformed))
.hasCauseInstanceOf(BizException.class);
}
}
private static void assertContract(Class<?> controller, String authority, String path) throws Exception {
RequestMapping mapping = controller.getAnnotation(RequestMapping.class);
assertThat(mapping.value()).containsExactly(path);
@@ -13,9 +13,9 @@ import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformPartnerAllTenantQueryContractTest {
class PlatformPartnerPublicScopeContractTest {
@Test
void keepsTenantRequiredOutsidePaging() {
void keepsOnlyCompatibilityTenantParameterOutsidePaging() {
Method page = method(PlatformPartnerController.class, "page");
assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class);
assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse();
@@ -27,6 +27,15 @@ class PlatformPartnerAllTenantQueryContractTest {
}
}
@Test
void exposesPlatformScopeGuardForSharedMasterData() throws Exception {
Method guard = method(PlatformPartnerController.class, "requirePlatformScope");
guard.setAccessible(true);
guard.invoke(null, 0L);
org.assertj.core.api.Assertions.assertThatThrownBy(() -> guard.invoke(null, 10001L))
.hasCauseInstanceOf(com.baiyue.common.core.exception.BizException.class);
}
@Test
void keepsTenantScopeForOtherMethods() throws Exception {
assertThat(PlatformPartnerQueryService.class.getMethod("page", Long.class, PartnerPageQuery.class))
@@ -21,4 +21,21 @@ class PartnerMapperContractTest {
.contains("<otherwise>")
.contains("pt.partner_type = #{type}");
}
@Test
void partnerTypesAreScopedByTenantForReadAndDelete() throws Exception {
Select select = PartnerMapper.class.getDeclaredMethod("selectTypes",
java.util.Collection.class, java.util.Collection.class).getAnnotation(Select.class);
assertThat(String.join("\n", select.value()))
.contains("tenant_id AS tenantId")
.contains("tenant_id IN")
.contains("partner_id IN");
org.apache.ibatis.annotations.Delete delete = PartnerMapper.class
.getDeclaredMethod("deleteTypes", long.class, long.class)
.getAnnotation(org.apache.ibatis.annotations.Delete.class);
assertThat(String.join("\n", delete.value()))
.contains("tenant_id = #{tenantId}")
.contains("partner_id = #{partnerId}");
}
}
@@ -26,6 +26,6 @@ class OverseasStorageFeeSqlContractTest {
.contains("platform:overseas-storage-fee:edit")
.contains("platform:overseas-storage-fee:delete")
.contains("CALL unified_assert_overseas_storage_fee()")
.contains("海外仓阶梯费用表、索引和权限校验通过");
.contains("统一增量全部执行完成");
}
}
@@ -2,6 +2,7 @@ package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.entity.PartnerTypeEntity;
import com.baiyue.warehouse.mapper.PartnerMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -82,4 +83,40 @@ class PartnerRepositoryTest {
eq("ACTIVE"), isNull());
verify(mapper, never()).selectPartnerIdsByType(anyLong(), anyString());
}
@Test
void hydratesSamePartnerIdUsingEachCustomersTypes() {
PartnerEntity customerA = new PartnerEntity();
customerA.setId(7L); customerA.setTenantId(10001L); customerA.setPartnerType("CUSTOMER");
PartnerEntity customerB = new PartnerEntity();
customerB.setId(7L); customerB.setTenantId(10002L); customerB.setPartnerType("SUPPLIER");
Page<PartnerEntity> source = new Page<>(1, 20);
source.setRecords(List.of(customerA, customerB));
source.setTotal(2);
when(mapper.selectPage(any(Page.class), any())).thenReturn(source);
PartnerTypeEntity typeA = new PartnerTypeEntity();
typeA.setTenantId(10001L); typeA.setPartnerId(7L); typeA.setPartnerType("CUSTOMER");
PartnerTypeEntity typeB = new PartnerTypeEntity();
typeB.setTenantId(10002L); typeB.setPartnerId(7L); typeB.setPartnerType("SUPPLIER");
when(mapper.selectTypes(List.of(10001L, 10002L), List.of(7L, 7L)))
.thenReturn(List.of(typeA, typeB));
repository.page(null, new PartnerPageQuery(1, 20, null, null, null));
assertThat(customerA.getTypes()).containsExactly("CUSTOMER");
assertThat(customerB.getTypes()).containsExactly("SUPPLIER");
}
@Test
void replacesOnlyTheCurrentCustomersTypes() {
PartnerEntity entity = new PartnerEntity();
entity.setId(7L); entity.setTenantId(10001L); entity.setDataScopeId(10001L);
entity.setTypes(List.of("CUSTOMER"));
repository.save(entity);
verify(mapper).deleteTypes(10001L, 7L);
verify(mapper).insertTypes(7L, 10001L, 10001L, List.of("CUSTOMER"));
}
}
@@ -30,13 +30,13 @@ class ExpressCompanyImportServiceTest {
{"SF", "顺丰速运", "95338", "https://www.sf-express.com", "https://example.com/{trackingNo}", "ACTIVE", "国内件"}
});
var result = service.importFile(10001L, "express.xlsx", workbook);
var result = service.importFile(0L, "express.xlsx", workbook);
assertThat(result.success()).isTrue();
assertThat(result.importedRows()).isEqualTo(1);
var captor = org.mockito.ArgumentCaptor.forClass(ExpressCompanyEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getTenantId()).isEqualTo(10001L);
assertThat(captor.getValue().getTenantId()).isZero();
assertThat(captor.getValue().getCompanyCode()).isEqualTo("SF");
}
@@ -48,7 +48,7 @@ class ExpressCompanyImportServiceTest {
{"SF2", "顺丰速运", "", "", "", "ACTIVE", ""}
});
var result = service.importFile(10001L, "express.xlsx", workbook);
var result = service.importFile(0L, "express.xlsx", workbook);
assertThat(result.success()).isFalse();
assertThat(result.importedRows()).isZero();
@@ -33,13 +33,14 @@ class PartnerImportServiceTest {
"CN", "广东省", "深圳市", "南山区", "测试路 1 号", "518000", "ACTIVE", "测试备注"}
});
PartnerImportResult result = service.importFile(10001L, "partners.xlsx", file);
PartnerImportResult result = service.importFile(0L, "partners.xlsx", file);
assertThat(result.success()).isTrue();
assertThat(result.importedRows()).isEqualTo(1);
var captor = org.mockito.ArgumentCaptor.forClass(PartnerEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getDataScopeId()).isEqualTo(10001L);
assertThat(captor.getValue().getTenantId()).isZero();
assertThat(captor.getValue().getDataScopeId()).isZero();
assertThat(captor.getValue().getTypes()).containsExactly("CUSTOMER", "SHIPPER");
assertThat(captor.getValue().getRemark()).isEqualTo("测试备注");
}
@@ -52,7 +53,7 @@ class PartnerImportServiceTest {
{"CUS-100", "测试单位二", "UNKNOWN", "李四", "13900139000", "bad-email", "C", "", "", "", "", "", "ENABLED", ""}
});
PartnerImportResult result = service.importFile(10001L, "partners.xlsx", file);
PartnerImportResult result = service.importFile(0L, "partners.xlsx", file);
assertThat(result.success()).isFalse();
assertThat(result.importedRows()).isZero();
@@ -19,11 +19,11 @@ class PartnerQueryServiceTest {
@MockBean private PartnerRepository repository;
@Resource private PartnerQueryService service;
@Test void unsupportedCandidateTypeReturnsBadRequest() {
assertThatThrownBy(() -> service.candidates(10001L, null, "CUSTOMER"))
assertThatThrownBy(() -> service.candidates(0L, null, "CUSTOMER"))
.isInstanceOf(BizException.class).hasMessageContaining("SHIPPER");
}
@Test void missingOrCrossTenantPartnerReturnsNotFound() {
assertThatThrownBy(() -> service.active(10001L, 7L))
@Test void missingPublicPartnerReturnsNotFound() {
assertThatThrownBy(() -> service.active(0L, 7L))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
@@ -31,10 +31,10 @@ class PartnerQueryServiceTest {
@Test void shouldHandleCase01() {
PartnerEntity partner = new PartnerEntity();
partner.setId(7L); partner.setTenantId(10001L); partner.setStatus("INACTIVE");
when(repository.findOwned(10001L, 7L)).thenReturn(partner);
partner.setId(7L); partner.setTenantId(0L); partner.setStatus("INACTIVE");
when(repository.findOwned(0L, 7L)).thenReturn(partner);
assertThatThrownBy(() -> service.active(10001L, 7L))
assertThatThrownBy(() -> service.active(0L, 7L))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
@@ -42,9 +42,16 @@ class PartnerQueryServiceTest {
@Test void shouldHandleCase02() {
PartnerEntity partner = new PartnerEntity();
partner.setId(7L); partner.setTenantId(10001L); partner.setStatus("ACTIVE");
when(repository.findOwned(10001L, 7L)).thenReturn(partner);
partner.setId(7L); partner.setTenantId(0L); partner.setStatus("ACTIVE");
when(repository.findOwned(0L, 7L)).thenReturn(partner);
assertThat(service.active(10001L, 7L)).isSameAs(partner);
assertThat(service.active(0L, 7L)).isSameAs(partner);
}
@Test void customerScopeIsRejectedForSharedPartner() {
assertThatThrownBy(() -> service.active(10001L, 7L))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.BAD_REQUEST);
}
}
@@ -35,17 +35,17 @@ class PlatformMasterDataManagementServiceTest {
PartnerRepository repository = partnerRepository;
PlatformPartnerQueryService service = partnerService;
assertThatThrownBy(() -> service.detail(10002L, 1L)).isInstanceOf(BizException.class);
verify(repository).findOwned(10002L, 1L);
assertThatThrownBy(() -> service.detail(0L, 1L)).isInstanceOf(BizException.class);
verify(repository).findOwned(0L, 1L);
}
@Test
void shouldHandleCase02() {
PartnerRepository repository = partnerRepository;
when(repository.existsCode(10001L, "CUS-001", null)).thenReturn(true);
when(repository.existsCode(0L, "CUS-001", null)).thenReturn(true);
PlatformPartnerQueryService service = partnerService;
assertThatThrownBy(() -> service.create(10001L, partnerRequest()))
assertThatThrownBy(() -> service.create(0L, partnerRequest()))
.isInstanceOf(BizException.class).hasMessageContaining("编码");
verify(repository, never()).insert(any());
}
@@ -56,7 +56,7 @@ class PlatformMasterDataManagementServiceTest {
doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any());
PlatformPartnerQueryService service = partnerService;
assertThatThrownBy(() -> service.create(10001L, partnerRequest()))
assertThatThrownBy(() -> service.create(0L, partnerRequest()))
.isInstanceOf(BizException.class).hasMessageContaining("编码");
}
@@ -70,12 +70,12 @@ class PlatformMasterDataManagementServiceTest {
}).when(repository).insert(any());
PlatformPartnerQueryService service = partnerService;
assertThat(service.create(10001L, partnerRequest())).isEqualTo(18L);
assertThat(service.create(0L, partnerRequest())).isEqualTo(18L);
org.mockito.ArgumentCaptor<PartnerEntity> entity = org.mockito.ArgumentCaptor.forClass(PartnerEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getTenantId()).isEqualTo(10001L);
assertThat(entity.getValue().getDataScopeId()).isEqualTo(10001L);
assertThat(entity.getValue().getTenantId()).isZero();
assertThat(entity.getValue().getDataScopeId()).isZero();
assertThat(entity.getValue().getPartnerCode()).isEqualTo("CUS-001");
assertThat(entity.getValue().getPartnerName()).isEqualTo("示例客户");
assertThat(entity.getValue().getTypes()).containsExactly("CUSTOMER", "SHIPPER");
@@ -87,13 +87,13 @@ class PlatformMasterDataManagementServiceTest {
PartnerRepository repository = partnerRepository;
PartnerEntity entity = new PartnerEntity();
entity.setId(19L);
entity.setTenantId(10001L);
when(repository.findOwned(10001L, 19L)).thenReturn(entity);
entity.setTenantId(0L);
when(repository.findOwned(0L, 19L)).thenReturn(entity);
PlatformPartnerQueryService service = partnerService;
service.update(10001L, 19L, partnerRequest());
service.update(0L, 19L, partnerRequest());
assertThat(entity.getTenantId()).isEqualTo(10001L);
assertThat(entity.getTenantId()).isZero();
assertThat(entity.getPartnerName()).isEqualTo("示例客户");
verify(repository).save(entity);
}
@@ -103,12 +103,12 @@ class PlatformMasterDataManagementServiceTest {
PartnerRepository repository = partnerRepository;
PartnerEntity entity = new PartnerEntity();
entity.setId(8L);
entity.setTenantId(10001L);
entity.setTenantId(0L);
entity.setStatus("ACTIVE");
when(repository.findOwned(10001L, 8L)).thenReturn(entity);
when(repository.findOwned(0L, 8L)).thenReturn(entity);
PlatformPartnerQueryService service = partnerService;
service.changeStatus(10001L, 8L, "INACTIVE");
service.changeStatus(0L, 8L, "INACTIVE");
assertThat(entity.getStatus()).isEqualTo("INACTIVE");
verify(repository).save(entity);
@@ -119,29 +119,29 @@ class PlatformMasterDataManagementServiceTest {
ExpressCompanyRepository repository = expressRepository;
ExpressCompanyQueryService service = expressService;
assertThatThrownBy(() -> service.detail(10002L, 1L)).isInstanceOf(BizException.class);
verify(repository).findOwned(10002L, 1L);
assertThatThrownBy(() -> service.detail(0L, 1L)).isInstanceOf(BizException.class);
verify(repository).findOwned(0L, 1L);
}
@Test
void shouldHandleCase08() {
ExpressCompanyRepository repository = expressRepository;
when(repository.existsCode(10001L, "SF", null)).thenReturn(true);
when(repository.existsCode(0L, "SF", null)).thenReturn(true);
ExpressCompanyQueryService service = expressService;
assertThatThrownBy(() -> service.create(10001L, expressRequest()))
assertThatThrownBy(() -> service.create(0L, expressRequest()))
.isInstanceOf(BizException.class).hasMessageContaining("编码");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("快递公司名称在租户内必须唯一")
@DisplayName("快递公司名称在平台范围内必须唯一")
void shouldRequireUniqueExpressCompanyNameWithinTenant() {
ExpressCompanyRepository repository = expressRepository;
when(repository.existsName(10001L, "顺丰速运", null)).thenReturn(true);
when(repository.existsName(0L, "顺丰速运", null)).thenReturn(true);
ExpressCompanyQueryService service = expressService;
assertThatThrownBy(() -> service.create(10001L, expressRequest()))
assertThatThrownBy(() -> service.create(0L, expressRequest()))
.isInstanceOf(BizException.class).hasMessageContaining("名称");
verify(repository, never()).insert(any());
}
@@ -153,7 +153,7 @@ class PlatformMasterDataManagementServiceTest {
doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any());
ExpressCompanyQueryService service = expressService;
assertThatThrownBy(() -> service.create(10001L, expressRequest()))
assertThatThrownBy(() -> service.create(0L, expressRequest()))
.isInstanceOf(BizException.class).hasMessageContaining("编码");
}
@@ -167,12 +167,12 @@ class PlatformMasterDataManagementServiceTest {
}).when(repository).insert(any());
ExpressCompanyQueryService service = expressService;
assertThat(service.create(10001L, expressRequest())).isEqualTo(28L);
assertThat(service.create(0L, expressRequest())).isEqualTo(28L);
org.mockito.ArgumentCaptor<ExpressCompanyEntity> entity =
org.mockito.ArgumentCaptor.forClass(ExpressCompanyEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getTenantId()).isEqualTo(10001L);
assertThat(entity.getValue().getTenantId()).isZero();
assertThat(entity.getValue().getCompanyCode()).isEqualTo("SF");
assertThat(entity.getValue().getCompanyName()).isEqualTo("顺丰速运");
}
@@ -182,13 +182,13 @@ class PlatformMasterDataManagementServiceTest {
ExpressCompanyRepository repository = expressRepository;
ExpressCompanyEntity entity = new ExpressCompanyEntity();
entity.setId(29L);
entity.setTenantId(10001L);
when(repository.findOwned(10001L, 29L)).thenReturn(entity);
entity.setTenantId(0L);
when(repository.findOwned(0L, 29L)).thenReturn(entity);
ExpressCompanyQueryService service = expressService;
service.update(10001L, 29L, expressRequest());
service.update(0L, 29L, expressRequest());
assertThat(entity.getTenantId()).isEqualTo(10001L);
assertThat(entity.getTenantId()).isZero();
assertThat(entity.getCompanyName()).isEqualTo("顺丰速运");
verify(repository).save(entity);
}
@@ -198,12 +198,12 @@ class PlatformMasterDataManagementServiceTest {
ExpressCompanyRepository repository = expressRepository;
ExpressCompanyEntity entity = new ExpressCompanyEntity();
entity.setId(9L);
entity.setTenantId(10001L);
entity.setTenantId(0L);
entity.setStatus("ACTIVE");
when(repository.findOwned(10001L, 9L)).thenReturn(entity);
when(repository.findOwned(0L, 9L)).thenReturn(entity);
ExpressCompanyQueryService service = expressService;
service.changeStatus(10001L, 9L, "INACTIVE");
service.changeStatus(0L, 9L, "INACTIVE");
assertThat(entity.getStatus()).isEqualTo("INACTIVE");
verify(repository).save(entity);