diff --git a/SimpleAdmin.sln b/SimpleAdmin.sln index 41c7569..1f0347f 100644 --- a/SimpleAdmin.sln +++ b/SimpleAdmin.sln @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oms.Tests", "src\Oms.Tests\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleAdmin.Api.IntegrationTests", "src\SimpleAdmin.Api.IntegrationTests\SimpleAdmin.Api.IntegrationTests.csproj", "{C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oms.E2E.Tests", "src\Oms.E2E.Tests\Oms.E2E.Tests.csproj", "{5002370F-FEDB-4D01-82E7-2EC2CFF127E8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -111,6 +113,18 @@ Global {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|x64.Build.0 = Release|Any CPU {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|x86.ActiveCfg = Release|Any CPU {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|x86.Build.0 = Release|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Debug|x64.ActiveCfg = Debug|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Debug|x64.Build.0 = Debug|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Debug|x86.Build.0 = Debug|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Release|Any CPU.Build.0 = Release|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Release|x64.ActiveCfg = Release|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Release|x64.Build.0 = Release|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Release|x86.ActiveCfg = Release|Any CPU + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -122,5 +136,6 @@ Global {30C0D9A7-E710-4353-81DA-5F1F42F8AB63} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {E8C403D2-7172-4D9F-BB0A-0618FEA12B03} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {5002370F-FEDB-4D01-82E7-2EC2CFF127E8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/db.sql b/db.sql index ded322e..088135b 100644 --- a/db.sql +++ b/db.sql @@ -52,6 +52,8 @@ DROP TABLE IF EXISTS `t_order_recipient`; DROP TABLE IF EXISTS `t_order_sender`; DROP TABLE IF EXISTS `t_order_task_fail`; DROP TABLE IF EXISTS `t_order_task`; +DROP TABLE IF EXISTS `t_order_batch_job_item`; +DROP TABLE IF EXISTS `t_order_batch_job`; DROP TABLE IF EXISTS `t_order_template`; DROP TABLE IF EXISTS `t_order`; @@ -178,6 +180,55 @@ CREATE TABLE `t_order_task_fail` ( KEY `idx_ord_task_fail_row` (`TaskId`, `RowNumber`) ) COMMENT='下单任务失败明细表'; +CREATE TABLE `t_order_batch_job` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', + `Action` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '批量操作类型:pay/pre-declare/intercept/parcel-create 等', + `Status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/completed/failed/cancelled', + `Total` INT NOT NULL DEFAULT 0 COMMENT '子项总数', + `SuccessCount` INT NOT NULL DEFAULT 0 COMMENT '成功数', + `FailCount` INT NOT NULL DEFAULT 0 COMMENT '失败数', + `CustomerId` INT NULL COMMENT '客户ID', + `PayloadJson` JSON NULL COMMENT '任务级扩展参数(JSON)', + `RequestId` VARCHAR(64) NULL COMMENT '请求幂等ID', + `StartedAt` DATETIME NULL COMMENT '开始执行时间', + `FinishedAt` DATETIME NULL COMMENT '结束时间', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + KEY `idx_order_batch_job_request` (`RequestId`), + KEY `idx_order_batch_job_status` (`Status`) +) COMMENT='订单批量异步任务表'; + +CREATE TABLE `t_order_batch_job_item` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', + `JobId` INT NOT NULL COMMENT '批量任务ID', + `OrderId` INT NOT NULL COMMENT '订单ID', + `ServiceNo` VARCHAR(64) NULL COMMENT '运单号', + `Action` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '子项操作类型,通常与任务 Action 一致', + `Status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '子项状态:pending/running/success/failed/cancelled', + `MessageId` VARCHAR(128) NULL COMMENT 'RabbitMQ 消息ID,用于幂等与去重', + `FailReason` TEXT NULL COMMENT '失败原因', + `PayloadJson` TEXT NULL COMMENT '子项入参快照(JSON)', + `ResultJson` TEXT NULL COMMENT '子项执行结果快照(JSON)', + `RetryCount` INT NOT NULL DEFAULT 0 COMMENT '系统重试次数', + `StartedAt` DATETIME NULL COMMENT '开始执行时间', + `FinishedAt` DATETIME NULL COMMENT '结束时间', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_order_batch_job_item_message` (`MessageId`), + KEY `idx_order_batch_job_item_job` (`JobId`), + KEY `idx_order_batch_job_item_order` (`OrderId`) +) COMMENT='订单批量异步任务明细表'; + CREATE TABLE `t_order_sender` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `OrderId` INT NOT NULL COMMENT '订单ID', diff --git a/scripts/order_batch_job_tables.sql b/scripts/order_batch_job_tables.sql index 5f31beb..efcda15 100644 --- a/scripts/order_batch_job_tables.sql +++ b/scripts/order_batch_job_tables.sql @@ -1,52 +1,62 @@ -- 订单批量异步任务表(RabbitMQ 方案) -- 执行前请确认数据库为 oms +-- +-- 注意:必须使用 DROP + CREATE,不能用 IF NOT EXISTS。 +-- 若表已存在,IF NOT EXISTS 会跳过建表,表/字段注释不会写入。 +-- 若表内已有业务数据,请勿执行本脚本,改用 sql/migrate_t_order_batch_job_comments.sql -CREATE TABLE IF NOT EXISTS `t_order_batch_job` ( - `Id` int NOT NULL AUTO_INCREMENT, - `Action` varchar(64) NOT NULL DEFAULT '', - `Status` varchar(32) NOT NULL DEFAULT 'pending', - `Total` int NOT NULL DEFAULT 0, - `SuccessCount` int NOT NULL DEFAULT 0, - `FailCount` int NOT NULL DEFAULT 0, - `CustomerId` int NULL, - `PayloadJson` json NULL, - `RequestId` varchar(64) NULL, - `StartedAt` datetime NULL, - `FinishedAt` datetime NULL, - `IsDeleted` tinyint(1) NOT NULL DEFAULT 0, - `CreateUserId` int NOT NULL DEFAULT 0, - `CreateTime` datetime NOT NULL, - `UpdateUserId` int NULL, - `UpdateTime` datetime NULL, +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS `t_order_batch_job_item`; +DROP TABLE IF EXISTS `t_order_batch_job`; + +CREATE TABLE `t_order_batch_job` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', + `Action` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '批量操作类型:pay/pre-declare/intercept/parcel-create 等', + `Status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/completed/failed/cancelled', + `Total` INT NOT NULL DEFAULT 0 COMMENT '子项总数', + `SuccessCount` INT NOT NULL DEFAULT 0 COMMENT '成功数', + `FailCount` INT NOT NULL DEFAULT 0 COMMENT '失败数', + `CustomerId` INT NULL COMMENT '客户ID', + `PayloadJson` JSON NULL COMMENT '任务级扩展参数(JSON)', + `RequestId` VARCHAR(64) NULL COMMENT '请求幂等ID', + `StartedAt` DATETIME NULL COMMENT '开始执行时间', + `FinishedAt` DATETIME NULL COMMENT '结束时间', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', PRIMARY KEY (`Id`), KEY `idx_order_batch_job_request` (`RequestId`), KEY `idx_order_batch_job_status` (`Status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单批量异步任务表'; -CREATE TABLE IF NOT EXISTS `t_order_batch_job_item` ( - `Id` int NOT NULL AUTO_INCREMENT, - `JobId` int NOT NULL, - `OrderId` int NOT NULL, - `ServiceNo` varchar(64) NULL, - `Action` varchar(64) NOT NULL DEFAULT '', - `Status` varchar(32) NOT NULL DEFAULT 'pending', - `MessageId` varchar(128) NULL, - `FailReason` text NULL, - `RetryCount` int NOT NULL DEFAULT 0, - `StartedAt` datetime NULL, - `FinishedAt` datetime NULL, - `IsDeleted` tinyint(1) NOT NULL DEFAULT 0, - `CreateUserId` int NOT NULL DEFAULT 0, - `CreateTime` datetime NOT NULL, - `UpdateUserId` int NULL, - `UpdateTime` datetime NULL, +CREATE TABLE `t_order_batch_job_item` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', + `JobId` INT NOT NULL COMMENT '批量任务ID', + `OrderId` INT NOT NULL COMMENT '订单ID', + `ServiceNo` VARCHAR(64) NULL COMMENT '运单号', + `Action` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '子项操作类型,通常与任务 Action 一致', + `Status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '子项状态:pending/running/success/failed/cancelled', + `MessageId` VARCHAR(128) NULL COMMENT 'RabbitMQ 消息ID,用于幂等与去重', + `FailReason` TEXT NULL COMMENT '失败原因', + `PayloadJson` TEXT NULL COMMENT '子项入参快照(JSON)', + `ResultJson` TEXT NULL COMMENT '子项执行结果快照(JSON)', + `RetryCount` INT NOT NULL DEFAULT 0 COMMENT '系统重试次数', + `StartedAt` DATETIME NULL COMMENT '开始执行时间', + `FinishedAt` DATETIME NULL COMMENT '结束时间', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', PRIMARY KEY (`Id`), UNIQUE KEY `uk_order_batch_job_item_message` (`MessageId`), KEY `idx_order_batch_job_item_job` (`JobId`), KEY `idx_order_batch_job_item_order` (`OrderId`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单批量异步任务明细表'; --- 阶段三:若表已存在,请执行 --- ALTER TABLE `t_order_batch_job_item` ADD COLUMN `ResultJson` text NULL AFTER `FailReason`; --- 阶段四:若表已存在,请执行 --- ALTER TABLE `t_order_batch_job_item` ADD COLUMN `PayloadJson` text NULL AFTER `FailReason`; +SET FOREIGN_KEY_CHECKS = 1; diff --git a/scripts/run-order-e2e.ps1 b/scripts/run-order-e2e.ps1 new file mode 100644 index 0000000..d7a778b --- /dev/null +++ b/scripts/run-order-e2e.ps1 @@ -0,0 +1,22 @@ +# 订单全链路 E2E 测试 +# 前置:MySQL 已执行 db.sql,且可连接 +# 用法: +# $env:OMS_E2E_ENABLED = "1" +# $env:OMS_E2E_CONNECTION = "Server=127.0.0.1;Port=3306;Database=oms;Uid=root;Pwd=你的密码;CharSet=utf8mb4;" +# .\scripts\run-order-e2e.ps1 + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot + +if (-not $env:OMS_E2E_ENABLED) { + $env:OMS_E2E_ENABLED = "1" +} + +Write-Host "Running Oms.E2E.Tests (Category=E2E) ..." +Push-Location $root +try { + dotnet test "src\Oms.E2E.Tests\Oms.E2E.Tests.csproj" --filter "Category=E2E" -v n +} +finally { + Pop-Location +} diff --git a/sql/migrate_t_order_batch_job_comments.sql b/sql/migrate_t_order_batch_job_comments.sql new file mode 100644 index 0000000..6714f1f --- /dev/null +++ b/sql/migrate_t_order_batch_job_comments.sql @@ -0,0 +1,46 @@ +-- 订单批量任务表:补表注释/字段注释(不删表、不丢数据) +-- 适用于已执行旧版 order_batch_job_tables.sql(IF NOT EXISTS)的库 + +ALTER TABLE `t_order_batch_job` COMMENT='订单批量异步任务表'; + +ALTER TABLE `t_order_batch_job` + MODIFY COLUMN `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', + MODIFY COLUMN `Action` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '批量操作类型:pay/pre-declare/intercept/parcel-create 等', + MODIFY COLUMN `Status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/completed/failed/cancelled', + MODIFY COLUMN `Total` INT NOT NULL DEFAULT 0 COMMENT '子项总数', + MODIFY COLUMN `SuccessCount` INT NOT NULL DEFAULT 0 COMMENT '成功数', + MODIFY COLUMN `FailCount` INT NOT NULL DEFAULT 0 COMMENT '失败数', + MODIFY COLUMN `CustomerId` INT NULL COMMENT '客户ID', + MODIFY COLUMN `PayloadJson` JSON NULL COMMENT '任务级扩展参数(JSON)', + MODIFY COLUMN `RequestId` VARCHAR(64) NULL COMMENT '请求幂等ID', + MODIFY COLUMN `StartedAt` DATETIME NULL COMMENT '开始执行时间', + MODIFY COLUMN `FinishedAt` DATETIME NULL COMMENT '结束时间', + MODIFY COLUMN `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + MODIFY COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + MODIFY COLUMN `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + MODIFY COLUMN `CreateTime` DATETIME NOT NULL COMMENT '创建时间', + MODIFY COLUMN `UpdateUserId` INT NULL COMMENT '更新人', + MODIFY COLUMN `UpdateTime` DATETIME NULL COMMENT '更新时间'; + +ALTER TABLE `t_order_batch_job_item` COMMENT='订单批量异步任务明细表'; + +ALTER TABLE `t_order_batch_job_item` + MODIFY COLUMN `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', + MODIFY COLUMN `JobId` INT NOT NULL COMMENT '批量任务ID', + MODIFY COLUMN `OrderId` INT NOT NULL COMMENT '订单ID', + MODIFY COLUMN `ServiceNo` VARCHAR(64) NULL COMMENT '运单号', + MODIFY COLUMN `Action` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '子项操作类型,通常与任务 Action 一致', + MODIFY COLUMN `Status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '子项状态:pending/running/success/failed/cancelled', + MODIFY COLUMN `MessageId` VARCHAR(128) NULL COMMENT 'RabbitMQ 消息ID,用于幂等与去重', + MODIFY COLUMN `FailReason` TEXT NULL COMMENT '失败原因', + MODIFY COLUMN `PayloadJson` TEXT NULL COMMENT '子项入参快照(JSON)', + MODIFY COLUMN `ResultJson` TEXT NULL COMMENT '子项执行结果快照(JSON)', + MODIFY COLUMN `RetryCount` INT NOT NULL DEFAULT 0 COMMENT '系统重试次数', + MODIFY COLUMN `StartedAt` DATETIME NULL COMMENT '开始执行时间', + MODIFY COLUMN `FinishedAt` DATETIME NULL COMMENT '结束时间', + MODIFY COLUMN `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + MODIFY COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + MODIFY COLUMN `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + MODIFY COLUMN `CreateTime` DATETIME NOT NULL COMMENT '创建时间', + MODIFY COLUMN `UpdateUserId` INT NULL COMMENT '更新人', + MODIFY COLUMN `UpdateTime` DATETIME NULL COMMENT '更新时间'; diff --git a/sql/migrate_t_order_batch_job_concurrency.sql b/sql/migrate_t_order_batch_job_concurrency.sql new file mode 100644 index 0000000..412bffe --- /dev/null +++ b/sql/migrate_t_order_batch_job_concurrency.sql @@ -0,0 +1,23 @@ +-- 已执行旧版 order_batch_job_tables.sql 的库:补 ConcurrencyStamp / PayloadJson / ResultJson +-- 若列已存在,对应 ALTER 会报错,跳过即可 +-- 补列完成后执行 sql/migrate_t_order_batch_job_comments.sql 写入表/字段注释 + +ALTER TABLE `t_order_batch_job` + ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳' AFTER `IsDeleted`; + +UPDATE `t_order_batch_job` + SET `ConcurrencyStamp` = UUID() + WHERE `ConcurrencyStamp` = ''; + +ALTER TABLE `t_order_batch_job_item` + ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳' AFTER `IsDeleted`; + +UPDATE `t_order_batch_job_item` + SET `ConcurrencyStamp` = UUID() + WHERE `ConcurrencyStamp` = ''; + +ALTER TABLE `t_order_batch_job_item` + ADD COLUMN `PayloadJson` TEXT NULL COMMENT '子项入参快照(JSON)' AFTER `FailReason`; + +ALTER TABLE `t_order_batch_job_item` + ADD COLUMN `ResultJson` TEXT NULL COMMENT '子项执行结果快照(JSON)' AFTER `PayloadJson`; diff --git a/sql/seed_e2e_order_lifecycle.sql b/sql/seed_e2e_order_lifecycle.sql new file mode 100644 index 0000000..a6b4b71 --- /dev/null +++ b/sql/seed_e2e_order_lifecycle.sql @@ -0,0 +1,149 @@ +-- E2E 订单全链路测试主数据(固定 ID 段 900x / 99001) +-- 前置:已执行 db.sql;库中已有 t_country.US +-- 用法:mysql ... oms_test < sql/seed_e2e_order_lifecycle.sql +-- RBAC 菜单/权限:sql/seed_order_lab_rbac_menu.sql + sql/seed_order_lab_role_permissions.sql + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- 清理旧 E2E 数据(子表优先) +DELETE FROM `t_sales_product_allocation_provider` WHERE `SalesProductId` = 9001; +DELETE FROM `t_sales_product_price_detail` WHERE `PriceId` = 9001; +DELETE FROM `t_sales_product_price_factor` WHERE `PriceId` = 9001; +DELETE FROM `t_sales_product_price` WHERE `Id` = 9001; +DELETE FROM `t_service_channel_price_detail` WHERE `PriceId` = 9001; +DELETE FROM `t_service_channel_price_factor` WHERE `PriceId` = 9001; +DELETE FROM `t_service_channel_price` WHERE `Id` = 9001; +DELETE FROM `t_sales_product` WHERE `Id` = 9001; +DELETE FROM `t_service_channel` WHERE `Id` = 9001; +DELETE FROM `t_weight_rounding_info` WHERE `RoundingId` = 9001; +DELETE FROM `t_weight_rounding` WHERE `Id` = 9001; +DELETE FROM `t_weight_rule_info` WHERE `RuleId` = 9001; +DELETE FROM `t_weight_rule` WHERE `Id` = 9001; +DELETE FROM `t_kpi_area_info` WHERE `AreaId` = 9001; +DELETE FROM `t_kpi_area` WHERE `Id` = 9001; +DELETE FROM `t_warehouse_address` WHERE `Id` = 9001; +DELETE FROM `t_customer` WHERE `Id` = 99001; + +-- 仓库 +INSERT INTO `t_warehouse_address` ( + `Id`, `WarehouseName`, `WarehouseType`, `Recipient`, `ContactPhone`, `ShippingAddress`, + `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, 'E2E测试仓', 'main', 'E2E', '0755-00000000', '深圳市南山区测试路1号', + 0, UUID(), 0, NOW() +); + +-- 价格分区(美西 → US) +INSERT INTO `t_kpi_area` ( + `Id`, `AppKey`, `AreaType`, `Name`, `SystemName`, + `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, 'oms', 'warning', '美西', 'E2E-US-WEST', + 0, UUID(), 0, NOW() +); + +INSERT INTO `t_kpi_area_info` ( + `AreaId`, `CountryCode`, `CountryName`, `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, 'US', '美国', 0, 0, NOW() +); + +-- 计重规则 / 进位 +INSERT INTO `t_weight_rule` ( + `Id`, `AppKey`, `Name`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, 'oms', 'E2E计重规则', 0, UUID(), 0, NOW() +); + +INSERT INTO `t_weight_rule_info` ( + `RuleId`, `Client`, `Condition`, `BillableWeight`, `VolumetricFactor`, + `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, '全部', 'gw>=vw', 'gw', '6000', 0, 0, NOW() +); + +INSERT INTO `t_weight_rounding` ( + `Id`, `AppKey`, `Name`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, 'oms', 'E2E进位', 0, UUID(), 0, NOW() +); + +INSERT INTO `t_weight_rounding_info` ( + `RoundingId`, `StartValue`, `EndValue`, `Public`, `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, 0, 30, '0.5', 0, 0, NOW() +); + +-- 服务渠道 + 买价 +INSERT INTO `t_service_channel` ( + `Id`, `ChannelCode`, `ChannelName`, `IsActive`, + `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, 'E2E-CH-01', 'E2E测试渠道', 1, + 0, UUID(), 0, NOW() +); + +INSERT INTO `t_service_channel_price` ( + `Id`, `ServiceChannelId`, `SerialNumber`, `Status`, `PriceName`, `CostItem`, `CostUnit`, `Currency`, + `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, 9001, 1, 1, 'E2E买价', 'freight', 'kg', 'CNY', + 0, 0, NOW() +); + +INSERT INTO `t_service_channel_price_detail` ( + `PriceId`, `FactorValuesJson`, `CalculationFormula`, `CalculatedValue`, + `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, + '{"分区":"美西","货物类型":"普货","首重":"1","首重价":"10","续重单位":"0.5","续重价":"4"}', + '续单价', NULL, + 0, 0, NOW() +); + +-- 销售产品 + 卖价 + 排货 +INSERT INTO `t_sales_product` ( + `Id`, `ProductCode`, `ProductName`, `ProductGroup`, `PriceAreaId`, `WeightRuleId`, `WeightRoundingId`, + `IsActive`, `Scheme`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, 'E2E-SP-01', 'E2E测试产品', '专线', 9001, 9001, 9001, + 1, 'costLowest', 0, UUID(), 0, NOW() +); + +INSERT INTO `t_sales_product_price` ( + `Id`, `SalesProductId`, `SerialNumber`, `Status`, `PriceName`, `CostItem`, `CostUnit`, `Currency`, + `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, 9001, 1, 1, 'E2E卖价', 'freight', 'kg', 'CNY', + 0, 0, NOW() +); + +INSERT INTO `t_sales_product_price_detail` ( + `PriceId`, `FactorValuesJson`, `CalculationFormula`, `CalculatedValue`, + `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, + '{"分区":"美西","货物类型":"普货","首重":"1","首重价":"18","续重单位":"0.5","续重价":"6"}', + '续单价', NULL, + 0, 0, NOW() +); + +INSERT INTO `t_sales_product_allocation_provider` ( + `SalesProductId`, `SerialNumber`, `ProviderName`, `ServiceChannelId`, `Weight`, + `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES ( + 9001, 1, 'E2E渠道商', 9001, 100, + 0, 0, NOW() +); + +-- 客户(密码明文 e2e123 → MD5) +INSERT INTO `t_customer` ( + `Id`, `UserId`, `CustomerCode`, `CustomerName`, `Email`, `Phone`, `Balance`, + `Status`, `Password`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime` +) VALUES ( + 99001, 1, 'E2E001', 'E2E测试客户', 'e2e99001@test.local', '19900009901', 50000.00, + 1, '5df3e7decfd8323014e399401de7d82f', 0, UUID(), 0, NOW() +); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/sql/seed_order_lab_rbac_menu.sql b/sql/seed_order_lab_rbac_menu.sql new file mode 100644 index 0000000..0fe3ac7 --- /dev/null +++ b/sql/seed_order_lab_rbac_menu.sql @@ -0,0 +1,63 @@ +-- 订单批量任务 + 链路演练 RBAC 菜单(Fake.Rbac menu 表) +-- 执行库:RBAC 库(与 seed_xhs_rbac_menu.sql 相同) +-- 幂等:可重复执行 + +SET @ADMIN_USER_ID = ( + SELECT `Id` FROM `user` + WHERE `Account` = 'admin' AND (`IsDeleted` = 0 OR `IsDeleted` IS NULL) + LIMIT 1 +); +SET @ADMIN_USER_ID = IFNULL( + @ADMIN_USER_ID, + '08de4226-3fd4-4a5c-83e5-70e164b15f2f' +); + +SET @ORDER_PID = ( + SELECT `Id` FROM `menu` + WHERE `Route` = '/order' AND `IsDeleted` = 0 + ORDER BY `Order` + LIMIT 1 +); +SET @ORDER_PID = IFNULL(@ORDER_PID, '00000000-0000-0000-0000-000000000000'); + +INSERT INTO `menu` ( + `Id`, `PId`, `Name`, `PermissionCode`, `Type`, `Icon`, `Route`, `Component`, `Order`, + `IsHidden`, `IsCached`, `Description`, `IsDeleted`, + `ConcurrencyStamp`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime` +) VALUES +( + 'b1b2c3d4-e5f6-7890-abcd-ef1234567901', + @ORDER_PID, + '批量任务', + 'Order.BatchJobs', + 1, + NULL, + '/order/batchJobs', + 'views/order/batchJobs/index', + 4, + 0, 1, '订单 RabbitMQ 批量任务治理', 0, + UUID(), @ADMIN_USER_ID, NOW(), @ADMIN_USER_ID, NOW() +), +( + 'b1b2c3d4-e5f6-7890-abcd-ef1234567902', + @ORDER_PID, + '链路演练', + 'Order.LifecycleLab', + 1, + NULL, + '/order/lifecycleLab', + 'views/order/lifecycleLab/index', + 5, + 0, 1, '订单全链路可视化演练(E2E)', 0, + UUID(), @ADMIN_USER_ID, NOW(), @ADMIN_USER_ID, NOW() +) +ON DUPLICATE KEY UPDATE + `Name` = VALUES(`Name`), + `PId` = VALUES(`PId`), + `PermissionCode` = VALUES(`PermissionCode`), + `Route` = VALUES(`Route`), + `Component` = VALUES(`Component`), + `Order` = VALUES(`Order`), + `Description` = VALUES(`Description`), + `CreateUserId` = VALUES(`CreateUserId`), + `UpdateUserId` = VALUES(`UpdateUserId`); diff --git a/sql/seed_order_lab_role_permissions.sql b/sql/seed_order_lab_role_permissions.sql new file mode 100644 index 0000000..515d343 --- /dev/null +++ b/sql/seed_order_lab_role_permissions.sql @@ -0,0 +1,30 @@ +-- 为 Admin / Customer 角色追加批量任务、链路演练权限(幂等) +-- RBAC 库执行;执行后登录用户需重新获取 token 或刷新权限缓存 + +SET @ADMIN_USER_ID = ( + SELECT `Id` FROM `user` + WHERE `Account` = 'admin' AND (`IsDeleted` = 0 OR `IsDeleted` IS NULL) + LIMIT 1 +); +SET @ADMIN_USER_ID = IFNULL( + @ADMIN_USER_ID, + '08de4226-3fd4-4a5c-83e5-70e164b15f2f' +); + +INSERT INTO `role_permission` (`Id`, `RoleId`, `PermissionCode`, `CreateUserId`, `CreateTime`) +SELECT UUID(), r.`Id`, p.`Code`, @ADMIN_USER_ID, NOW() +FROM `role` r +CROSS JOIN ( + SELECT 'Order.BatchJobs.Query' AS `Code` UNION ALL + SELECT 'Order.BatchJobs.View' UNION ALL + SELECT 'Order.BatchJobs.Retry' UNION ALL + SELECT 'Order.BatchJobs.Cancel' UNION ALL + SELECT 'Order.LifecycleLab.Query' UNION ALL + SELECT 'Order.LifecycleLab.RunStep' UNION ALL + SELECT 'Order.LifecycleLab.RunAll' +) p +WHERE r.`Code` IN ('Admin', 'Customer') + AND NOT EXISTS ( + SELECT 1 FROM `role_permission` rp + WHERE rp.`RoleId` = r.`Id` AND rp.`PermissionCode` = p.`Code` + ); diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs index 260e458..219b3ba 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs @@ -464,14 +464,17 @@ public sealed class OrderBatchJobCoordinator( var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); var status = input.Status?.Trim(); var action = input.Action?.Trim(); + var hasStatusFilter = !string.IsNullOrWhiteSpace(status); + var hasActionFilter = !string.IsNullOrWhiteSpace(action); var hasCustomerFilter = customerId.HasValue; var customerIdValue = customerId.GetValueOrDefault(); - var jobId = input.JobId; + var hasJobIdFilter = input.JobId is > 0; + var jobIdValue = input.JobId.GetValueOrDefault(); Expression> predicate = x => - (!jobId.HasValue || jobId.Value <= 0 || x.Id == jobId.Value) && - (string.IsNullOrWhiteSpace(status) || x.Status == status) && - (string.IsNullOrWhiteSpace(action) || x.Action == action) && + (!hasJobIdFilter || x.Id == jobIdValue) && + (!hasStatusFilter || x.Status == status) && + (!hasActionFilter || x.Action == action) && (!hasCustomerFilter || x.CustomerId == customerIdValue); var jobs = await batchJobRepository.GetPagedListAsync( diff --git a/src/Oms.Application/Services/Orders/OrderService.LifecycleLab.cs b/src/Oms.Application/Services/Orders/OrderService.LifecycleLab.cs new file mode 100644 index 0000000..2c90865 --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.LifecycleLab.cs @@ -0,0 +1,41 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + /// + /// 链路演练:模拟签收(待上网/运输中 → 派送成功)。 + /// 仅供 E2E/演练;正式环境签收应接入轨迹同步。 + /// + [HttpPost("/order/lifecycle-lab/sign")] + public async Task LifecycleLabSignAsync([FromBody] OrderActionInput input) + { + var order = await GetOrderOrThrowAsync(input.Id); + if (order.OrderStatus == OrderStatusCodes.DeliverySuccess) + { + throw new BusinessException("订单已签收"); + } + + if (order.OrderStatus is not (OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping)) + { + throw new BusinessException( + $"当前状态「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」不可模拟签收,请先完成出库"); + } + + var now = DateTime.Now; + if (order.OrderStatus == OrderStatusCodes.PendingOnline) + { + order.OrderStatus = OrderStatusCodes.Shipping; + } + + order.OrderStatus = OrderStatusCodes.DeliverySuccess; + order.SignTime = now; + order.UpdateTime = now; + await UpdateOrderAsync(order); + } +} diff --git a/src/Oms.E2E.Tests/Fixtures/OrderE2eFixture.cs b/src/Oms.E2E.Tests/Fixtures/OrderE2eFixture.cs new file mode 100644 index 0000000..292851f --- /dev/null +++ b/src/Oms.E2E.Tests/Fixtures/OrderE2eFixture.cs @@ -0,0 +1,97 @@ +using System.Net.Http.Headers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; +using Oms.Application.Integrations.SmallBagTrack; +using Oms.E2E.Tests.Support; +using SimpleAdmin.Api; +using SimpleAdmin.Api.Auth; + +namespace Oms.E2E.Tests.Fixtures; + +/// +/// 订单 E2E 共享宿主:真实 MySQL + TestServer,轨迹外呼 Mock。 +/// +public sealed class OrderE2eFixture : IAsyncLifetime +{ + private OrderE2eTestHost? _host; + + public HttpClient Client => _host?.Client + ?? throw new InvalidOperationException("E2E host not started. Set OMS_E2E_ENABLED=1."); + + public string ConnectionString { get; private set; } = E2eTestConfig.ConnectionString; + + public Mock TrackClientMock { get; } = new(MockBehavior.Loose); + + public async Task InitializeAsync() + { + if (!E2eTestConfig.IsEnabled) + { + return; + } + + if (!await E2eDatabase.CanConnectAsync(ConnectionString)) + { + throw new InvalidOperationException($"Cannot connect to MySQL: {ConnectionString}"); + } + + await E2eDatabase.ApplySeedScriptAsync(ConnectionString); + + _host = await OrderE2eTestHost.CreateAsync( + ConnectionString, + services => + { + services.RemoveAll(); + services.AddSingleton(TrackClientMock.Object); + + services.RemoveAll(); + services.AddSingleton(new NoopSmallBagTrackOrderNotifier()); + }); + } + + public async Task LoginAdminAsync() + { + var response = await Client.PostAsync( + "/rbac/auth/login", + E2eJson.AsJsonContent(new OmsLoginInput + { + Account = E2eTestConfig.AdminAccount, + Password = E2eTestConfig.AdminPassword, + Type = OmsLoginType.User + })); + + var body = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.Should().BeTrue($"admin login failed: {body}"); + + var login = await E2eJson.ReadAsync(response); + login!.AccessToken.Should().NotBeNullOrWhiteSpace(); + return login.AccessToken!; + } + + public void UseBearer(string accessToken) + { + Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + } + + public async Task DisposeAsync() + { + if (_host != null) + { + await _host.DisposeAsync(); + } + } + + private sealed class NoopSmallBagTrackOrderNotifier : ISmallBagTrackOrderNotifier + { + public void Enqueue(SmallBagTrackUpsertRequest request, int orderId, int? customerId = null) + { + } + } +} + +[CollectionDefinition(Name)] +public sealed class OrderE2eCollection : ICollectionFixture +{ + public const string Name = "OrderE2E"; +} diff --git a/src/Oms.E2E.Tests/GlobalUsings.cs b/src/Oms.E2E.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/src/Oms.E2E.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/src/Oms.E2E.Tests/Oms.E2E.Tests.csproj b/src/Oms.E2E.Tests/Oms.E2E.Tests.csproj new file mode 100644 index 0000000..621c955 --- /dev/null +++ b/src/Oms.E2E.Tests/Oms.E2E.Tests.csproj @@ -0,0 +1,39 @@ + + + + net8.0 + enable + enable + false + true + Oms.E2E.Tests + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/src/Oms.E2E.Tests/Scenarios/OrderLifecycleHappyPathTests.cs b/src/Oms.E2E.Tests/Scenarios/OrderLifecycleHappyPathTests.cs new file mode 100644 index 0000000..f0cc07a --- /dev/null +++ b/src/Oms.E2E.Tests/Scenarios/OrderLifecycleHappyPathTests.cs @@ -0,0 +1,154 @@ +using FluentAssertions; +using Oms.Application.Dtos.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.E2E.Tests.Fixtures; +using Oms.E2E.Tests.Support; + +namespace Oms.E2E.Tests.Scenarios; + +/// +/// 订单主链路:创建 → 入库 → 支付 → 出库 → 签收。 +/// +[Collection(OrderE2eCollection.Name)] +[Trait("Category", "E2E")] +public sealed class OrderLifecycleHappyPathTests(OrderE2eFixture fixture) +{ + private const decimal ParcelWeight = 1.2m; + private const decimal ParcelLength = 30m; + private const decimal ParcelWidth = 20m; + private const decimal ParcelHeight = 10m; + + [Fact] + public async Task Create_Inbound_Pay_Outbound_ShouldReachPendingOnline() + { + if (E2eTestConfig.IsDisabled) + { + return; + } + + var token = await fixture.LoginAdminAsync(); + fixture.UseBearer(token); + + var clientNo = $"E2E-{Guid.NewGuid():N}"[..20]; + var createBody = BuildParcelCreateBody(clientNo); + + var createResponse = await fixture.Client.PostAsync("/order/parcel/create", E2eJson.AsJsonContent(createBody)); + var createText = await createResponse.Content.ReadAsStringAsync(); + createResponse.IsSuccessStatusCode.Should().BeTrue($"parcel create failed: {createText}"); + + var created = await E2eJson.ReadAsync(createResponse); + created.Should().NotBeNull(); + created!.ServiceNo.Should().NotBeNullOrWhiteSpace(); + var serviceNo = created.ServiceNo!; + + await AssertOrderStatusAsync(serviceNo, OrderStatusCodes.Predeclared); + + var inboundBody = new + { + waybillNo = serviceNo, + weight = ParcelWeight, + length = ParcelLength, + width = ParcelWidth, + height = ParcelHeight, + warehouseId = E2eTestConfig.E2eWarehouseId + }; + var inboundResponse = await fixture.Client.PostAsync("/operation/inbound/create", E2eJson.AsJsonContent(inboundBody)); + var inboundText = await inboundResponse.Content.ReadAsStringAsync(); + inboundResponse.IsSuccessStatusCode.Should().BeTrue($"inbound failed: {inboundText}"); + + await AssertOrderStatusAsync(serviceNo, OrderStatusCodes.PendingPayment); + + var orderId = await E2eDatabase.GetOrderIdByServiceNoAsync(fixture.ConnectionString, serviceNo); + orderId.Should().HaveValue(); + var id = orderId!.Value; + + var payResponse = await fixture.Client.PostAsync("/order/pay", E2eJson.AsJsonContent(new { id })); + var payText = await payResponse.Content.ReadAsStringAsync(); + payResponse.IsSuccessStatusCode.Should().BeTrue($"pay failed: {payText}"); + + await AssertOrderStatusAsync(serviceNo, OrderStatusCodes.PendingShip); + + var outboundBody = new + { + waybillNo = serviceNo, + weight = ParcelWeight, + length = ParcelLength, + width = ParcelWidth, + height = ParcelHeight, + warehouseId = E2eTestConfig.E2eWarehouseId + }; + var outboundResponse = await fixture.Client.PostAsync("/operation/outbound/create", E2eJson.AsJsonContent(outboundBody)); + var outboundText = await outboundResponse.Content.ReadAsStringAsync(); + outboundResponse.IsSuccessStatusCode.Should().BeTrue($"outbound failed: {outboundText}"); + + await AssertOrderStatusAsync(serviceNo, OrderStatusCodes.PendingOnline); + + var signResponse = await fixture.Client.PostAsync("/order/lifecycle-lab/sign", E2eJson.AsJsonContent(new { id })); + var signText = await signResponse.Content.ReadAsStringAsync(); + signResponse.IsSuccessStatusCode.Should().BeTrue($"sign failed: {signText}"); + + await AssertOrderStatusAsync(serviceNo, OrderStatusCodes.DeliverySuccess); + + var detailResponse = await fixture.Client.GetAsync($"/order/detail?id={id}"); + detailResponse.IsSuccessStatusCode.Should().BeTrue(); + var detail = await E2eJson.ReadAsync(detailResponse); + detail.Should().NotBeNull(); + detail!.OrderStatus.Should().Be(OmsDocHelper.GetOrderStatusName(OrderStatusCodes.DeliverySuccess)); + detail.ReceiveTime.Should().NotBeNull(); + detail.PayTime.Should().NotBeNull(); + detail.ShipTime.Should().NotBeNull(); + detail.SignTime.Should().NotBeNull(); + } + + private static object BuildParcelCreateBody(string clientNo) => new + { + requestId = $"e2e-{Guid.NewGuid():N}", + clientNo, + productCode = E2eTestConfig.E2eProductCode, + cargoAttribute = CargoAttributeCodes.General, + warehouseId = E2eTestConfig.E2eWarehouseId, + customerId = E2eTestConfig.E2eCustomerId, + length = ParcelLength, + width = ParcelWidth, + height = ParcelHeight, + actualWeight = ParcelWeight, + quantity = 1, + declaredCurrency = "USD", + receiver = new + { + recipientsName = "E2E Receiver", + phone = "13800000001", + countryCode = "US", + province = "CA", + city = "Los Angeles", + streetAddress1 = "100 Test St", + zipCode = "90001" + }, + sender = new + { + senderName = "E2E Sender", + phone = "13900000001", + countryCode = "CN", + city = "Shenzhen", + streetAddress1 = "Test Warehouse" + }, + items = new[] + { + new + { + enName = "Test Goods", + cnName = "测试商品", + quantity = 1, + unitPrice = 10m, + unitWeight = ParcelWeight + } + } + }; + + private async Task AssertOrderStatusAsync(string serviceNo, string expectedStatus) + { + var actual = await E2eDatabase.GetOrderStatusByServiceNoAsync(fixture.ConnectionString, serviceNo); + actual.Should().Be(expectedStatus, $"order {serviceNo} status mismatch"); + } +} diff --git a/src/Oms.E2E.Tests/Support/E2eDatabase.cs b/src/Oms.E2E.Tests/Support/E2eDatabase.cs new file mode 100644 index 0000000..c8a1c74 --- /dev/null +++ b/src/Oms.E2E.Tests/Support/E2eDatabase.cs @@ -0,0 +1,81 @@ +using MySqlConnector; + +namespace Oms.E2E.Tests.Support; + +internal static class E2eDatabase +{ + public static async Task CanConnectAsync(string connectionString, CancellationToken cancellationToken = default) + { + try + { + await using var conn = new MySqlConnection(connectionString); + await conn.OpenAsync(cancellationToken); + return true; + } + catch + { + return false; + } + } + + public static async Task ApplySeedScriptAsync(string connectionString, CancellationToken cancellationToken = default) + { + var seedPath = Path.Combine(AppContext.BaseDirectory, "sql", "seed_e2e_order_lifecycle.sql"); + if (!File.Exists(seedPath)) + { + throw new FileNotFoundException("E2E seed script not found.", seedPath); + } + + var script = await File.ReadAllTextAsync(seedPath, cancellationToken); + var builder = new MySqlConnectionStringBuilder(connectionString) + { + AllowUserVariables = true + }; + await using var conn = new MySqlConnection(builder.ConnectionString); + await conn.OpenAsync(cancellationToken); + + foreach (var statement in SplitSqlStatements(script)) + { + await using var cmd = new MySqlCommand(statement, conn); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + } + + private static IEnumerable SplitSqlStatements(string script) + { + return script + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => x.Trim()) + .Where(x => x.Length > 0 && !x.StartsWith("--", StringComparison.Ordinal)); + } + + public static async Task GetOrderStatusByServiceNoAsync( + string connectionString, + string serviceNo, + CancellationToken cancellationToken = default) + { + await using var conn = new MySqlConnection(connectionString); + await conn.OpenAsync(cancellationToken); + await using var cmd = new MySqlCommand( + "SELECT `OrderStatus` FROM `t_order` WHERE `ServiceNo` = @serviceNo AND `IsDeleted` = 0 LIMIT 1", + conn); + cmd.Parameters.AddWithValue("@serviceNo", serviceNo); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + return result as string; + } + + public static async Task GetOrderIdByServiceNoAsync( + string connectionString, + string serviceNo, + CancellationToken cancellationToken = default) + { + await using var conn = new MySqlConnection(connectionString); + await conn.OpenAsync(cancellationToken); + await using var cmd = new MySqlCommand( + "SELECT `Id` FROM `t_order` WHERE `ServiceNo` = @serviceNo AND `IsDeleted` = 0 LIMIT 1", + conn); + cmd.Parameters.AddWithValue("@serviceNo", serviceNo); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + return result is int id ? id : Convert.ToInt32(result); + } +} diff --git a/src/Oms.E2E.Tests/Support/E2eJson.cs b/src/Oms.E2E.Tests/Support/E2eJson.cs new file mode 100644 index 0000000..5380233 --- /dev/null +++ b/src/Oms.E2E.Tests/Support/E2eJson.cs @@ -0,0 +1,31 @@ +using Newtonsoft.Json; + +namespace Oms.E2E.Tests.Support; + +internal static class E2eJson +{ + private static readonly JsonSerializerSettings Settings = new() + { + DateParseHandling = DateParseHandling.DateTime, + NullValueHandling = NullValueHandling.Ignore + }; + + public static async Task ReadAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + var json = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonConvert.DeserializeObject(json, Settings); + } + + public static StringContent AsJsonContent(object body) => + new(JsonConvert.SerializeObject(body), System.Text.Encoding.UTF8, "application/json"); +} + +internal sealed class LoginResultDto +{ + public string? AccessToken { get; set; } +} + +internal sealed class ApiErrorDto +{ + public string? Message { get; set; } +} diff --git a/src/Oms.E2E.Tests/Support/E2eTestConfig.cs b/src/Oms.E2E.Tests/Support/E2eTestConfig.cs new file mode 100644 index 0000000..31991b0 --- /dev/null +++ b/src/Oms.E2E.Tests/Support/E2eTestConfig.cs @@ -0,0 +1,39 @@ +namespace Oms.E2E.Tests.Support; + +/// +/// E2E 环境变量与常量。 +/// +internal static class E2eTestConfig +{ + /// + /// 设为 1 / true 时运行需 MySQL 的 E2E 用例。 + /// + public const string EnabledEnv = "OMS_E2E_ENABLED"; + + /// + /// MySQL 连接串,默认与本地开发一致。 + /// + public const string ConnectionEnv = "OMS_E2E_CONNECTION"; + + public const string DefaultConnection = + "Server=127.0.0.1;Port=3306;Database=oms;Uid=root;Pwd=;CharSet=utf8mb4;"; + + public const string AdminAccount = "admin"; + public const string AdminPassword = "123456"; + + public const int E2eCustomerId = 99001; + public const int E2eWarehouseId = 9001; + public const string E2eProductCode = "E2E-SP-01"; + + public static bool IsEnabled => + string.Equals(Environment.GetEnvironmentVariable(EnabledEnv), "1", StringComparison.OrdinalIgnoreCase) + || string.Equals(Environment.GetEnvironmentVariable(EnabledEnv), "true", StringComparison.OrdinalIgnoreCase); + + public static string ConnectionString => + Environment.GetEnvironmentVariable(ConnectionEnv) ?? DefaultConnection; + + /// + /// 未启用 E2E 时返回 true,调用方应直接 return。 + /// + public static bool IsDisabled => !IsEnabled; +} diff --git a/src/Oms.E2E.Tests/Support/OrderE2eTestHost.cs b/src/Oms.E2E.Tests/Support/OrderE2eTestHost.cs new file mode 100644 index 0000000..8504510 --- /dev/null +++ b/src/Oms.E2E.Tests/Support/OrderE2eTestHost.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleAdmin.Api; + +namespace Oms.E2E.Tests.Support; + +internal sealed class OrderE2eTestHost : IAsyncDisposable +{ + private readonly WebApplication _app; + + private OrderE2eTestHost(WebApplication app) + { + _app = app; + Client = app.GetTestClient(); + } + + public HttpClient Client { get; } + + public static async Task CreateAsync( + string connectionString, + Action? configureServices = null) + { + var builder = SimpleAdminApiBootstrap.CreateBuilder([], webAppBuilder => + { + webAppBuilder.WebHost.UseTestServer(); + webAppBuilder.Configuration.AddInMemoryCollection(new Dictionary + { + ["UseLocalConfigs"] = "true", + ["ConnectionStrings:Default"] = connectionString, + ["App:SendStartupNotification"] = "false", + ["App:CorsOrigins:0"] = "http://localhost", + ["App:Urls:0"] = "http://127.0.0.1:5005", + ["Jwt:SecretKey"] = "oms-e2e-test-secret-key-32chars-min", + ["Jwt:Issuer"] = "Oms.E2E.Tests", + ["Jwt:Audience"] = "Oms.E2E.Tests", + ["Jwt:ExpiryInMinutes"] = "120", + ["Jwt:RefreshExpiryInDays"] = "7", + ["Jwt:ClockSkewMinutes"] = "5", + ["JwtSettings:SecretKey"] = "oms-e2e-test-secret-key-32chars-min", + ["JwtSettings:Issuer"] = "Oms.E2E.Tests", + ["JwtSettings:Audience"] = "Oms.E2E.Tests", + ["JwtSettings:ExpiryInMinutes"] = "120", + ["JwtSettings:RefreshExpiryInDays"] = "7", + ["JwtSettings:ClockSkewMinutes"] = "5", + ["FeiShu:Webhook"] = "https://example.com/webhook", + ["FeiShu:EnableFeiShuExceptionSubscribe"] = "false", + ["FeiShuNotice:Webhook"] = "https://example.com/webhook", + ["FeiShuNotice:EnableFeiShuExceptionSubscribe"] = "false", + ["OrderBatch:RabbitMq:Enabled"] = "false", + ["Oms:TrackService:Enabled"] = "false", + ["Oms:TrackService:BaseUrl"] = "http://127.0.0.1:9090", + ["Oms:TrackService:AppKey"] = "E2E_KEY", + ["Oms:TrackService:AppSecret"] = "E2E_SECRET", + ["Oms:TrackService:TimeoutSeconds"] = "5" + }); + }, environmentName: "Testing"); + + configureServices?.Invoke(builder.Services); + + var app = SimpleAdminApiBootstrap.BuildApplication(builder); + await app.StartAsync(); + return new OrderE2eTestHost(app); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + } +}