feat(order): 订单链路演练签收 API、E2E 测试与批量任务 SQL 修复

补充链路演练模拟签收接口、E2E 主数据/菜单种子与测试项目,并修复批量任务列表 SqlSugar 查询及表结构注释脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-08 11:32:45 +08:00
parent 4f3bbd17bc
commit c918faec97
19 changed files with 1013 additions and 44 deletions

View File

@ -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

51
db.sql
View File

@ -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',

View File

@ -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;

22
scripts/run-order-e2e.ps1 Normal file
View File

@ -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
}

View File

@ -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 '更新时间';

View File

@ -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`;

View File

@ -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;

View File

@ -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`);

View File

@ -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`
);

View File

@ -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<Func<OrderBatchJob, bool>> 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(

View File

@ -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
{
/// <summary>
/// 链路演练:模拟签收(待上网/运输中 → 派送成功)。
/// 仅供 E2E/演练;正式环境签收应接入轨迹同步。
/// </summary>
[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);
}
}

View File

@ -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;
/// <summary>
/// 订单 E2E 共享宿主:真实 MySQL + TestServer,轨迹外呼 Mock。
/// </summary>
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<ISmallBagTrackOrderClient> 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<ISmallBagTrackOrderClient>();
services.AddSingleton(TrackClientMock.Object);
services.RemoveAll<ISmallBagTrackOrderNotifier>();
services.AddSingleton<ISmallBagTrackOrderNotifier>(new NoopSmallBagTrackOrderNotifier());
});
}
public async Task<string> 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<LoginResultDto>(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<OrderE2eFixture>
{
public const string Name = "OrderE2E";
}

View File

@ -0,0 +1 @@
global using Xunit;

View File

@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>Oms.E2E.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.7.1" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="MySqlConnector" Version="2.4.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Oms.Application\Oms.Application.csproj" />
<ProjectReference Include="..\SimpleAdmin.Api\SimpleAdmin.Api.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\sql\seed_e2e_order_lifecycle.sql" Link="sql\seed_e2e_order_lifecycle.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@ -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;
/// <summary>
/// 订单主链路:创建 → 入库 → 支付 → 出库 → 签收。
/// </summary>
[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<OrderParcelCreateOutput>(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<OrderOutput>(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");
}
}

View File

@ -0,0 +1,81 @@
using MySqlConnector;
namespace Oms.E2E.Tests.Support;
internal static class E2eDatabase
{
public static async Task<bool> 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<string> 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<string?> 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<int?> 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);
}
}

View File

@ -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<T?> ReadAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken = default)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonConvert.DeserializeObject<T>(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; }
}

View File

@ -0,0 +1,39 @@
namespace Oms.E2E.Tests.Support;
/// <summary>
/// E2E 环境变量与常量。
/// </summary>
internal static class E2eTestConfig
{
/// <summary>
/// 设为 1 / true 时运行需 MySQL 的 E2E 用例。
/// </summary>
public const string EnabledEnv = "OMS_E2E_ENABLED";
/// <summary>
/// MySQL 连接串,默认与本地开发一致。
/// </summary>
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;
/// <summary>
/// 未启用 E2E 时返回 true,调用方应直接 return。
/// </summary>
public static bool IsDisabled => !IsEnabled;
}

View File

@ -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<OrderE2eTestHost> CreateAsync(
string connectionString,
Action<IServiceCollection>? configureServices = null)
{
var builder = SimpleAdminApiBootstrap.CreateBuilder([], webAppBuilder =>
{
webAppBuilder.WebHost.UseTestServer();
webAppBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["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();
}
}