暂存提交

This commit is contained in:
2026-07-31 16:53:07 +08:00
parent 45358ef40f
commit 704cb15ccf
98 changed files with 1944 additions and 134 deletions
+5 -1
View File
@@ -16,11 +16,13 @@
| 内部往来单位有效性校验 | `controller/InternalPartnerController.java` |
| 平台往来单位增改查与启停 | `controller/PlatformPartnerController.java`、`service/PlatformPartnerQueryService.java` |
| 平台快递公司增改查与启停 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java` |
| 服务与数据库结构状态 | `controller/WarehouseHealthController.java`、`service/WarehouseSchemaReadinessService.java` |
| 数据访问异常映射 | `controller/WarehouseDataAccessExceptionHandler.java`;数据库不可用或结构漂移统一返回 `S0503/503` |
| 仓库业务编排 | `service/WarehouseApplicationService.java` |
| 往来单位查询与停用过滤 | `service/PartnerQueryService.java` |
| 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java`、`MasterDataQueries.java` |
| 仓库、地址、能力、往来单位与快递公司持久化 | `entity/`、`mapper/`、`repository/` |
| 表结构、伙伴、快递公司与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`warehouse-seed.sql` |
| 表结构、伙伴、快递公司与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` |
| 测试 | `src/test/java/com/baiyue/warehouse/` |
## 修改与验证
@@ -28,4 +30,6 @@
- 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。
- 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。
- 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。
- 既有数据库发布必须按 `warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` 的顺序执行;服务不在启动时自动跑 SQL。
- `/api/platform/v1/warehouses/status` 必须执行真实字段探针;缺表或缺列时返回 `DEGRADED/503`,不得只根据进程存活伪报 `UP`。
- 从父目录运行 `mvn -pl warehouse -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1`。
@@ -0,0 +1,31 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.web.CorrelationContext;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.dao.DataAccessException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** 仓储数据库不可用或结构漂移时返回可识别的 503,并保留完整链路日志。 */
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "com.baiyue.warehouse.controller")
public class WarehouseDataAccessExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(WarehouseDataAccessExceptionHandler.class);
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ApiResponse<Void>> handle(DataAccessException exception, HttpServletRequest request) {
String requestId = CorrelationContext.requestId(request);
String traceId = CorrelationContext.traceId(request);
log.error("Warehouse data access failure requestId={} traceId={}", requestId, traceId, exception);
ResultCode code = ResultCode.SERVICE_UNAVAILABLE;
return ResponseEntity.status(code.httpStatus()).body(ApiResponse.failure(code,
"仓储数据库暂不可用,请检查数据源和数据库迁移", requestId, traceId));
}
}
@@ -1,11 +1,13 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.warehouse.service.WarehouseSchemaReadinessService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.ResponseEntity;
import java.util.Map;
@@ -16,11 +18,22 @@ import java.util.Map;
@RestController
@RequestMapping("/api/platform/v1/warehouses")
public class WarehouseHealthController {
private final WarehouseSchemaReadinessService readinessService;
public WarehouseHealthController(WarehouseSchemaReadinessService readinessService) {
this.readinessService = readinessService;
}
@Operation(summary = "Get service status / 查询服务状态", description = "Returns the logical status of warehouse-service. / 返回 warehouse-service 的逻辑运行状态。")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Service is available / 服务可用")
@GetMapping("/status")
public ApiResponse<Map<String, String>> status() {
return ApiResponse.success(Map.of("service", "warehouse-service", "status", "UP"));
public ResponseEntity<ApiResponse<Map<String, String>>> status() {
var readiness = readinessService.readiness();
var body = ApiResponse.success(Map.of(
"service", "warehouse-service",
"status", readiness.status(),
"message", readiness.message()));
return ResponseEntity.status(readiness.ready() ? 200 : 503).body(body);
}
}
@@ -4,26 +4,43 @@ 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
/** 仓储域内的租户往来单位配置。 */
@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")
private Long tenantId;
@Schema(description = "租户内唯一往来单位编码", example = "CUS-001")
private String partnerCode;
@Schema(description = "往来单位名称", example = "深圳示例贸易有限公司")
private String partnerName;
@Schema(description = "类型", example = "CUSTOMER", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"})
private String partnerType;
@Schema(description = "联系人")
private String contactName;
@Schema(description = "联系电话")
private String contactPhone;
@Schema(description = "联系邮箱")
private String contactEmail;
@Schema(description = "国家二字码", example = "CN")
private String countryCode;
@Schema(description = "省或州")
private String province;
@Schema(description = "城市")
private String city;
@Schema(description = "区县")
private String district;
@Schema(description = "详细地址")
private String addressLine;
@Schema(description = "邮政编码")
private String postalCode;
@Schema(description = "状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"})
private String status;
}
@@ -0,0 +1,40 @@
package com.baiyue.warehouse.service;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 仓储库表结构就绪检查。状态接口通过真实字段探针判断数据库是否已执行一期迁移,
* 避免服务进程存活但所有业务查询因缺表或缺列返回 500。
*/
@Service
public class WarehouseSchemaReadinessService {
private static final List<String> PROBES = List.of(
"SELECT id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, status, deleted FROM wh_warehouse LIMIT 0",
"SELECT id, tenant_id, warehouse_id, address_type, country_code, city, address_line1, deleted FROM wh_warehouse_address LIMIT 0",
"SELECT id, tenant_id, warehouse_id, capability_code, capability_name, enabled, deleted FROM wh_service_capability LIMIT 0",
"SELECT id, tenant_id, partner_code, partner_name, partner_type, status, deleted FROM wh_partner LIMIT 0",
"SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0"
);
private final JdbcTemplate jdbcTemplate;
public WarehouseSchemaReadinessService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public Readiness readiness() {
try {
PROBES.forEach(jdbcTemplate::execute);
return new Readiness(true, "UP", "仓储数据库表结构已就绪");
} catch (DataAccessException exception) {
return new Readiness(false, "DEGRADED", "仓储数据库表结构未就绪,请执行一期迁移脚本");
}
}
public record Readiness(boolean ready, String status, String message) {
}
}
@@ -0,0 +1,147 @@
-- 既有 baiyue_warehouse 的一期增量迁移(只增不删,可重复执行)。
-- 执行顺序:warehouse.sql、warehouse-partner.sql、warehouse-express-company.sql、
-- 本文件、warehouse-seed.sql。应用不会在启动时自动执行这些脚本,必须由发布流程执行。
SET NAMES utf8mb4;
USE baiyue_warehouse;
-- MySQL 5.7/8.0 各小版本对条件补列语法的支持不一致。
-- 统一先查 information_schema,再通过动态 SQL 补列,确保脚本可重复执行。
DROP PROCEDURE IF EXISTS add_column_if_missing;
DELIMITER $$
CREATE PROCEDURE add_column_if_missing(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND column_name = p_column_name
) THEN
SET @add_column_sql = CONCAT(
'ALTER TABLE `', REPLACE(p_table_name, '`', '``'),
'` ADD COLUMN `', REPLACE(p_column_name, '`', '``'),
'` ', p_column_definition
);
PREPARE add_column_statement FROM @add_column_sql;
EXECUTE add_column_statement;
DEALLOCATE PREPARE add_column_statement;
END IF;
END$$
DELIMITER ;
-- 早期 wh_warehouse 缺少的业务与审计字段。
CALL add_column_if_missing('wh_warehouse', 'warehouse_type', 'VARCHAR(32) NOT NULL DEFAULT ''DOMESTIC'' COMMENT ''仓库类型'' AFTER warehouse_name');
CALL add_column_if_missing('wh_warehouse', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' COMMENT ''所在国家二字码'' AFTER warehouse_type');
CALL add_column_if_missing('wh_warehouse', 'timezone', 'VARCHAR(64) NOT NULL DEFAULT ''Asia/Shanghai'' COMMENT ''IANA时区'' AFTER country_code');
CALL add_column_if_missing('wh_warehouse', 'contact_name', 'VARCHAR(64) NULL AFTER timezone');
CALL add_column_if_missing('wh_warehouse', 'contact_phone', 'VARCHAR(32) NULL AFTER contact_name');
CALL add_column_if_missing('wh_warehouse', 'contact_email', 'VARCHAR(128) NULL AFTER contact_phone');
CALL add_column_if_missing('wh_warehouse', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER contact_email');
CALL add_column_if_missing('wh_warehouse', 'remark', 'VARCHAR(500) NULL AFTER status');
CALL add_column_if_missing('wh_warehouse', 'created_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_warehouse', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_warehouse', 'updated_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_warehouse', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_warehouse', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_warehouse', 'version', 'INT NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_warehouse_address', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id');
CALL add_column_if_missing('wh_warehouse_address', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id');
CALL add_column_if_missing('wh_warehouse_address', 'address_type', 'VARCHAR(20) NOT NULL DEFAULT ''OPERATING'' AFTER warehouse_id');
CALL add_column_if_missing('wh_warehouse_address', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER address_type');
CALL add_column_if_missing('wh_warehouse_address', 'state_province', 'VARCHAR(100) NULL');
CALL add_column_if_missing('wh_warehouse_address', 'city', 'VARCHAR(100) NOT NULL DEFAULT '''' AFTER state_province');
CALL add_column_if_missing('wh_warehouse_address', 'district', 'VARCHAR(100) NULL');
CALL add_column_if_missing('wh_warehouse_address', 'postal_code', 'VARCHAR(20) NULL');
CALL add_column_if_missing('wh_warehouse_address', 'address_line1', 'VARCHAR(255) NOT NULL DEFAULT '''' AFTER postal_code');
CALL add_column_if_missing('wh_warehouse_address', 'address_line2', 'VARCHAR(255) NULL');
CALL add_column_if_missing('wh_warehouse_address', 'latitude', 'DECIMAL(10,7) NULL');
CALL add_column_if_missing('wh_warehouse_address', 'longitude', 'DECIMAL(10,7) NULL');
CALL add_column_if_missing('wh_warehouse_address', 'created_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_warehouse_address', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_warehouse_address', 'updated_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_warehouse_address', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_warehouse_address', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_warehouse_address', 'version', 'INT NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_service_capability', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id');
CALL add_column_if_missing('wh_service_capability', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id');
CALL add_column_if_missing('wh_service_capability', 'capability_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER warehouse_id');
CALL add_column_if_missing('wh_service_capability', 'capability_name', 'VARCHAR(100) NOT NULL DEFAULT '''' AFTER capability_code');
CALL add_column_if_missing('wh_service_capability', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1');
CALL add_column_if_missing('wh_service_capability', 'cutoff_time', 'TIME NULL');
CALL add_column_if_missing('wh_service_capability', 'description', 'VARCHAR(500) NULL');
CALL add_column_if_missing('wh_service_capability', 'created_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_service_capability', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_service_capability', 'updated_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_service_capability', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_service_capability', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_service_capability', 'version', 'INT NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id');
CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id');
CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code');
CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name');
CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type');
CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name');
CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL');
CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email');
CALL add_column_if_missing('wh_partner', 'province', 'VARCHAR(64) NULL');
CALL add_column_if_missing('wh_partner', 'city', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER province');
CALL add_column_if_missing('wh_partner', 'district', 'VARCHAR(64) NULL');
CALL add_column_if_missing('wh_partner', 'address_line', 'VARCHAR(255) NOT NULL DEFAULT '''' AFTER district');
CALL add_column_if_missing('wh_partner', 'postal_code', 'VARCHAR(20) NULL');
CALL add_column_if_missing('wh_partner', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER postal_code');
CALL add_column_if_missing('wh_partner', 'created_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_partner', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0');
-- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。
CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id');
CALL add_column_if_missing('wh_express_company', 'company_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id');
CALL add_column_if_missing('wh_express_company', 'company_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER company_code');
CALL add_column_if_missing('wh_express_company', 'service_phone', 'VARCHAR(32) NULL');
CALL add_column_if_missing('wh_express_company', 'website', 'VARCHAR(255) NULL');
CALL add_column_if_missing('wh_express_company', 'tracking_url_template', 'VARCHAR(500) NULL');
CALL add_column_if_missing('wh_express_company', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER tracking_url_template');
CALL add_column_if_missing('wh_express_company', 'remark', 'VARCHAR(500) NULL');
CALL add_column_if_missing('wh_express_company', 'created_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_express_company', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_express_company', 'updated_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_express_company', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)');
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');
DROP PROCEDURE IF EXISTS add_column_if_missing;
-- MySQL 8 的 ALTER ADD INDEX 不在所有小版本都支持 IF NOT EXISTS,使用元数据判断以保证重复执行安全。
SET @schema_name = DATABASE();
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_warehouse' AND index_name = 'idx_wh_tenant_status') = 0,
'ALTER TABLE wh_warehouse ADD KEY idx_wh_tenant_status (tenant_id, status, deleted)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_warehouse_address' AND index_name = 'idx_address_warehouse') = 0,
'ALTER TABLE wh_warehouse_address ADD KEY idx_address_warehouse (tenant_id, warehouse_id, deleted)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_service_capability' AND index_name = 'idx_capability_query') = 0,
'ALTER TABLE wh_service_capability ADD KEY idx_capability_query (tenant_id, warehouse_id, enabled, deleted)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_partner' AND index_name = 'idx_wh_partner_tenant_status') = 0,
'ALTER TABLE wh_partner ADD KEY idx_wh_partner_tenant_status (tenant_id, status, deleted)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'idx_wh_express_tenant_status') = 0,
'ALTER TABLE wh_express_company ADD KEY idx_wh_express_tenant_status (tenant_id, status, deleted)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_partner' AND index_name = 'uk_wh_partner_tenant_code') = 0,
'ALTER TABLE wh_partner ADD UNIQUE KEY uk_wh_partner_tenant_code (tenant_id, partner_code, deleted)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'uk_wh_express_tenant_code') = 0,
'ALTER TABLE wh_express_company ADD UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
@@ -10,3 +10,8 @@ ON DUPLICATE KEY UPDATE 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);
-- 一期联调快递公司主数据,供平台维护和订单履约回显验证。
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';
@@ -1,6 +1,7 @@
package com.baiyue.warehouse.controller;
import io.swagger.v3.oas.annotations.Operation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -13,9 +14,11 @@ import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("平台主数据控制器契约")
class PlatformMasterDataControllerContractTest {
@Test
void 往来单位分页路径权限和接口说明完整() throws Exception {
@DisplayName("往来单位分页路径权限和接口说明完整")
void shouldDeclareCompletePartnerListContract() throws Exception {
assertContract(PlatformPartnerController.class, "platform:counterparty:list", "/api/platform/v1/partners");
assertMethod(PlatformPartnerController.class, "detail", GetMapping.class, "platform:counterparty:list");
assertMethod(PlatformPartnerController.class, "create", PostMapping.class, "platform:counterparty:create");
@@ -24,7 +27,8 @@ class PlatformMasterDataControllerContractTest {
}
@Test
void 快递公司分页路径权限和接口说明完整() throws Exception {
@DisplayName("快递公司分页路径权限和接口说明完整")
void shouldDeclareCompleteExpressCompanyListContract() throws Exception {
assertContract(PlatformExpressCompanyController.class, "platform:express:list", "/api/platform/v1/express-companies");
assertMethod(PlatformExpressCompanyController.class, "detail", GetMapping.class, "platform:express:list");
assertMethod(PlatformExpressCompanyController.class, "create", PostMapping.class, "platform:express:create");
@@ -0,0 +1,30 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ResultCode;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@DisplayName("仓库数据访问异常处理器")
class WarehouseDataAccessExceptionHandlerTest {
@Test
@DisplayName("数据源故障统一返回503并保留请求标识")
void shouldReturnServiceUnavailableAndPreserveRequestIdsForDataSourceFailure() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getAttribute("requestId")).thenReturn("request-1");
when(request.getAttribute("traceId")).thenReturn("trace-1");
var response = new WarehouseDataAccessExceptionHandler().handle(
new DataAccessResourceFailureException("database unavailable"), request);
assertThat(response.getStatusCode().value()).isEqualTo(503);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE.code());
assertThat(response.getBody().message()).contains("数据库");
}
}
@@ -0,0 +1,26 @@
package com.baiyue.warehouse.controller;
import com.baiyue.warehouse.service.WarehouseSchemaReadinessService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@DisplayName("仓库健康检查控制器")
class WarehouseHealthControllerTest {
@Test
@DisplayName("表结构漂移时状态接口返回503")
void shouldReturnServiceUnavailableWhenSchemaDrifts() {
WarehouseSchemaReadinessService service = mock(WarehouseSchemaReadinessService.class);
when(service.readiness()).thenReturn(
new WarehouseSchemaReadinessService.Readiness(false, "DEGRADED", "需要执行迁移"));
var response = new WarehouseHealthController(service).status();
assertThat(response.getStatusCode().value()).isEqualTo(503);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().data()).containsEntry("status", "DEGRADED");
}
}
@@ -9,6 +9,7 @@ import com.baiyue.warehouse.mapper.ExpressCompanyMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -18,14 +19,16 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("快递公司仓储")
class ExpressCompanyRepositoryTest {
@BeforeAll
static void 初始化实体字段缓存() {
static void initializeEntityFieldCache() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), ExpressCompanyEntity.class);
}
@Test
void 平台分页强制租户并支持筛选() {
@DisplayName("平台分页强制租户并支持筛选")
void shouldEnforceTenantAndSupportFilteringForPlatformPage() {
ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class);
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
@@ -3,6 +3,7 @@ package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.mapper.PartnerMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
import org.mockito.ArgumentCaptor;
@@ -16,18 +17,23 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@DisplayName("往来单位仓储")
class PartnerRepositoryTest {
@BeforeAll static void 初始化实体字段缓存() {
@BeforeAll static void initializeEntityFieldCache() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), PartnerEntity.class);
}
@Test void 候选查询强制租户和启用状态() {
@Test
@DisplayName("候选查询强制租户和启用状态")
void shouldEnforceTenantAndActiveStatusForCandidateQuery() {
PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectList(any())).thenReturn(List.of());
new PartnerRepository(mapper).activeCandidates(10001,"示例","CUSTOMER");
@SuppressWarnings("unchecked") ArgumentCaptor<LambdaQueryWrapper<PartnerEntity>> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(mapper).selectList(c.capture()); c.getValue().getSqlSegment();
assertThat(c.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE","CUSTOMER");
}
@Test void 详情查询强制租户和启用状态() {
@Test
@DisplayName("详情查询强制租户和启用状态")
void shouldEnforceTenantAndActiveStatusForDetailQuery() {
PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectOne(any())).thenReturn(null);
new PartnerRepository(mapper).findActive(10002,9);
@SuppressWarnings("unchecked") ArgumentCaptor<LambdaQueryWrapper<PartnerEntity>> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class);
@@ -35,7 +41,9 @@ class PartnerRepositoryTest {
assertThat(c.getValue().getParamNameValuePairs().values()).contains(10002L,9L,"ACTIVE");
}
@Test void 平台分页强制租户并支持筛选() {
@Test
@DisplayName("平台分页强制租户并支持筛选")
void shouldEnforceTenantAndSupportFilteringForPlatformPage() {
PartnerMapper mapper=mock(PartnerMapper.class);
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
@@ -1,5 +1,6 @@
package com.baiyue.warehouse.repository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.IOException;
@@ -8,9 +9,11 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("仓库数据库结构契约")
class WarehouseSchemaContractTest {
@Test
void 仓库三张表包含实体全部持久化字段() throws IOException {
@DisplayName("仓库三张表包含实体全部持久化字段")
void shouldContainAllPersistentFieldsInWarehouseTables() throws IOException {
String sql = read("warehouse.sql");
assertColumns(sql, "wh_warehouse", "warehouse_code", "warehouse_name", "warehouse_type", "country_code",
"timezone", "contact_name", "contact_phone", "contact_email", "status", "remark");
@@ -22,7 +25,8 @@ class WarehouseSchemaContractTest {
}
@Test
void 往来单位表包含实体全部持久化字段() throws IOException {
@DisplayName("往来单位表包含实体全部持久化字段")
void shouldContainAllPersistentFieldsInPartnerTable() throws IOException {
String sql = read("warehouse-partner.sql");
assertColumns(sql, "wh_partner", "partner_code", "partner_name", "partner_type", "contact_name",
"contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line",
@@ -31,13 +35,32 @@ class WarehouseSchemaContractTest {
}
@Test
void 快递公司表包含实体全部持久化字段() throws IOException {
@DisplayName("快递公司表包含实体全部持久化字段")
void shouldContainAllPersistentFieldsInExpressCompanyTable() throws IOException {
String sql = read("warehouse-express-company.sql");
assertColumns(sql, "wh_express_company", "company_code", "company_name", "service_phone", "website",
"tracking_url_template", "status", "remark");
assertBaseColumns(sql);
}
@Test
@DisplayName("既有库增量迁移覆盖全部仓储实体表和列表索引")
void shouldCoverAllWarehouseTablesAndListIndexesInIncrementalMigration() throws IOException {
String sql = read("phase1-warehouse-schema-migration.sql");
assertThat(sql).contains("FROM information_schema.columns")
.contains("CREATE PROCEDURE add_column_if_missing")
.contains("CALL add_column_if_missing('wh_warehouse'")
.contains("CALL add_column_if_missing('wh_warehouse_address'")
.contains("CALL add_column_if_missing('wh_service_capability'")
.contains("CALL add_column_if_missing('wh_partner'")
.contains("CALL add_column_if_missing('wh_express_company'")
.contains("DROP PROCEDURE IF EXISTS add_column_if_missing")
.doesNotContain("ADD COLUMN IF NOT EXISTS")
.contains("idx_wh_tenant_status")
.contains("idx_wh_partner_tenant_status")
.contains("idx_wh_express_tenant_status");
}
private static String read(String file) throws IOException {
return Files.readString(Path.of("src/main/resources/db", file));
}
@@ -7,6 +7,7 @@ import com.baiyue.warehouse.entity.ExpressCompanyEntity;
import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.repository.ExpressCompanyRepository;
import com.baiyue.warehouse.repository.PartnerRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
@@ -20,9 +21,11 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("平台主数据维护服务")
class PlatformMasterDataManagementServiceTest {
@Test
void 往来单位详情强制租户隔离() {
@DisplayName("往来单位详情强制租户隔离")
void shouldEnforceTenantIsolationForPartnerDetail() {
PartnerRepository repository = mock(PartnerRepository.class);
PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository);
@@ -31,7 +34,8 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
void 往来单位编码在租户内必须唯一() {
@DisplayName("往来单位编码在租户内必须唯一")
void shouldRequireUniquePartnerCodeWithinTenant() {
PartnerRepository repository = mock(PartnerRepository.class);
when(repository.existsCode(10001L, "CUS-001", null)).thenReturn(true);
PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository);
@@ -42,7 +46,8 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
void 往来单位并发重复编码统一返回业务冲突() {
@DisplayName("往来单位并发重复编码统一返回业务冲突")
void shouldReturnBusinessConflictForConcurrentDuplicatePartnerCode() {
PartnerRepository repository = mock(PartnerRepository.class);
doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any());
PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository);
@@ -52,7 +57,8 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
void 往来单位新增写入租户和请求字段() {
@DisplayName("往来单位新增写入租户和请求字段")
void shouldPersistTenantAndRequestFieldsWhenCreatingPartner() {
PartnerRepository repository = mock(PartnerRepository.class);
doAnswer(invocation -> {
PartnerEntity entity = invocation.getArgument(0);
@@ -71,7 +77,8 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
void 往来单位编辑保留归属并更新字段() {
@DisplayName("往来单位编辑保留归属并更新字段")
void shouldPreserveOwnershipAndUpdateFieldsWhenEditingPartner() {
PartnerRepository repository = mock(PartnerRepository.class);
PartnerEntity entity = new PartnerEntity();
entity.setId(19L);
@@ -87,7 +94,8 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
void 往来单位停用保留主数据() {
@DisplayName("往来单位停用保留主数据")
void shouldPreservePartnerMasterDataWhenDeactivating() {
PartnerRepository repository = mock(PartnerRepository.class);
PartnerEntity entity = new PartnerEntity();
entity.setId(8L);
@@ -103,7 +111,8 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
void 快递公司详情强制租户隔离() {
@DisplayName("快递公司详情强制租户隔离")
void shouldEnforceTenantIsolationForExpressCompanyDetail() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
@@ -112,7 +121,8 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
void 快递公司编码在租户内必须唯一() {
@DisplayName("快递公司编码在租户内必须唯一")
void shouldRequireUniqueExpressCompanyCodeWithinTenant() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
when(repository.existsCode(10001L, "SF", null)).thenReturn(true);
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
@@ -123,7 +133,8 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
void 快递公司并发重复编码统一返回业务冲突() {
@DisplayName("快递公司并发重复编码统一返回业务冲突")
void shouldReturnBusinessConflictForConcurrentDuplicateExpressCompanyCode() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any());
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
@@ -133,7 +144,8 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
void 快递公司新增写入租户和请求字段() {
@DisplayName("快递公司新增写入租户和请求字段")
void shouldPersistTenantAndRequestFieldsWhenCreatingExpressCompany() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
doAnswer(invocation -> {
ExpressCompanyEntity entity = invocation.getArgument(0);
@@ -153,7 +165,8 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
void 快递公司编辑保留归属并更新字段() {
@DisplayName("快递公司编辑保留归属并更新字段")
void shouldPreserveOwnershipAndUpdateFieldsWhenEditingExpressCompany() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
ExpressCompanyEntity entity = new ExpressCompanyEntity();
entity.setId(29L);
@@ -169,7 +182,8 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
void 快递公司停用保留主数据() {
@DisplayName("快递公司停用保留主数据")
void shouldPreserveExpressCompanyMasterDataWhenDeactivating() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
ExpressCompanyEntity entity = new ExpressCompanyEntity();
entity.setId(9L);
@@ -3,14 +3,17 @@ package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.WarehouseRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@DisplayName("仓库应用服务")
class WarehouseApplicationServiceTest {
@Test
void 商户详情只返回启用且属于租户的仓库() {
@DisplayName("商户详情只返回启用且属于租户的仓库")
void shouldReturnOnlyActiveTenantOwnedWarehouseForMerchantDetail() {
WarehouseRepository repository=mock(WarehouseRepository.class);
WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("ACTIVE");
when(repository.findActiveOwned(10001,1)).thenReturn(warehouse);
@@ -21,7 +24,8 @@ class WarehouseApplicationServiceTest {
verify(repository).findActiveOwned(10001,1);
}
@Test
void 停用或跨租户仓库按不存在处理() {
@DisplayName("停用或跨租户仓库按不存在处理")
void shouldTreatInactiveOrCrossTenantWarehouseAsNotFound() {
WarehouseRepository repository=mock(WarehouseRepository.class);
WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository);
assertThatThrownBy(() -> service.activeDetail(10002,1)).isInstanceOf(BizException.class)
@@ -0,0 +1,43 @@
package com.baiyue.warehouse.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.BadSqlGrammarException;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@DisplayName("仓库表结构就绪检查服务")
class WarehouseSchemaReadinessServiceTest {
@Test
@DisplayName("全部表字段可查询时返回就绪")
void shouldReportUpWhenAllTablesAndColumnsAreQueryable() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
var readiness = new WarehouseSchemaReadinessService(jdbcTemplate).readiness();
assertThat(readiness.ready()).isTrue();
assertThat(readiness.status()).isEqualTo("UP");
verify(jdbcTemplate).execute(org.mockito.ArgumentMatchers.contains("wh_express_company"));
}
@Test
@DisplayName("缺表或缺列时返回降级而不是伪报正常")
void shouldReportDegradedWhenTableOrColumnIsMissing() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
doThrow(new BadSqlGrammarException("probe", "SELECT", new SQLException("missing")))
.when(jdbcTemplate).execute(anyString());
var readiness = new WarehouseSchemaReadinessService(jdbcTemplate).readiness();
assertThat(readiness.ready()).isFalse();
assertThat(readiness.status()).isEqualTo("DEGRADED");
assertThat(readiness.message()).contains("迁移");
}
}