feat(order): 订单链路演练签收 API、E2E 测试与批量任务 SQL 修复
补充链路演练模拟签收接口、E2E 主数据/菜单种子与测试项目,并修复批量任务列表 SqlSugar 查询及表结构注释脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4f3bbd17bc
commit
c918faec97
@ -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
51
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',
|
||||
|
||||
@ -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
22
scripts/run-order-e2e.ps1
Normal 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
|
||||
}
|
||||
46
sql/migrate_t_order_batch_job_comments.sql
Normal file
46
sql/migrate_t_order_batch_job_comments.sql
Normal 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 '更新时间';
|
||||
23
sql/migrate_t_order_batch_job_concurrency.sql
Normal file
23
sql/migrate_t_order_batch_job_concurrency.sql
Normal 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`;
|
||||
149
sql/seed_e2e_order_lifecycle.sql
Normal file
149
sql/seed_e2e_order_lifecycle.sql
Normal 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;
|
||||
63
sql/seed_order_lab_rbac_menu.sql
Normal file
63
sql/seed_order_lab_rbac_menu.sql
Normal 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`);
|
||||
30
sql/seed_order_lab_role_permissions.sql
Normal file
30
sql/seed_order_lab_role_permissions.sql
Normal 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`
|
||||
);
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
97
src/Oms.E2E.Tests/Fixtures/OrderE2eFixture.cs
Normal file
97
src/Oms.E2E.Tests/Fixtures/OrderE2eFixture.cs
Normal 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";
|
||||
}
|
||||
1
src/Oms.E2E.Tests/GlobalUsings.cs
Normal file
1
src/Oms.E2E.Tests/GlobalUsings.cs
Normal file
@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
39
src/Oms.E2E.Tests/Oms.E2E.Tests.csproj
Normal file
39
src/Oms.E2E.Tests/Oms.E2E.Tests.csproj
Normal 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>
|
||||
154
src/Oms.E2E.Tests/Scenarios/OrderLifecycleHappyPathTests.cs
Normal file
154
src/Oms.E2E.Tests/Scenarios/OrderLifecycleHappyPathTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
81
src/Oms.E2E.Tests/Support/E2eDatabase.cs
Normal file
81
src/Oms.E2E.Tests/Support/E2eDatabase.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
31
src/Oms.E2E.Tests/Support/E2eJson.cs
Normal file
31
src/Oms.E2E.Tests/Support/E2eJson.cs
Normal 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; }
|
||||
}
|
||||
39
src/Oms.E2E.Tests/Support/E2eTestConfig.cs
Normal file
39
src/Oms.E2E.Tests/Support/E2eTestConfig.cs
Normal 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;
|
||||
}
|
||||
74
src/Oms.E2E.Tests/Support/OrderE2eTestHost.cs
Normal file
74
src/Oms.E2E.Tests/Support/OrderE2eTestHost.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user