diff --git a/SimpleAdmin.sln b/SimpleAdmin.sln
index 9dbeb5a..a5b3729 100644
--- a/SimpleAdmin.sln
+++ b/SimpleAdmin.sln
@@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "items", "items", "{BE5132AD
oms表结构与接口设计2026040802.md = oms表结构与接口设计2026040802.md
.gitignore = .gitignore
.dockerignore = .dockerignore
+ oms表结构与接口设计20260427.md = oms表结构与接口设计20260427.md
+ db.sql = db.sql
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleAdmin.Api", "src\SimpleAdmin.Api\SimpleAdmin.Api.csproj", "{229ED167-9ECC-4736-8DFB-522EEA8FF680}"
@@ -23,6 +25,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oms.Application", "src\Oms.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oms.SqlSugarCore", "src\Oms.SqlSugarCore\Oms.SqlSugarCore.csproj", "{30C0D9A7-E710-4353-81DA-5F1F42F8AB63}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oms.Tests", "src\Oms.Tests\Oms.Tests.csproj", "{E8C403D2-7172-4D9F-BB0A-0618FEA12B03}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleAdmin.Api.IntegrationTests", "src\SimpleAdmin.Api.IntegrationTests\SimpleAdmin.Api.IntegrationTests.csproj", "{C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -81,6 +87,30 @@ Global
{30C0D9A7-E710-4353-81DA-5F1F42F8AB63}.Release|x64.Build.0 = Release|Any CPU
{30C0D9A7-E710-4353-81DA-5F1F42F8AB63}.Release|x86.ActiveCfg = Release|Any CPU
{30C0D9A7-E710-4353-81DA-5F1F42F8AB63}.Release|x86.Build.0 = Release|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|x64.Build.0 = Debug|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Debug|x86.Build.0 = Debug|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|x64.ActiveCfg = Release|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|x64.Build.0 = Release|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|x86.ActiveCfg = Release|Any CPU
+ {E8C403D2-7172-4D9F-BB0A-0618FEA12B03}.Release|x86.Build.0 = Release|Any CPU
+ {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|x64.Build.0 = Debug|Any CPU
+ {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Debug|x86.Build.0 = Debug|Any CPU
+ {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C0A71CD6-4F82-4EC4-BB90-CE8269A515A9}.Release|x64.ActiveCfg = Release|Any CPU
+ {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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -90,5 +120,7 @@ Global
{A4E19908-0101-4438-899C-CA1563ADF4A3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0C57854D-BD5D-4E52-BD11-3F52C154AA07} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{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}
EndGlobalSection
EndGlobal
diff --git a/SimpleAdmin.sln.DotSettings.user b/SimpleAdmin.sln.DotSettings.user
index a09d00e..771f328 100644
--- a/SimpleAdmin.sln.DotSettings.user
+++ b/SimpleAdmin.sln.DotSettings.user
@@ -11,9 +11,13 @@
ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
- ForceIncluded
\ No newline at end of file
+ ForceIncluded
+ <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from <src>\<SimpleAdmin.Api.IntegrationTests>" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
+ <Project Location="D:\my\itfx\admin\src\SimpleAdmin.Api.IntegrationTests" Presentation="<src>\<SimpleAdmin.Api.IntegrationTests>" />
+</SessionState>
\ No newline at end of file
diff --git a/db.sql b/db.sql
index f8b5f5d..8cb218c 100644
--- a/db.sql
+++ b/db.sql
@@ -76,6 +76,7 @@ CREATE TABLE `t_order` (
`IsIntercept` TINYINT NOT NULL DEFAULT 0 COMMENT '是否拦截:0-否,1-是',
`IsRemote` TINYINT NOT NULL DEFAULT 0 COMMENT '是否偏远:0-否,1-是',
`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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -100,6 +101,7 @@ CREATE TABLE `t_order_template` (
`TemplateKey` VARCHAR(64) NULL COMMENT '模板标识',
`CustomerId` BIGINT NULL COMMENT '客户ID(自定义模板)',
`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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -119,6 +121,7 @@ CREATE TABLE `t_order_task` (
`TemplateId` BIGINT NULL COMMENT '模板ID',
`CustomerId` BIGINT NULL COMMENT '客户ID',
`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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -295,6 +298,7 @@ CREATE TABLE `t_sales_product` (
`CustomerWaybill` VARCHAR(32) NULL COMMENT '系统面单/客户面单',
`BlacklistAreaId` BIGINT NULL COMMENT '黑名单区ID',
`RemoteAreaId` BIGINT NULL COMMENT '偏远分区ID',
+ `PriceAreaId` BIGINT NULL COMMENT '价格分区ID',
`WeightRuleId` BIGINT NULL COMMENT '计重规则ID',
`WeightRoundingId` BIGINT NULL COMMENT '计重进位ID',
`ProductDescription` TEXT COMMENT '产品描述',
@@ -302,6 +306,7 @@ CREATE TABLE `t_sales_product` (
`IsActive` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
`Scheme` VARCHAR(32) NOT NULL COMMENT '排货方案:costLowest/timeFastest/overallBest',
`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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -407,13 +412,17 @@ CREATE TABLE `t_sales_product_publish_record` (
CREATE TABLE `t_service_channel_group` (
`Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`Name` VARCHAR(128) NOT NULL COMMENT '渠道组名称',
+ `AppId` BIGINT NOT NULL COMMENT '应用ID',
+ `ApiServiceProviderId` BIGINT NOT NULL COMMENT 'API服务商ID',
`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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
`UpdateTime` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`Id`),
- UNIQUE KEY `uk_scg_name` (`Name`)
+ UNIQUE KEY `uk_scg_name` (`Name`),
+ KEY `idx_scg_app_provider` (`AppId`, `ApiServiceProviderId`)
) COMMENT='服务渠道组表';
CREATE TABLE `t_service_channel` (
@@ -429,6 +438,7 @@ CREATE TABLE `t_service_channel` (
`PreScript` TEXT COMMENT '入库前脚本',
`IsActive` TINYINT NOT NULL DEFAULT 1 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -518,6 +528,7 @@ CREATE TABLE `t_kpi_area` (
`Name` VARCHAR(128) NOT NULL COMMENT '分区名称',
`SystemName` VARCHAR(128) 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -551,6 +562,7 @@ CREATE TABLE `t_weight_rule` (
`AppKey` VARCHAR(64) NOT NULL COMMENT '应用标识',
`Name` VARCHAR(128) NOT 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -580,6 +592,7 @@ CREATE TABLE `t_weight_rounding` (
`AppKey` VARCHAR(64) NOT NULL COMMENT '应用标识',
`Name` VARCHAR(128) NOT 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -614,6 +627,7 @@ CREATE TABLE `t_auto_trigger` (
`PromptMessage` VARCHAR(512) NULL COMMENT '提示信息',
`Notifier` VARCHAR(128) 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -629,6 +643,7 @@ CREATE TABLE `t_label_template` (
`TemplateJson` JSON NULL COMMENT 'vg-print 模板 JSON',
`TemplateType` VARCHAR(32) NULL COMMENT '尾程/客户/入库/出库/sku',
`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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -650,6 +665,7 @@ CREATE TABLE `t_inbound_record` (
`OperateTime` DATETIME NOT NULL COMMENT '操作时间',
`OperatorId` BIGINT NULL COMMENT '操作人ID',
`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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -667,6 +683,7 @@ CREATE TABLE `t_outbound_record` (
`OperateTime` DATETIME NOT NULL COMMENT '操作时间',
`OperatorId` BIGINT NULL COMMENT '操作人ID',
`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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -687,6 +704,7 @@ CREATE TABLE `t_label_print_record` (
`PrintTime` DATETIME NOT NULL COMMENT '打印时间',
`OperatorId` BIGINT NULL COMMENT '操作人ID',
`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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
@@ -700,6 +718,7 @@ CREATE TABLE `t_label_print_record` (
CREATE TABLE `t_customer` (
`Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`CustomerCode` VARCHAR(32) NOT NULL COMMENT '客户编号,规则:前缀w+年份+序号',
+ `CustomerName` VARCHAR(128) NOT NULL COMMENT '客户名称',
`WechatAvatar` VARCHAR(255) NULL COMMENT '微信头像URL',
`WechatNickname` VARCHAR(64) NULL COMMENT '微信昵称',
`Email` VARCHAR(64) NULL COMMENT '邮箱,用于登录/找回密码',
@@ -720,6 +739,7 @@ CREATE TABLE `t_customer` (
`LastLoginIp` VARCHAR(64) NULL COMMENT '最后在线IP',
`LastLoginTime` DATETIME NULL COMMENT '最后在线时间',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
`WechatOpenid` VARCHAR(64) NULL COMMENT '微信OpenID',
`Password` VARCHAR(64) NOT NULL COMMENT '密码(MD5加密存储)',
PRIMARY KEY (`Id`),
@@ -746,6 +766,7 @@ CREATE TABLE `t_customer_sub_account` (
`UpdateUserId` INT NULL COMMENT '更新人',
`UpdateTime` DATETIME NULL COMMENT '更新时间',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_csa_code` (`SubAccountCode`),
UNIQUE KEY `uk_csa_phone` (`SubPhone`),
@@ -770,12 +791,142 @@ CREATE TABLE `t_recharge_record` (
`AuditTime` DATETIME NULL COMMENT '审核时间',
`AuditBy` VARCHAR(32) NULL COMMENT '审核人(管理员账号)',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_rr_serial` (`SerialNumber`),
KEY `idx_rr_customer_status_time` (`CustomerId`, `RechargeStatus`, `RechargeTime`),
KEY `idx_rr_audit_time` (`AuditTime`)
) COMMENT='充值记录表';
+CREATE TABLE `t_country` (
+ `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
+ `ChineseName` VARCHAR(128) NOT NULL COMMENT '国家中文名',
+ `EnglishName` VARCHAR(128) NOT NULL COMMENT '国家英文名',
+ `CountryCode` VARCHAR(2) NOT NULL COMMENT 'ISO 3166-1 alpha-2 国家二字码',
+ `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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `UpdateUserId` INT NULL COMMENT '更新人',
+ `UpdateTime` DATETIME NULL COMMENT '更新时间',
+ PRIMARY KEY (`Id`),
+ UNIQUE KEY `uk_country_code` (`CountryCode`),
+ KEY `idx_country_name` (`EnglishName`, `ChineseName`)
+) COMMENT='国家表';
+
+CREATE TABLE `t_dict` (
+ `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
+ `DictType` VARCHAR(64) NOT NULL COMMENT '字典类型',
+ `DictSort` INT NOT NULL DEFAULT 0 COMMENT '排序值',
+ `DictCode` VARCHAR(64) NOT NULL COMMENT '字典编码',
+ `DictName` VARCHAR(128) NOT 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `UpdateUserId` INT NULL COMMENT '更新人',
+ `UpdateTime` DATETIME NULL COMMENT '更新时间',
+ PRIMARY KEY (`Id`),
+ UNIQUE KEY `uk_dict_type_code` (`DictType`, `DictCode`),
+ KEY `idx_dict_type_sort` (`DictType`, `DictSort`, `Id`)
+) COMMENT='系统字典表';
+
+CREATE TABLE `t_notice` (
+ `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
+ `NoticeType` VARCHAR(16) NOT NULL COMMENT '通知类型:notice/message',
+ `Title` VARCHAR(200) NOT NULL COMMENT '通知标题',
+ `Content` TEXT NOT NULL COMMENT '通知内容',
+ `IsPopup` TINYINT NOT NULL DEFAULT 0 COMMENT '是否弹窗:0-否,1-是',
+ `ReceiverType` VARCHAR(16) NOT NULL COMMENT '接收对象:user/customer',
+ `ReceiverIds` JSON NOT NULL COMMENT '接收对象ID列表(JSON数组)',
+ `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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `UpdateUserId` INT NULL COMMENT '更新人',
+ `UpdateTime` DATETIME NULL COMMENT '更新时间',
+ PRIMARY KEY (`Id`),
+ KEY `idx_notice_type_receiver_time` (`NoticeType`, `ReceiverType`, `CreateTime`),
+ KEY `idx_notice_title` (`Title`)
+) COMMENT='公告通知表';
+
+CREATE TABLE `t_notice_read` (
+ `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
+ `NoticeId` BIGINT NOT NULL COMMENT '通知ID',
+ `ReceiverType` VARCHAR(16) NOT NULL COMMENT '接收对象:user/customer',
+ `ReceiverId` BIGINT NOT NULL COMMENT '接收主体ID',
+ `ReadTime` DATETIME NOT 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `UpdateUserId` INT NULL COMMENT '更新人',
+ `UpdateTime` DATETIME NULL COMMENT '更新时间',
+ PRIMARY KEY (`Id`),
+ UNIQUE KEY `uk_notice_read_identity` (`NoticeId`, `ReceiverType`, `ReceiverId`),
+ KEY `idx_notice_read_receiver` (`ReceiverType`, `ReceiverId`, `ReadTime`)
+) COMMENT='通知已读明细表';
+
+INSERT INTO `t_country`
+(`ChineseName`, `EnglishName`, `CountryCode`, `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`)
+VALUES
+('中国', 'China', 'CN', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('中国香港', 'Hong Kong SAR China', 'HK', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('中国澳门', 'Macao SAR China', 'MO', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('中国台湾', 'Taiwan', 'TW', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('美国', 'United States', 'US', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('加拿大', 'Canada', 'CA', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('英国', 'United Kingdom', 'GB', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('爱尔兰', 'Ireland', 'IE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('德国', 'Germany', 'DE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('法国', 'France', 'FR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('意大利', 'Italy', 'IT', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('西班牙', 'Spain', 'ES', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('葡萄牙', 'Portugal', 'PT', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('荷兰', 'Netherlands', 'NL', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('比利时', 'Belgium', 'BE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('卢森堡', 'Luxembourg', 'LU', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('瑞士', 'Switzerland', 'CH', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('奥地利', 'Austria', 'AT', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('瑞典', 'Sweden', 'SE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('挪威', 'Norway', 'NO', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('芬兰', 'Finland', 'FI', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('丹麦', 'Denmark', 'DK', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('波兰', 'Poland', 'PL', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('捷克', 'Czechia', 'CZ', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('匈牙利', 'Hungary', 'HU', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('罗马尼亚', 'Romania', 'RO', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('俄罗斯', 'Russia', 'RU', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('乌克兰', 'Ukraine', 'UA', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('土耳其', 'Turkey', 'TR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('希腊', 'Greece', 'GR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('以色列', 'Israel', 'IL', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('阿联酋', 'United Arab Emirates', 'AE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('沙特阿拉伯', 'Saudi Arabia', 'SA', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('日本', 'Japan', 'JP', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('韩国', 'South Korea', 'KR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('新加坡', 'Singapore', 'SG', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('马来西亚', 'Malaysia', 'MY', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('泰国', 'Thailand', 'TH', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('越南', 'Vietnam', 'VN', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('印度尼西亚', 'Indonesia', 'ID', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('菲律宾', 'Philippines', 'PH', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('印度', 'India', 'IN', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('巴基斯坦', 'Pakistan', 'PK', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('孟加拉国', 'Bangladesh', 'BD', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('澳大利亚', 'Australia', 'AU', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('新西兰', 'New Zealand', 'NZ', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('南非', 'South Africa', 'ZA', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('尼日利亚', 'Nigeria', 'NG', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('肯尼亚', 'Kenya', 'KE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('埃及', 'Egypt', 'EG', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('巴西', 'Brazil', 'BR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('阿根廷', 'Argentina', 'AR', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('智利', 'Chile', 'CL', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('哥伦比亚', 'Colombia', 'CO', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('秘鲁', 'Peru', 'PE', 0, 0, CURRENT_TIMESTAMP, NULL, NULL),
+('墨西哥', 'Mexico', 'MX', 0, 0, CURRENT_TIMESTAMP, NULL, NULL);
+
CREATE TABLE `t_warehouse_address` (
`Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`WarehouseName` VARCHAR(128) NOT NULL COMMENT '仓库名称',
@@ -787,6 +938,7 @@ CREATE TABLE `t_warehouse_address` (
`ServiceWechat` VARCHAR(64) NULL COMMENT '客服微信',
`ServiceWechatFiles` JSON 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
diff --git a/db.sync.sql b/db.sync.sql
deleted file mode 100644
index cd8ed98..0000000
--- a/db.sync.sql
+++ /dev/null
@@ -1,41 +0,0 @@
--- OMS 增量数据库同步脚本
--- 适用场景:
--- 1. 已经按旧版 db.sql 建库
--- 2. 需要把本轮实体新增字段和任务失败表补齐到当前代码
--- 3. 建议在执行前先备份数据库
-
--- 订单主表:补充幂等字段与申报汇总字段
-ALTER TABLE `t_order`
- ADD COLUMN `RequestId` VARCHAR(64) NULL COMMENT '请求幂等ID' AFTER `Id`,
- ADD COLUMN `DeclaredCurrency` VARCHAR(16) NULL COMMENT '申报币种' AFTER `CodSum`,
- ADD COLUMN `DeclaredSum` DECIMAL(18,4) NULL COMMENT '申报金额' AFTER `DeclaredCurrency`,
- ADD COLUMN `Quantity` INT NULL COMMENT '包裹数量' AFTER `DeclaredSum`;
-
-ALTER TABLE `t_order`
- ADD UNIQUE KEY `uk_ord_request_id` (`RequestId`);
-
--- 订单申报表:补充进口/出口 HsCode
-ALTER TABLE `t_order_declaration`
- ADD COLUMN `ImHsCode` VARCHAR(50) NULL COMMENT '进口HsCode' AFTER `HsCode`,
- ADD COLUMN `ExHsCode` VARCHAR(50) NULL COMMENT '出口HsCode' AFTER `ImHsCode`;
-
--- 导入任务表:补充成功数与失败数
-ALTER TABLE `t_order_task`
- ADD COLUMN `SuccessCount` INT NULL COMMENT '成功数' AFTER `Quantity`,
- ADD COLUMN `FailCount` INT NULL COMMENT '失败数' AFTER `SuccessCount`;
-
--- 导入任务失败明细表:用于 /order/task/detail.failList
-CREATE TABLE `t_order_task_fail` (
- `Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
- `TaskId` BIGINT NOT NULL COMMENT '任务ID',
- `RowNumber` INT NOT NULL COMMENT '失败行号',
- `Reason` VARCHAR(500) NOT NULL COMMENT '失败原因',
- `RawDataJson` JSON NULL COMMENT '原始行数据',
- `IsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除',
- `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
- `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `UpdateUserId` INT NULL DEFAULT NULL COMMENT '修改人',
- `UpdateTime` DATETIME NULL DEFAULT NULL COMMENT '修改时间',
- PRIMARY KEY (`Id`),
- KEY `idx_otf_task` (`TaskId`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单导入任务失败明细表';
diff --git a/oms表结构与接口设计20260427.md b/oms表结构与接口设计20260427.md
new file mode 100644
index 0000000..1d07366
--- /dev/null
+++ b/oms表结构与接口设计20260427.md
@@ -0,0 +1,5933 @@
+# oms 表结构与接口设计文档
+
+----------------------------------------------------------------------------------------
+
+## OMS 系统说明
+
+OMS(Order Management System,订单管理系统)作为独立服务运行和admin权限管理服务合并,面向跨境物流/仓储业务场景,提供**订单**、**渠道**、**操作**、**客户**四大核心模块,支撑从客户注册、充值、下单、定价、支付到货物入库出库的全流程管理。
+
+### 核心模块
+
+| 模块 | 说明 |
+| --- | --- |
+| **订单** | 订单全生命周期管理,包括创建、预报、支付、发货、跟踪等 |
+| **渠道** | 销售产品与物流渠道的配置、定价及买价/卖价计算 |
+| **操作** | 入库、出库、打印等仓储操作 |
+| **客户** | 客户注册、认证、账户余额、额度及权限管理 |
+
+
+----------------------------------------------------------------------------------------
+
+### 1.2 表结构设计公共规范
+
+#### 表的字段命名规范
+
+| 规范 | 说明 | 示例 |
+| --- | --- | --- |
+| 驼峰命名 | 字段名开头大写+单词驼峰 | `ServiceProviderId` |
+| 单数名词 | 表名用单数,表示实体 | `customer` 而非 `customers` |
+| 业务前缀 | 表名按业务模块加前缀 | `t_order`、`t_customer` |
+
+
+#### 必备字段(所有表)
+
+| 字段名 | 字段类型 | 说明 |
+| --- | --- | --- |
+| Id | BIGINT | 主键,自增,步长 1 |
+| IsDeleted | TINYINT | 软删除:0-否,1-是,默认 0 |
+| CreateUserId | INT | 创建人 |
+| CreateTime | DATETIME | 创建时间 |
+| UpdateUserId | INT | 更新人 |
+| UpdateTime | DATETIME | 更新时间 |
+
+#### 字段命名规范
+
+| 规范 | 说明 | 示例 |
+| --- | --- | --- |
+| 布尔字段 | 用 `IsXxx` 或 `HasXxx` | `IsDeleted`、`IsActive` |
+| 外键字段 | `关联表名Id` 或 `XxxId` | `CustomerId`、`WarehouseId` |
+| 时间字段 | `XxxTime` 或 `XxxAt` | `CreateTime`、`PayTime` |
+| 禁用保留字 | 不用 desc、range、match 等 | 用 `Description` 替代 `Desc` |
+
+#### 数据类型规范
+
+| 类型 | 说明 |
+| --- | --- |
+| 主键 | BIGINT,单表自增 |
+| 金额/重量 | DECIMAL,禁止 float/double |
+| 字符串 | varchar 长度 ≤ 5000,超出用 TEXT |
+| 枚举 | 小数据量用 TINYINT,可读性要求高用 VARCHAR |
+| 状态标志 | TINYINT |
+
+### 1.3 接口设计公共规范
+
+| 规范 | 说明 |
+| --- | --- |
+| 操作审计必填 | 任何新增、编辑、删除操作,必须记录操作者与操作时间 |
+| 新增接口 | 创建成功后需落库 `CreateUserId`、`CreateTime` |
+| 编辑接口 | 更新成功后需落库 `UpdateUserId`、`UpdateTime` |
+| 删除接口 | 逻辑删除时需落库 `UpdateUserId`、`UpdateTime`,并更新 `IsDeleted=1` |
+
+> 审计字段中的操作者信息优先从当前登录会话获取,不允许由前端直接信任覆盖。
+
+
+----------------------------------------------------------------------------------------
+
+## 需求迭代目录(按日期管理)
+
+> 迭代编号建议使用 `YYYYMMDD-序号`,按日期倒序维护;不同功能迭代可使用 `-01`、`-02` 递增。
+
+| 迭代日期 | 迭代编号 | 迭代功能 | 变更模块 | 备注 |
+| --- | --- | --- | --- | --- |
+| 2026-04-27 | 20260427-01 | 客户的账号密码接入登录接口/rbac/auth/login。入参增加一个类型,0(用户)/1(客户),客户的角色编码读取`t_customer`上的`Role`字段来划分菜单权限,默认使用编码`Customer` | 客户模块 | 已完成 |
+| 2026-04-27 | 20260427-02 | 任何新增、编辑、删除操作,必须通过authorization来记录操作者与操作时间 | 全局 | 已完成 |
+| 2026-04-27 | 20260427-03 | 文档版本差异汇总(20260427 vs 2026040802) | 全文档 | 已完成结构与内容差异归档,见下方“差异摘要” |
+
+### 20260427-03 差异明细(按表结构与接口)
+
+> 对比范围:`oms表结构与接口设计20260427.md` vs `oms表结构与接口设计2026040802.md`
+
+#### 一、表结构差异
+
+**新增表(4)**
+
+- `t_country`
+- `t_dict`
+- `t_notice`
+- `t_notice_read`
+
+**更新表(0)**
+
+- 无(按表名维度无同名结构替换;本次以新增为主)
+
+**删除表(0)**
+
+- 无
+
+#### 二、接口差异
+
+**新增接口(22)**
+
+- `GET /system/country/list`(国家列表)
+- `GET /system/dict/list`(字典列表)
+- `GET /system/notice/list`(通知列表)
+- `POST /channel/service-channel/group/create`(新增服务商)
+- `POST /channel/service-channel/group/delete`(删除服务商)
+- `POST /channel/service-channel/group/update`(编辑服务商)
+- `POST /order/parcel/batch-create`(批量创建小包)
+- `POST /system/dict/create`(新增字典)
+- `POST /system/dict/delete`(删除字典)
+- `POST /system/dict/update`(更新字典)
+- `POST /system/notice/create`(新增通知)
+- `POST /system/notice/delete`(删除通知)
+- `POST /system/notice/read-confirm`(确认已读)
+- `POST /system/notice/update`(编辑通知)
+
+**更新接口(8)**
+
+分区的接口命名更新了,可忽略,因为已经完成了变更了,知道这个事情就行。
+---现在接口命名
+- `POST /channel/kpi-area/create`(创建分区)
+- `POST /channel/kpi-area/delete`(删除分区)
+- `POST /channel/kpi-area/info/add`(新增分区明细)
+- `POST /channel/kpi-area/info/delete`(删除分区明细)
+- `POST /channel/kpi-area/info/list`(分区明细列表)
+- `POST /channel/kpi-area/info/update`(修改分区明细)
+- `POST /channel/kpi-area/list`(分区列表)
+- `POST /channel/kpi-area/update`(修改分区)
+---原来接口命名
+- `POST Track/ForewarningService/KpiArea/AddAreaInfo`(新增分区明细)
+- `POST Track/ForewarningService/KpiArea/CreateKpiArea`(创建分区)
+- `POST Track/ForewarningService/KpiArea/DeleteKpiArea`(删除分区)
+- `POST Track/ForewarningService/KpiArea/DeleteKpiAreaInfo`(删除分区明细)
+- `POST Track/ForewarningService/KpiArea/GetKpiAreaInfos`(分区明细列表)
+- `POST Track/ForewarningService/KpiArea/GetKpiAreas`(分区列表)
+- `POST Track/ForewarningService/KpiArea/ModifyKpiArea`(修改分区)
+- `POST Track/ForewarningService/KpiArea/ModifyKpiAreaInfo`(修改分区明细)
+
+**删除接口(10)**
+
+- `POST /channel/sales-product/config/save`(保存产品配置)
+- `POST /channel/service-channel/config/save`(保存渠道配置)
+
+
+
+
+----------------------------------------------------------------------------------------
+
+## 二、订单模块
+
+### 1.1 核心需求说明
+
+- **我的订单**:支持订单全流程管理,包括分页查询、详情查看、支付、预报、拦截/取消拦截、删除、打印、导出;订单状态覆盖草稿→已预报→已收货→待支付→待发货→运输中→派送成功/失败等全生命周期。
+- **我要查价**:根据目的国、运输方式、重量尺寸、货物类型等条件查价,返回可选渠道及费用明细。
+- **我要下单**:支持下载下单模板、上传 Excel 批量下单、查看任务进度与详情;模板分为标准模板与自定义模板。
+
+### 1.2 表结构设计
+
+> **命名约定**:表名按业务模块加 t_ 前缀;字段名驼峰命名(开头大写+单词驼峰)。
+
+#### 订单表 `t_order`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| ServiceNo | VARCHAR | 64 | - | 是 | 运单号 |
+| ClientNo | VARCHAR | 64 | - | 否 | 客户单号 |
+| TrackNo | VARCHAR | 64 | - | 否 | 跟踪号 |
+| ProductCode | VARCHAR | 128 | - | 否 | 产品code |
+| ChannelCode | VARCHAR | 128 | - | 否 | 渠道code |
+| ConsigneeCountry | VARCHAR | 8 | - | 否 | 国家二字码 |
+| CargoAttribute | VARCHAR | 32 | - | 否 | 货物属性:general-普货,electronics-3c&带电,sensitive-敏感货 |
+| OrderStatus | VARCHAR | 32 | - | 是 | 订单状态 |
+| WarehouseId | BIGINT | - | 外键 | 否 | 仓库ID |
+| CustomerId | BIGINT | - | 外键 | 否 | 客户ID |
+| SenderId | BIGINT | - | 外键 | 否 | 发件人ID → t_order_sender |
+| RecipientId | BIGINT | - | 外键 | 否 | 收件人ID → t_order_recipient |
+| DeclarationId | BIGINT | - | 外键 | 否 | 申报信息ID → t_order_declaration |
+| ReceiveTime | DATETIME | - | - | 否 | 收货时间 |
+| ShipTime | DATETIME | - | - | 否 | 发货时间 |
+| PayTime | DATETIME | - | - | 否 | 支付时间 |
+| SignTime | DATETIME | - | - | 否 | 签收时间 |
+| IsCod | TINYINT | - | - | 否 | 是否COD:0-否,1-是 |
+| CodCurrency | VARCHAR | 16 | - | 否 | COD币种 |
+| CodSum | DECIMAL | 18,4 | - | 否 | COD金额 |
+| IsIntercept | TINYINT | - | - | 否 | 是否拦截:0-否,1-是 |
+| IsRemote | TINYINT | - | - | 否 | 是否偏远:0-否,1-是 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+**OrderStatus 枚举**:draft, predeclared, received, pendingPayment, pendingShip, pendingOnline, shipping, deliverySuccess, deliveryFailed, problematic, deleted, claimed
+
+**CargoAttribute 枚举**:general-普货,electronics-3c&带电,sensitive-敏感货
+
+#### 下单模板表 `t_order_template`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| Name | VARCHAR | 64 | - | 是 | 模板名称 |
+| Type | VARCHAR | 16 | - | 是 | standard/custom |
+| TemplateKey | VARCHAR | 64 | - | 否 | 模板标识 |
+| CustomerId | BIGINT | - | 外键 | 否 | 客户ID(自定义模板) |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 下单任务表 `t_order_task`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| TaskName | VARCHAR | 128 | - | 是 | 任务名称 |
+| Quantity | INT | - | - | 否 | 数量 |
+| Status | VARCHAR | 16 | - | 是 | 处理中/已完成/待处理 |
+| TemplateId | BIGINT | - | 外键 | 否 | 模板ID |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+| CustomerId | BIGINT | - | 外键 | 否 | 客户ID |
+
+#### 订单发件人表 `t_order_sender`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| OrderId | BIGINT | - | 外键 | 是 | 订单ID |
+| SenderName | VARCHAR | 50 | - | 否 | 姓名 |
+| Company | VARCHAR | 255 | - | 否 | 公司 |
+| Country | INT | - | - | 否 | 国家 |
+| Province | VARCHAR | 50 | - | 否 | 州省 |
+| City | VARCHAR | 50 | - | 否 | 城市 |
+| ZipCode | VARCHAR | 50 | - | 否 | 邮编 |
+| StreetAddress1 | VARCHAR | 500 | - | 否 | 地址1 |
+| StreetAddress2 | VARCHAR | 500 | - | 否 | 地址2 |
+| StreetAddress3 | VARCHAR | 500 | - | 否 | 地址3 |
+| HouseNumber | VARCHAR | 50 | - | 否 | 门牌号 |
+| Phone | VARCHAR | 50 | - | 否 | 手机 |
+| TelePhone | VARCHAR | 50 | - | 否 | 电话 |
+| Email | VARCHAR | 60 | - | 否 | 邮箱 |
+| IsDefault | TINYINT | - | - | 否 | 是否默认:0-否,1-是 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 订单收件人表 `t_order_recipient`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| OrderId | BIGINT | - | 外键 | 是 | 订单ID |
+| RecipientsName | VARCHAR | 30 | - | 否 | 收件人名称 |
+| CompanyName | VARCHAR | 60 | - | 否 | 收件人公司名称 |
+| Country | INT | - | - | 否 | 国家 |
+| Phone | VARCHAR | 20 | - | 否 | 收件人手机 |
+| TelePhone | VARCHAR | 20 | - | 否 | 收件人电话 |
+| ZipCode | VARCHAR | 15 | - | 否 | 收件人邮编 |
+| Province | VARCHAR | 25 | - | 否 | 收件人省/州 |
+| City | VARCHAR | 25 | - | 否 | 收件人城市 |
+| StreetNumber | VARCHAR | 20 | - | 否 | 收件人门牌号 |
+| StreetAddress1 | VARCHAR | 255 | - | 否 | 收件人地址1 |
+| StreetAddress2 | VARCHAR | 255 | - | 否 | 收件人地址2 |
+| StreetAddress3 | VARCHAR | 255 | - | 否 | 收件人地址3 |
+| Email | VARCHAR | 60 | - | 否 | 收件人邮箱 |
+| TaxType | INT | - | - | 否 | 税号类型 |
+| TaxNo | VARCHAR | 35 | - | 否 | 税号 |
+| CertificateType | INT | - | - | 否 | 证件类型 |
+| CertificateNo | VARCHAR | 30 | - | 否 | 证件号码 |
+| Eori | VARCHAR | 255 | - | 否 | EORI |
+| Vat | VARCHAR | 255 | - | 否 | Vat |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 订单申报信息表 `t_order_declaration`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| OrderId | BIGINT | - | 外键 | 是 | 订单ID |
+| CnName | VARCHAR | 500 | - | 否 | 品名--中 |
+| EnName | VARCHAR | 500 | - | 否 | 品名--英 |
+| Sku | VARCHAR | 255 | - | 否 | SKU |
+| Unit | VARCHAR | 5 | - | 否 | 单位 |
+| UnitPrice | DECIMAL | 10,2 | - | 否 | 单价 |
+| UnitWeight | DECIMAL | 11,3 | - | 否 | 单重 |
+| Quantity | INT | - | - | 否 | 数量 |
+| Amount | DECIMAL | 11,2 | - | 否 | 总金额 |
+| Weight | DECIMAL | 11,3 | - | 否 | 总重量 |
+| HsCode | VARCHAR | 50 | - | 否 | HsCode |
+| SkuImage | VARCHAR | 255 | - | 否 | 图片 |
+| Category | VARCHAR | 20 | - | 否 | 类别 |
+| Brand | VARCHAR | 50 | - | 否 | 品牌 |
+| BrandType | VARCHAR | 50 | - | 否 | 品牌类型 |
+| IsMagnetic | BIT | - | - | 否 | 是否带磁带电 |
+| Distribution | VARCHAR | 255 | - | 否 | 配货信息 |
+| Material | VARCHAR | 50 | - | 否 | 材质 |
+| EnMaterial | VARCHAR | 64 | - | 否 | 英文材质 |
+| Purpose | VARCHAR | 50 | - | 否 | 用途 |
+| Attributes | VARCHAR | 50 | - | 否 | 属性 |
+| OriginCountry | VARCHAR | 50 | - | 否 | 原产国 |
+| SalesLink | VARCHAR | 255 | - | 否 | 销售链接 |
+| Model | VARCHAR | 64 | - | 否 | 型号 |
+| Specifications | VARCHAR | 50 | - | 否 | 规格 |
+| Comment | VARCHAR | 500 | - | 否 | 备注 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 订单件重尺表 `t_order_weight`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| OrderId | BIGINT | - | 外键 | 是 | 订单ID |
+| Type | VARCHAR | 16 | - | 是 | 类型:forecast/receive/ship/downstream |
+| Length | DECIMAL | 10,2 | - | 否 | 长(cm) |
+| Width | DECIMAL | 10,2 | - | 否 | 宽(cm) |
+| Height | DECIMAL | 10,2 | - | 否 | 高(cm) |
+| ActualWeight | DECIMAL | 10,3 | - | 否 | 实重(kg) |
+| VolumetricWeight | DECIMAL | 10,3 | - | 否 | 体积重(kg) |
+| ChargeableWeight | DECIMAL | 10,3 | - | 否 | 计费重(kg) |
+| Cubic | DECIMAL | 12,6 | - | 否 | 立方(m³) |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+**Type 枚举**:forecast(预报)、receive(收货)、ship(发货)、downstream(下游)
+
+#### 订单费用表 `t_order_fee`(子表,关联 t_order)
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| OrderId | BIGINT | - | 外键 | 是 | 订单ID |
+| Type | VARCHAR | 16 | - | 是 | 类型:receivable(应收)/payable(应付) |
+| CostItem | VARCHAR | 64 | - | 是 | 费用项 |
+| Amount | DECIMAL | 18,4 | - | 是 | 金额 |
+| Currency | VARCHAR | 16 | - | 是 | 币种:RMB/USD/EUR |
+| DownstreamProvider | VARCHAR | 128 | - | 否 | 下游服务商 |
+| PriceNo | VARCHAR | 64 | - | 否 | 价格编号 |
+| PriceEffectiveTime | DATETIME | - | - | 否 | 价格编号生效时间 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+**Type 枚举**:receivable(应收)、payable(应付)
+
+### 1.3 核心接口设计
+
+#### 我的订单
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 分页查询订单列表 | POST | /order/list | 支持 status、keyword、createTimeStart/End 筛选 |
+| 订单详情 | GET | /order/detail | 根据订单ID获取详情 |
+| 支付 | POST | /order/pay | 订单支付 |
+| 预报 | POST | /order/pre-declare | 订单预报 |
+| 拦截 | POST | /order/intercept | 订单拦截 |
+| 取消拦截 | POST | /order/cancel-intercept | 取消订单拦截 |
+| 删除 | POST | /order/delete | 删除订单 |
+| 打印 | POST | /order/print | 打印订单 |
+| 导出 | POST | /order/export | 导出订单 |
+
+##### 分页查询订单列表(POST /order/list)
+
+###### 1 接口请求示例
+
+**请求 /order/list**(POST,分页参数放 Body)
+
+```json
+{
+ "page": 1, // 页码,从1开始
+ "pageSize": 20, // 每页条数
+ "status": "all|draft|predeclared|received|...", // 订单状态筛选
+ "keyword": "", // 关键词(运单号/客户单号/跟踪号)
+ "createTimeStart": "", // 创建时间起
+ "createTimeEnd": "" // 创建时间止
+}
+```
+
+**响应**(用于 order/myOrders 列表及订单详情抽屉,字段与 parcel/create 对应,创建时传啥返回啥 + weightList)
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "clientNo": "CUST...",
+ "trackNo": "TRK...",
+ "serviceNo": "LP...",
+ "productCode": "US_AIR",
+ "channelCode": "CH001",
+ "cargoAttribute": "general",
+ "warehouseId": 1,
+ "warehouse": "深圳仓",
+ "customerId": 1,
+ "isCod": false,
+ "codCurrency": "",
+ "codSum": "",
+ "orderStatus": "待支付",
+ "status": "pendingPayment",
+ "receiveTime": "",
+ "shipTime": "",
+ "payTime": "",
+ "signTime": "",
+ "isIntercept": false,
+ "isRemote": false,
+ "createTime": "2024-01-01 12:00:00",
+ "createBy": "",
+ "updateTime": "",
+ "updateBy": "",
+ "sender": {
+ "senderName": "发件人",
+ "company": "示例公司",
+ "phone": "13800138000",
+ "telePhone": "",
+ "email": "sender@example.com",
+ "countryCode": 0,
+ "province": "广东省",
+ "city": "深圳市",
+ "zipCode": "518000",
+ "streetAddress1": "示例地址1",
+ "streetAddress2": "",
+ "streetAddress3": "",
+ "houseNumber": ""
+ },
+ "receiver": {
+ "recipientsName": "收件人1",
+ "companyName": "",
+ "phone": "13800138000",
+ "telePhone": "",
+ "email": "",
+ "countryCode": 0,
+ "province": "California",
+ "city": "Los Angeles",
+ "streetNumber": "",
+ "streetAddress1": "123 Main St",
+ "streetAddress2": "",
+ "streetAddress3": "",
+ "zipCode": "90001",
+ "taxType": 0,
+ "taxNo": "",
+ "certificateType": 0,
+ "certificateNo": "",
+ "eori": "",
+ "vat": ""
+ },
+ "items": [
+ {
+ "cnName": "示例商品",
+ "enName": "Sample Product",
+ "sku": "SKU001",
+ "unit": "件",
+ "unitPrice": 10,
+ "unitWeight": 0.5,
+ "quantity": 1,
+ "amount": 10,
+ "weight": 0.5,
+ "hsCode": "1234567890",
+ "skuImage": "",
+ "category": "普货",
+ "brand": "示例品牌",
+ "brandType": "自有",
+ "isMagnetic": false,
+ "distribution": "",
+ "material": "塑料",
+ "enMaterial": "Plastic",
+ "purpose": "日用",
+ "attributes": "",
+ "originCountry": "中国",
+ "salesLink": "",
+ "model": "",
+ "specifications": "",
+ "comment": ""
+ }
+ ],
+ "weightList": [
+ {
+ "type": "forecast",
+ "length": "20",
+ "width": "15",
+ "height": "10",
+ "actualWeight": "2.50",
+ "volumetricWeight": "2.00",
+ "chargeableWeight": "2.50",
+ "cubic": "0.003"
+ }
+ ]
+ }
+ ],
+ "total": 100
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 订单详情(GET /order/detail)
+
+###### 1 接口请求示例
+
+**请求 /order/detail**(GET,Query 参数)
+
+```
+?id=1
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "id": 1,
+ "clientNo": "CUST...",
+ "trackNo": "TRK...",
+ "serviceNo": "LP...",
+ "productCode": "US_AIR",
+ "channelCode": "CH001",
+ "cargoAttribute": "general",
+ "warehouseId": 1,
+ "warehouse": "深圳仓",
+ "customerId": 1,
+ "sender": {},
+ "receiver": {},
+ "items": [],
+ "weightList": []
+ }
+}
+```
+
+> data 结构与 list 单条 item 一致,含 sender、receiver、items、weightList。
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 支付(POST /order/pay)
+
+###### 1 接口请求示例
+
+**请求 /order/pay**(POST)
+
+```json
+{
+ "id": 1,
+ "requestId": ""
+}
+```
+
+> requestId 幂等,可选。
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 预报(POST /order/pre-declare)
+
+###### 1 接口请求示例
+
+**请求 /order/pre-declare**(POST)
+
+```json
+{
+ "id": 1,
+ "requestId": ""
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 拦截(POST /order/intercept)
+
+###### 1 接口请求示例
+
+**请求 /order/intercept**(POST)
+
+```json
+{
+ "id": 1,
+ "requestId": ""
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 取消拦截(POST /order/cancel-intercept)
+
+###### 1 接口请求示例
+
+**请求 /order/cancel-intercept**(POST)
+
+```json
+{
+ "id": 1,
+ "requestId": ""
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 删除(POST /order/delete)
+
+###### 1 接口请求示例
+
+**请求 /order/delete**(POST)
+
+```json
+{
+ "id": 1,
+ "requestId": ""
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 打印(POST /order/print)
+
+###### 1 接口请求示例
+
+**请求 /order/print**(POST)
+
+```json
+{
+ "id": 1,
+ "requestId": ""
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "printUrl": ""
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 导出(POST /order/export)
+
+###### 1 接口请求示例
+
+**请求 /order/export**(POST)
+
+```json
+{
+ "status": "all|draft|...",
+ "keyword": "",
+ "createTimeStart": "",
+ "createTimeEnd": ""
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "downloadUrl": "",
+ "expireAt": ""
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+#### 我要查价
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 查价 | POST | /order/price-check | 根据目的国、重量尺寸等查价 |
+
+##### 查价(POST /order/price-check)
+
+###### 1 接口请求示例
+
+**请求 /order/price-check**(POST)
+
+```json
+{
+ "destinationCountry": "US", // 目的国二字码
+ "shippingMethod": "", // 运输方式
+ "cargoWeight": "2", // 货物重量(kg)
+ "length": "20", // 长(cm)
+ "width": "15", // 宽(cm)
+ "height": "10", // 高(cm)
+ "cargoType": "普货", // 货物类型
+ "destinationProvince": "", // 目的省州
+ "postalCode": "" // 邮编
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "shippingMethod": "美国空运专线(普货)",
+ "warehouse": "一代深圳仓",
+ "deliveryTime": "3-7",
+ "volumetricWeight": "2",
+ "chargeableWeight": "2",
+ "costFormula": "(62*2) +5+7",
+ "freight": "62",
+ "otherFees": "5",
+ "totalCost": "136",
+ "maxDeliveryDays": 7,
+ "totalCostNum": 136
+ }
+ ]
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+#### 我要下单
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 模板列表 | GET | /order/template/list | 获取下单模板列表 |
+| 下载模板 | GET | /order/template/download | 下载 Excel 模板 |
+| 上传下单 | POST | /order/upload | multipart/form-data,file + templateId |
+| 任务进度列表 | GET | /order/task/list | 批量下单任务列表 |
+| 任务详情 | GET | /order/task/detail | 任务详情 |
+| 创建小包 | POST | /order/parcel/create | 创建小包订单 |
+| 批量创建小包 | POST | /order/parcel/batch-create | 批量创建小包订单 |
+
+##### 创建小包(POST /order/parcel/create)
+
+###### 1 接口请求示例
+
+**请求 /order/parcel/create**(POST)
+
+> 字段与 t_order、t_order_sender、t_order_recipient、t_order_declaration、t_order_weight 表对应,采用 camelCase。
+
+```json
+{
+ "clientNo": "string", // 客户单号 t_order.ClientNo
+ "trackNo": "string", // 跟踪号 t_order.TrackNo
+ "serviceNo": "string", // 运单号 t_order.ServiceNo
+ "productCode": "string", // 产品代码 t_order.ProductCode
+ "channelCode": "string", // 渠道代码 t_order.ChannelCode
+ "cargoAttribute": "string", // 货物属性 t_order.CargoAttribute:general-普货,electronics-3c&带电,sensitive-敏感货
+ "warehouseId": 0, // 仓库ID t_order.WarehouseId
+ "customerId": 0, // 客户ID t_order.CustomerId
+ "length": 3, // 长(cm) t_order_weight.Length
+ "width": 3, // 宽(cm) t_order_weight.Width
+ "height": 3, // 高(cm) t_order_weight.Height
+ "actualWeight": 3, // 实重(kg) t_order_weight.ActualWeight
+ "volumetricWeight": 3, // 体积重(kg) t_order_weight.VolumetricWeight
+ "chargeableWeight": 3, // 计费重(kg) t_order_weight.ChargeableWeight
+ "cubic": 0, // 立方(m³) t_order_weight.Cubic
+ "isCod": false, // 是否COD
+ "codCurrency": "string", // COD币种
+ "codSum": "string", // COD金额
+ "declaredCurrency": "string", // 申报币种
+ "declaredSum": "string", // 申报金额
+ "quantity": "string", // 包裹数量
+ "receiver": { // 收件人 t_order_recipient
+ "recipientsName": "string", // 收件人名称
+ "companyName": "string", // 收件人公司名称
+ "phone": "string", // 收件人手机
+ "telePhone": "string", // 收件人电话
+ "email": "string", // 收件人邮箱
+ "countryCode": 0, // 国家
+ "province": "string", // 收件人省/州
+ "city": "string", // 收件人城市
+ "streetNumber": "string", // 收件人门牌号
+ "streetAddress1": "string", // 收件人地址1
+ "streetAddress2": "string", // 收件人地址2
+ "streetAddress3": "string", // 收件人地址3
+ "zipCode": "string", // 收件人邮编
+ "taxType": 0, // 税号类型
+ "taxNo": "string", // 税号
+ "certificateType": 0, // 证件类型
+ "certificateNo": "string", // 证件号码
+ "eori": "string", // EORI
+ "vat": "string" // Vat
+ },
+ "sender": { // 发件人 t_order_sender
+ "senderName": "string", // 发件人姓名
+ "company": "string", // 发件人公司
+ "phone": "string", // 发件人手机
+ "telePhone": "string", // 发件人电话
+ "email": "string", // 发件人邮箱
+ "countryCode": 0, // 国家
+ "province": "string", // 州省
+ "city": "string", // 城市
+ "zipCode": "string", // 邮编
+ "streetAddress1": "string", // 地址1
+ "streetAddress2": "string", // 地址2
+ "streetAddress3": "string", // 地址3
+ "houseNumber": "string" // 门牌号
+ },
+ "items": [ // 申报明细 t_order_declaration
+ {
+ "cnName": "string", // 品名-中
+ "enName": "string", // 品名-英
+ "sku": "string", // SKU
+ "unit": "string", // 单位
+ "unitPrice": 0, // 单价
+ "unitWeight": 0, // 单重
+ "quantity": 0, // 数量
+ "amount": 0, // 总金额
+ "weight": 0, // 总重量
+ "imHsCode": "string", // 进口HsCode
+ "exHsCode": "string", // 出口HsCode
+ "skuImage": "string", // 图片
+ "category": "string", // 类别
+ "brand": "string", // 品牌
+ "brandType": "string", // 品牌类型
+ "isMagnetic": false, // 是否带磁带电
+ "distribution": "string", // 配货信息
+ "material": "string", // 材质
+ "enMaterial": "string", // 英文材质
+ "purpose": "string", // 用途
+ "attributes": "string", // 属性
+ "originCountry": "string", // 原产国
+ "salesLink": "string", // 销售链接
+ "model": "string", // 型号
+ "specifications": "string", // 规格
+ "comment": "string" // 备注
+ }
+ ]
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "clientNo": "",
+ "serviceNo": "",
+ "trackNo": "",
+ "labelUrl": ""
+ }
+}
+```
+
+> 幂等时可选传 requestId。
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 批量创建小包(POST /order/parcel/batch-create)
+
+###### 1 接口请求示例
+
+**请求 /order/parcel/batch-create**(POST)
+
+> 批量请求体为数组,数组内每一项与 `/order/parcel/create` 的单票请求结构完全一致。
+
+```json
+[
+ {
+ "clientNo": "string", // 客户单号 t_order.ClientNo
+ "trackNo": "string", // 跟踪号 t_order.TrackNo
+ "serviceNo": "string", // 运单号 t_order.ServiceNo
+ "productCode": "string", // 产品代码 t_order.ProductCode
+ "channelCode": "string", // 渠道代码 t_order.ChannelCode
+ "cargoAttribute": "string", // 货物属性 t_order.CargoAttribute:general-普货,electronics-3c&带电,sensitive-敏感货
+ "warehouseId": 0, // 仓库ID t_order.WarehouseId
+ "customerId": 0, // 客户ID t_order.CustomerId
+ "length": 3, // 长(cm) t_order_weight.Length
+ "width": 3, // 宽(cm) t_order_weight.Width
+ "height": 3, // 高(cm) t_order_weight.Height
+ "actualWeight": 3, // 实重(kg) t_order_weight.ActualWeight
+ "volumetricWeight": 3, // 体积重(kg) t_order_weight.VolumetricWeight
+ "chargeableWeight": 3, // 计费重(kg) t_order_weight.ChargeableWeight
+ "cubic": 0, // 立方(m³) t_order_weight.Cubic
+ "isCod": false, // 是否COD
+ "codCurrency": "string", // COD币种
+ "codSum": "string", // COD金额
+ "declaredCurrency": "string", // 申报币种
+ "declaredSum": "string", // 申报金额
+ "quantity": "string", // 包裹数量
+ "receiver": { // 收件人 t_order_recipient
+ "recipientsName": "string", // 收件人名称
+ "companyName": "string", // 收件人公司名称
+ "phone": "string", // 收件人手机
+ "telePhone": "string", // 收件人电话
+ "email": "string", // 收件人邮箱
+ "countryCode": 0, // 国家
+ "province": "string", // 收件人省/州
+ "city": "string", // 收件人城市
+ "streetNumber": "string", // 收件人门牌号
+ "streetAddress1": "string", // 收件人地址1
+ "streetAddress2": "string", // 收件人地址2
+ "streetAddress3": "string", // 收件人地址3
+ "zipCode": "string", // 收件人邮编
+ "taxType": 0, // 税号类型
+ "taxNo": "string", // 税号
+ "certificateType": 0, // 证件类型
+ "certificateNo": "string", // 证件号码
+ "eori": "string", // EORI
+ "vat": "string" // Vat
+ },
+ "sender": { // 发件人 t_order_sender
+ "senderName": "string", // 发件人姓名
+ "company": "string", // 发件人公司
+ "phone": "string", // 发件人手机
+ "telePhone": "string", // 发件人电话
+ "email": "string", // 发件人邮箱
+ "countryCode": 0, // 国家
+ "province": "string", // 州省
+ "city": "string", // 城市
+ "zipCode": "string", // 邮编
+ "streetAddress1": "string", // 地址1
+ "streetAddress2": "string", // 地址2
+ "streetAddress3": "string", // 地址3
+ "houseNumber": "string" // 门牌号
+ },
+ "items": [ // 申报明细 t_order_declaration
+ {
+ "cnName": "string", // 品名-中
+ "enName": "string", // 品名-英
+ "sku": "string", // SKU
+ "unit": "string", // 单位
+ "unitPrice": 0, // 单价
+ "unitWeight": 0, // 单重
+ "quantity": 0, // 数量
+ "amount": 0, // 总金额
+ "weight": 0, // 总重量
+ "imHsCode": "string", // 进口HsCode
+ "exHsCode": "string", // 出口HsCode
+ "skuImage": "string", // 图片
+ "category": "string", // 类别
+ "brand": "string", // 品牌
+ "brandType": "string", // 品牌类型
+ "isMagnetic": false, // 是否带磁带电
+ "distribution": "string", // 配货信息
+ "material": "string", // 材质
+ "enMaterial": "string", // 英文材质
+ "purpose": "string", // 用途
+ "attributes": "string", // 属性
+ "originCountry": "string", // 原产国
+ "salesLink": "string", // 销售链接
+ "model": "string", // 型号
+ "specifications": "string", // 规格
+ "comment": "string" // 备注
+ }
+ ]
+ }
+]
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 接收批量订单数据并逐条按单票创建规则执行,下发统一批次任务号并返回逐条处理结果。
+
+####### 校验与异常
+
+- 校验请求数组非空、单次数量上限与单条必填字段;部分失败时返回明细错误信息。
+
+##### 模板列表(GET /order/template/list)
+
+###### 1 接口请求示例
+
+**请求 /order/template/list**(GET,Query 参数)
+
+```
+?page=1&pageSize=20&type=standard|custom&keyword=
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "name": "标准模板",
+ "type": "standard",
+ "templateKey": "standard_v1",
+ "customerId": 0,
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ],
+ "total": 10
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 下载模板(GET /order/template/download)
+
+###### 1 接口请求示例
+
+**请求 /order/template/download**(GET,Query 参数)
+
+```
+?templateId=1
+```
+
+**响应**
+
+> 直接返回 Excel 文件流,Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 上传下单(POST /order/upload)
+
+###### 1 接口请求示例
+
+**请求 /order/upload**(POST,multipart/form-data)
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| file | File | Excel 文件 |
+| templateId | number | 模板ID |
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "taskId": 1
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 任务进度列表(GET /order/task/list)
+
+###### 1 接口请求示例
+
+**请求 /order/task/list**(GET,Query 参数)
+
+```
+?page=1&pageSize=20&status=&keyword=
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "taskName": "批量下单_20240101",
+ "quantity": 100,
+ "status": "处理中",
+ "templateId": 1,
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ],
+ "total": 10
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 任务详情(GET /order/task/detail)
+
+###### 1 接口请求示例
+
+**请求 /order/task/detail**(GET,Query 参数)
+
+```
+?taskId=1
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "id": 1,
+ "taskName": "批量下单_20240101",
+ "quantity": 100,
+ "successCount": 80,
+ "failCount": 20,
+ "status": "已完成",
+ "templateId": 1,
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00",
+ "failList": []
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+## 三、渠道模块
+
+### 2.1 核心需求说明
+
+- **销售产品**:管理对外销售的产品(专线/快递/邮政),配置时效、黑名单区、偏远分区、价格分区、计重规则、计重进位等;支持价格配置。
+ - **产品配置**:产品基础信息(编码、名称、分组、时效、面单、黑名单区、偏远分区、价格分区、计重规则、计重进位等)。
+ - **产品卖价**:为每个产品配置多条价格项(运费、保险费、燃油附加费等);每条价格项通过弹窗配置:
+ - **基础信息**:费用项、费用单位、币种、收费时机、收费条件、计费因素(因素+因素值,如货物类型、分区、重量、仓库、客户等)。
+ - **价格表**:根据计费因素动态生成价格表,支持分区/货物类型/重量等组合、计算公式(总价/单价/续单价)、计算值、客户组。
+ - **排货规则**:选择排货方案(成本最低/时效最快/综合最优),配置服务商列表(服务商名称、服务渠道、权重、指定客户)。
+ - **发布记录**:价格与规则的发布历史,支持查看版本内容、回滚到指定版本。
+- **服务渠道**:管理下游服务渠道(支持渠道组+渠道层级),配置预报单号类型、时效、买价等。
+ - **渠道配置**:渠道基础信息(渠道代码、名称、时效、预报配置、描述、预报前脚本等)。
+ - **渠道买价**:为每个渠道配置多条买价项(运费、保险费、燃油附加费等);每条买价项通过弹窗配置:
+ - **基础信息**:费用项、费用单位、币种、收费时机、收费条件、计费因素(因素+因素值)。
+ - **价格表**:根据计费因素动态生成价格表,支持分区/货物类型/重量等组合、计算公式、计算值、客户组。
+ - **发布记录**:买价与规则的发布历史,支持查看版本内容、回滚到指定版本。
+- **黑名单区/偏远分区/价格分区**:共用 KpiArea 表,仅通过 `AreaType` 区分;支持分区及明细(国家/省/市/邮编区间)的增删改查。
+- **计重规则/计重进位**:配置计费重量规则与进位方式,明细结构与分区类似。
+- **自动触发**:配置下单/入库/出库时的自动拦截、警告、问题件等触发规则。
+- **标签设计**:保存 vg-print 导出的标签模板 JSON,支持尾程/客户/入库/出库/sku 等类型。
+
+### 2.2 表结构设计
+
+#### 销售产品表 `t_sales_product`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| ProductCode | VARCHAR | 64 | - | 是 | 产品编码 |
+| ProductName | VARCHAR | 128 | - | 是 | 产品名称 |
+| ProductGroup | VARCHAR | 32 | - | 否 | 分组(专线/快递/邮政) |
+| MinTimeliness | INT | - | - | 否 | 最小时效(天) |
+| MaxTimeliness | INT | - | - | 否 | 最大时效(天) |
+| CustomerWaybill | VARCHAR | 32 | - | 否 | 系统面单/客户面单 |
+| BlacklistAreaId | BIGINT | - | 外键 | 否 | 黑名单区ID |
+| RemoteAreaId | BIGINT | - | 外键 | 否 | 偏远分区ID |
+| PriceAreaId | BIGINT | - | 外键 | 否 | 价格分区ID(`t_kpi_area`,`AreaType=price`) |
+| WeightRuleId | BIGINT | - | 外键 | 否 | 计重规则ID |
+| WeightRoundingId | BIGINT | - | 外键 | 否 | 计重进位ID |
+| ProductDescription | TEXT | - | - | 否 | 产品描述 |
+| PreStorageScript | TEXT | - | - | 否 | 入库前脚本 |
+| IsActive | TINYINT | - | - | 是 | 是否启用 |
+| Scheme | VARCHAR | 32 | - | 是 | 排货方案:costLowest/timeFastest/overallBest |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 产品卖价表 `t_sales_product_price`(子表,关联 t_sales_product)
+
+> 产品卖价主表,每个产品可配置多条价格项(运费、保险费、燃油附加费等)。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| SalesProductId | BIGINT | - | 外键 | 是 | 销售产品ID |
+| SerialNumber | INT | - | - | 是 | 序号 |
+| Status | TINYINT | - | - | 是 | 状态:0-禁用,1-启用 |
+| PriceName | VARCHAR | 64 | - | 是 | 价格名称(如运费、保险费) |
+| CostItem | VARCHAR | 64 | - | 是 | 费用项 |
+| CostUnit | VARCHAR | 16 | - | 是 | 费用单位:kg/票/件 |
+| Currency | VARCHAR | 16 | - | 是 | 币种:RMB/USD/EUR |
+| ChargingTime | VARCHAR | 32 | - | 否 | 收费时机:预报/出库/签收 |
+| ChargingCondition | VARCHAR | 64 | - | 否 | 收费条件 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 产品卖价-计费因素表 `t_sales_product_price_factor`(子表,关联 t_sales_product_price)
+
+> 弹窗「基础信息」中的计费因素配置,支持货物类型、分区、重量、仓库、客户等。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| PriceId | BIGINT | - | 外键 | 是 | 产品卖价ID |
+| SerialNumber | INT | - | - | 是 | 序号(决定因素排序) |
+| Factor | VARCHAR | 64 | - | 是 | 因素:货物类型/分区/重量/仓库/客户 |
+| FactorValues | JSON | - | - | 否 | 因素值数组,如 ["普货","敏感货"] |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 产品卖价-价格表明细表 `t_sales_product_price_detail`(子表,关联 t_sales_product_price)
+
+> 弹窗「价格表」中的价格明细,表头由计费因素动态生成,存储各组合的价格值。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| PriceId | BIGINT | - | 外键 | 是 | 产品卖价ID |
+| FactorValuesJson | JSON | - | - | 否 | 非客户因素组合,如 {"分区":"欧洲","货物类型":"普货","重量":"0-0.5"} |
+| CalculationFormula | VARCHAR | 32 | - | 是 | 计算公式:总价/单价/续单价 |
+| CalculatedValue | DECIMAL | 18,4 | - | 否 | 计算值 |
+| CustomerGroupValues | JSON | - | - | 否 | 客户组对应价格,如 {"客户组1":10,"客户组2":12} |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+
+#### 排货规则-服务商表 `t_sales_product_allocation_provider`(子表,关联 t_sales_product_allocation_rule)
+
+> 服务商列表:服务商名称、服务渠道、权重、指定客户。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| SalesProductId | BIGINT | - | 外键 | 是 | 销售产品ID |
+| SerialNumber | INT | - | - | 是 | 序号 |
+| ProviderName | VARCHAR | 128 | - | 是 | 服务商名称 |
+| ServiceChannelId | BIGINT | - | 外键 | 否 | 服务渠道ID |
+| Weight | INT | - | - | 是 | 权重(评分用) |
+| CustomerIds | JSON | - | - | 否 | 指定客户ID数组,空表示全部 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+**Scheme 枚举**:costLowest(成本最低)、timeFastest(时效最快)、overallBest(综合最优)
+
+#### 发布记录表 `t_sales_product_publish_record`(子表,关联 t_sales_product)
+
+> 价格与规则的发布历史,支持查看版本内容、回滚。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| SalesProductId | BIGINT | - | 外键 | 是 | 销售产品ID |
+| Version | VARCHAR | 32 | - | 是 | 版本号,如 20231227112339 |
+| OperatorType | VARCHAR | 16 | - | 是 | 操作类型:price(价格)/rules(规则) |
+| Status | VARCHAR | 16 | - | 是 | 状态:生效/回滚/无效 |
+| PublishUser | VARCHAR | 64 | - | 是 | 发布人 |
+| PublishTime | DATETIME | - | - | 是 | 发布时间 |
+| Description | VARCHAR | 512 | - | 否 | 版本备注 |
+| Content | JSON | - | - | 否 | 发布内容(价格或规则配置的JSON快照) |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 服务渠道组表 `t_service_channel_group`(可选,用于渠道分组)
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| Name | VARCHAR | 128 | - | 是 | 渠道组名称 |
+| AppId | BIGINT | - | - | 否 | 应用ID(页面新增/编辑服务商时保存) |
+| ApiServiceProviderId | BIGINT | - | - | 否 | API服务商ID(页面新增/编辑服务商时保存) |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 服务渠道表 `t_service_channel`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| GroupId | BIGINT | - | 外键 | 否 | 渠道组ID(可选) |
+| ChannelCode | VARCHAR | 64 | - | 是 | 渠道编码 |
+| ChannelName | VARCHAR | 128 | - | 是 | 渠道名称 |
+| MinTime | INT | - | - | 否 | 最小时效(天) |
+| MaxTime | INT | - | - | 否 | 最大时效(天) |
+| ForecastType | VARCHAR | 32 | - | 否 | 预报单号类型(customer/waybill/tracking) |
+| ForecastCode | VARCHAR | 128 | - | 否 | 预报单号 |
+| Description | TEXT | - | - | 否 | 描述 |
+| PreScript | TEXT | - | - | 否 | 入库前脚本 |
+| IsActive | TINYINT | - | - | 是 | 是否启用 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 渠道买价表 `t_service_channel_price`(子表,关联 t_service_channel)
+
+> 渠道买价主表,每个渠道可配置多条买价项(运费、保险费、燃油附加费等)。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| ServiceChannelId | BIGINT | - | 外键 | 是 | 服务渠道ID |
+| SerialNumber | INT | - | - | 是 | 序号 |
+| Status | TINYINT | - | - | 是 | 状态:0-禁用,1-启用 |
+| PriceName | VARCHAR | 64 | - | 是 | 价格名称 |
+| CostItem | VARCHAR | 64 | - | 是 | 费用项 |
+| CostUnit | VARCHAR | 16 | - | 是 | 费用单位:kg/票/件 |
+| Currency | VARCHAR | 16 | - | 是 | 币种:RMB/USD/EUR |
+| ChargingTime | VARCHAR | 32 | - | 否 | 收费时机:预报/出库/签收 |
+| ChargingCondition | VARCHAR | 64 | - | 否 | 收费条件 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 渠道买价-计费因素表 `t_service_channel_price_factor`(子表,关联 t_service_channel_price)
+
+> 弹窗「基础信息」中的计费因素配置。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| PriceId | BIGINT | - | 外键 | 是 | 渠道买价ID |
+| SerialNumber | INT | - | - | 是 | 序号 |
+| Factor | VARCHAR | 64 | - | 是 | 因素:货物类型/分区/重量/仓库/客户 |
+| FactorValues | JSON | - | - | 否 | 因素值数组 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 渠道买价-价格表明细表 `t_service_channel_price_detail`(子表,关联 t_service_channel_price)
+
+> 弹窗「价格表」中的价格明细。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| PriceId | BIGINT | - | 外键 | 是 | 渠道买价ID |
+| FactorValuesJson | JSON | - | - | 否 | 非客户因素组合 |
+| CalculationFormula | VARCHAR | 32 | - | 是 | 计算公式:总价/单价/续单价 |
+| CalculatedValue | DECIMAL | 18,4 | - | 否 | 计算值 |
+| CustomerGroupValues | JSON | - | - | 否 | 客户组对应价格 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 服务渠道发布记录表 `t_service_channel_publish_record`(子表,关联 t_service_channel)
+
+> 买价与规则的发布历史。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| ServiceChannelId | BIGINT | - | 外键 | 是 | 服务渠道ID |
+| Version | VARCHAR | 32 | - | 是 | 版本号 |
+| OperatorType | VARCHAR | 16 | - | 是 | 操作类型:price(价格)/rules(规则) |
+| Status | VARCHAR | 16 | - | 是 | 状态:生效/回滚/无效 |
+| PublishUser | VARCHAR | 64 | - | 是 | 发布人 |
+| PublishTime | DATETIME | - | - | 是 | 发布时间 |
+| Description | VARCHAR | 512 | - | 否 | 版本备注 |
+| Content | JSON | - | - | 否 | 发布内容JSON快照 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 分区表(黑名单区/偏远分区/价格分区共用)`t_kpi_area`
+
+> 三类分区共用本表,仅通过 `AreaType` 区分业务含义。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| AreaType | VARCHAR | 32 | - | 是 | 类型: blacklist/remote/price |
+| Name | VARCHAR | 128 | - | 是 | 分区名称 |
+| SystemName | VARCHAR | 128 | - | 否 | 系统名称 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+**AreaType 枚举**:blacklist(黑名单区)、remote(偏远分区)、price(价格分区)
+
+#### 分区明细表 `t_kpi_area_info`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| AreaId | BIGINT | - | 外键 | 是 | 分区ID |
+| CountryCode | VARCHAR | 8 | - | 否 | 国家二字码 |
+| CountryName | VARCHAR | 64 | - | 否 | 国家名称 |
+| Province | VARCHAR | 128 | - | 否 | 省份 |
+| City | VARCHAR | 128 | - | 否 | 城市 |
+| LeftPostcode | VARCHAR | 32 | - | 否 | 左侧邮编 |
+| RightPostcode | VARCHAR | 32 | - | 否 | 右侧邮编 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 计重规则表 `t_weight_rule`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| Name | VARCHAR | 128 | - | 是 | 规则名称 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 计重规则明细表 `t_weight_rule_info`(子表,关联 t_weight_rule)
+
+> 计重规则子页面,配置客户组对应的计费规则(条件表达式、计费重取值、材积系数)。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| RuleId | BIGINT | - | 外键 | 是 | 计重规则ID |
+| Client | VARCHAR | 128 | - | 是 | 客户/客户组(如:公共、客户组1、客户组2) |
+| Condition | VARCHAR | 256 | - | 是 | 条件表达式(如:gw>vw) |
+| BillableWeight | VARCHAR | 64 | - | 是 | 计费重取值(如:vw、gw) |
+| VolumetricFactor | VARCHAR | 32 | - | 是 | 材积系数(如:6000) |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 计重进位表 `t_weight_rounding`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| Name | VARCHAR | 128 | - | 是 | 进位名称 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 计重进位明细表 `t_weight_rounding_info`(子表,关联 t_weight_rounding)
+
+> 计重进位子页面,配置重量区间的进位值(起始值、结束值、公共/客户组进位值)。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| RoundingId | BIGINT | - | 外键 | 是 | 计重进位ID |
+| StartValue | DECIMAL | 18,4 | - | 是 | 起始值(kg) |
+| EndValue | DECIMAL | 18,4 | - | 是 | 结束值(kg) |
+| Public | VARCHAR | 32 | - | 否 | 公共进位值 |
+| CustomerGroup1 | VARCHAR | 32 | - | 否 | 客户组1进位值 |
+| CustomerGroup2 | VARCHAR | 32 | - | 否 | 客户组2进位值 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 自动触发表 `t_auto_trigger`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| Status | TINYINT | - | - | 是 | 是否启用 |
+| Operation | VARCHAR | 32 | - | 否 | 操作: 拦截/警告/问题件 |
+| TriggerTiming | VARCHAR | 32 | - | 否 | 触发时机: 下单/入库/出库 |
+| TriggerCondition | TEXT | - | - | 否 | 触发条件 |
+| PromptMessage | VARCHAR | 512 | - | 否 | 提示信息 |
+| Notifier | VARCHAR | 128 | - | 否 | 通知人 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 标签模板表 `t_label_template`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| Name | VARCHAR | 128 | - | 是 | 模板名称 |
+| TemplateJson | JSON | - | - | 否 | vg-print 模板 JSON |
+| TemplateType | VARCHAR | 32 | - | 否 | 尾程/客户/入库/出库/sku |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+### 2.3 核心接口设计
+
+#### 销售产品
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 产品列表 | GET | /channel/sales-product/list | 产品列表 |
+| 产品详情 | GET | /channel/sales-product/detail | 产品详情 |
+| 新增产品 | POST | /channel/sales-product/create | 新增产品 |
+| 更新产品 | POST | /channel/sales-product/update | 更新产品(包含产品配置 tab 保存) |
+| 删除产品 | POST | /channel/sales-product/delete | 删除产品 |
+
+##### 产品列表(GET /channel/sales-product/list)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/list**(GET,Query 参数)
+
+```
+?page=1&pageSize=20&productGroup=专线&keyword=&isActive=1
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "productCode": "US-AIR-P",
+ "productName": "美国空运专线(普货)",
+ "productGroup": "专线",
+ "minTimeliness": 3,
+ "maxTimeliness": 7,
+ "customerWaybill": "系统面单",
+ "blacklistAreaId": 1,
+ "remoteAreaId": 2,
+ "priceAreaId": 3,
+ "weightRuleId": 1,
+ "weightRoundingId": 1,
+ "productDescription": "美国空运普货专线",
+ "isActive": 1,
+ "scheme": "costLowest",
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ],
+ "total": 10
+ }
+}
+```
+
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 接收分页与筛选参数后构建查询条件(productGroup、keyword、isActive)。
+####### 校验与异常
+- 按分页参数查询销售产品列表,返回 `items` 与 `total`。
+
+##### 产品详情(GET /channel/sales-product/detail)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/detail**(GET,Query 参数)
+
+```
+?id=1
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "id": 1,
+ "productCode": "US-AIR-P",
+ "productName": "美国空运专线(普货)",
+ "productGroup": "专线",
+ "minTimeliness": 3,
+ "maxTimeliness": 7,
+ "customerWaybill": "系统面单",
+ "blacklistAreaId": 1,
+ "remoteAreaId": 2,
+ "priceAreaId": 3,
+ "weightRuleId": 1,
+ "weightRoundingId": 1,
+ "productDescription": "美国空运普货专线",
+ "preStorageScript": "",
+ "isActive": 1,
+ "scheme": "costLowest",
+ "createTime": "2024-01-01 12:00:00",
+ "createBy": "admin",
+ "updateTime": "2024-01-01 12:00:00",
+ "updateBy": "admin"
+ }
+}
+```
+
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据 `id` 查询产品主数据及扩展配置,组装详情对象返回。
+####### 校验与异常
+- 若产品不存在或不可访问,返回业务错误码。
+
+##### 新增产品(POST /channel/sales-product/create)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/create**(POST)
+
+```json
+{
+ "productCode": "US-AIR-P",
+ "productName": "美国空运专线(普货)",
+ "productGroup": "专线",
+ "minTimeliness": 3,
+ "maxTimeliness": 7,
+ "customerWaybill": "系统面单",
+ "blacklistAreaId": 1,
+ "remoteAreaId": 2,
+ "priceAreaId": 3,
+ "weightRuleId": 1,
+ "weightRoundingId": 1,
+ "productDescription": "美国空运普货专线",
+ "preStorageScript": "",
+ "isActive": 1,
+ "scheme": "costLowest"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 校验产品编码唯一性与必填字段后新增产品记录。
+####### 校验与异常
+- 初始化产品配置与默认状态,写入审计字段。
+
+##### 更新产品(POST /channel/sales-product/update)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/update**(POST)
+
+```json
+{
+ "id": 1,
+ "productCode": "US-AIR-P",
+ "productName": "美国空运专线(普货)",
+ "productGroup": "专线",
+ "minTimeliness": 3,
+ "maxTimeliness": 7,
+ "customerWaybill": "系统面单",
+ "blacklistAreaId": 1,
+ "remoteAreaId": 2,
+ "priceAreaId": 3,
+ "weightRuleId": 1,
+ "weightRoundingId": 1,
+ "productDescription": "美国空运普货专线",
+ "preStorageScript": "",
+ "isActive": 1,
+ "scheme": "costLowest"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据 `id` 校验产品存在性后更新可变字段(包含产品配置 tab 的保存内容)。
+####### 校验与异常
+- 记录操作人和更新时间,并触发配置缓存刷新。
+
+##### 删除产品(POST /channel/sales-product/delete)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/delete**(POST)
+
+```json
+{
+ "id": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 校验产品是否可删除(无有效关联配置/任务)后执行逻辑删除。
+####### 校验与异常
+- 更新删除标识及审计字段,避免物理删除带来的历史数据丢失。
+
+#### 产品卖价(子表接口)
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 价格项列表 | GET | /channel/sales-product/price/list | 按产品ID获取价格项列表 |
+| 新增价格项 | POST | /channel/sales-product/price/create | 新增价格项(含基础信息+计费因素+价格表) |
+| 更新价格项 | POST | /channel/sales-product/price/update | 更新价格项 |
+| 删除价格项 | POST | /channel/sales-product/price/delete | 删除价格项 |
+| 切换价格项状态 | POST | /channel/sales-product/price/toggleStatus | 启用/禁用价格项 |
+| 保存价格配置 | POST | /channel/sales-product/price/save | 批量保存产品卖价(保存并可选发布) |
+
+##### 价格项列表(GET /channel/sales-product/price/list)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/price/list**(GET,Query 参数)
+
+```
+?salesProductId=1
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "salesProductId": 1,
+ "serialNumber": 1,
+ "status": 1,
+ "priceName": "运费",
+ "costItem": "freight",
+ "costUnit": "kg",
+ "currency": "RMB",
+ "chargingTime": "出库",
+ "chargingCondition": "",
+ "factors": [
+ {
+ "id": 1,
+ "priceId": 1,
+ "serialNumber": 1,
+ "factor": "货物类型",
+ "factorValues": ["普货", "敏感货"]
+ }
+ ],
+ "details": [
+ {
+ "id": 1,
+ "priceId": 1,
+ "factorValuesJson": {"分区": "欧洲", "货物类型": "普货", "重量": "0-0.5"},
+ "calculationFormula": "单价",
+ "calculatedValue": 62,
+ "customerGroupValues": {"客户组1": 60, "客户组2": 65}
+ }
+ ],
+ "version": "20260425143000",
+ "publishTime": "2026-04-25 14:30:00",
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ]
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按 `salesProductId` 查询价格项主表,并聚合返回计费因素与价格明细子数据。
+- 结果按 `serialNumber` 升序组织,便于前端按配置顺序渲染。
+
+####### 校验与异常
+
+- 校验 `salesProductId` 必填且产品存在;不存在时返回业务错误。
+- 仅返回未删除记录,查询异常返回统一错误码。
+
+##### 新增价格项(POST /channel/sales-product/price/create)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/price/create**(POST)
+
+```json
+{
+ "salesProductId": 1,
+ "serialNumber": 1,
+ "status": 1,
+ "priceName": "运费",
+ "costItem": "freight",
+ "costUnit": "kg",
+ "currency": "RMB",
+ "chargingTime": "出库",
+ "chargingCondition": "",
+ "factors": [
+ {
+ "serialNumber": 1,
+ "factor": "货物类型",
+ "factorValues": ["普货", "敏感货"]
+ },
+ {
+ "serialNumber": 2,
+ "factor": "分区",
+ "factorValues": ["欧洲", "美国"]
+ }
+ ],
+ "details": [
+ {
+ "factorValuesJson": {"分区": "欧洲", "货物类型": "普货", "重量": "0-0.5"},
+ "calculationFormula": "单价",
+ "calculatedValue": 62,
+ "customerGroupValues": {"客户组1": 60, "客户组2": 65}
+ }
+ ]
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 新增价格项主记录,并写入对应 `factors` 与 `details` 子记录。
+- 同步写入审计字段,保持主从数据一致性。
+
+####### 校验与异常
+
+- 校验 `salesProductId`、`priceName`、`costItem`、`costUnit` 等必填字段。
+- 校验同产品下 `serialNumber` 不冲突;冲突时返回业务错误。
+
+##### 更新价格项(POST /channel/sales-product/price/update)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/price/update**(POST)
+
+```json
+{
+ "id": 1,
+ "salesProductId": 1,
+ "serialNumber": 1,
+ "status": 1,
+ "priceName": "运费",
+ "costItem": "freight",
+ "costUnit": "kg",
+ "currency": "RMB",
+ "chargingTime": "出库",
+ "chargingCondition": "",
+ "factors": [],
+ "details": []
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按 `id` 更新价格项主数据,并按最新请求重建/同步因素与明细子数据。
+- 保留版本追溯能力,更新后可参与后续发布流程。
+
+####### 校验与异常
+
+- 校验 `id` 存在且归属 `salesProductId`。
+- 校验更新后配置合法(计算公式、阶梯区间、客户组取值等),不合法时返回业务错误。
+
+##### 删除价格项(POST /channel/sales-product/price/delete)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/price/delete**(POST)
+
+```json
+{
+ "id": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按价格项 `id` 执行逻辑删除,并处理关联子表数据的可见性。
+
+####### 校验与异常
+
+- 校验 `id` 存在且允许删除(如未处于不可删状态)。
+- 删除失败返回统一业务错误码。
+
+##### 切换价格项状态(POST /channel/sales-product/price/toggleStatus)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/price/toggleStatus**(POST)
+
+```json
+{
+ "id": 1,
+ "status": 0
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按 `id` 更新价格项启用状态(`status`),并刷新相关缓存。
+
+####### 校验与异常
+
+- 校验 `status` 仅允许合法枚举值(启用/禁用)。
+- 校验 `id` 存在,不存在时返回业务错误。
+
+##### 保存价格配置(POST /channel/sales-product/price/save)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/price/save**(POST)
+
+```json
+{
+ "salesProductId": 1,
+ "publish": false,
+ "prices": [
+ {
+ "id": 1,
+ "serialNumber": 1,
+ "status": 1,
+ "priceName": "运费",
+ "costItem": "freight",
+ "costUnit": "kg",
+ "currency": "RMB",
+ "chargingTime": "出库",
+ "chargingCondition": "",
+ "factors": [],
+ "details": []
+ }
+ ]
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 批量保存 `prices`,按是否存在 `id` 自动分流新增/更新,并同步子项数据。
+- 当 `publish=true` 时,保存完成后触发发布流程并生成版本记录。
+
+####### 校验与异常
+
+- 校验 `salesProductId`、`prices` 必填且数据结构合法。
+- 批量保存过程中任一子项失败则整体回滚,返回统一错误信息。
+#### 排货规则(子表接口)
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 获取排货规则 | GET | /channel/sales-product/allocation/get | 按产品ID获取排货方案及服务商列表 |
+| 保存排货规则 | POST | /channel/sales-product/allocation/save | 保存排货方案及服务商(保存并可选发布) |
+
+##### 获取排货规则(GET /channel/sales-product/allocation/get)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/allocation/get**(GET,Query 参数)
+
+```
+?salesProductId=1
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "salesProductId": 1,
+ "scheme": "costLowest",
+ "providers": [
+ {
+ "id": 1,
+ "salesProductId": 1,
+ "serialNumber": 1,
+ "providerName": "一代物流",
+ "serviceChannelId": 10,
+ "serviceChannelName": "DHL美国专线",
+ "weight": 100,
+ "customerIds": [1, 2],
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ]
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据 `salesProductId` 查询排货方案与服务商列表并按顺序返回。
+
+####### 校验与异常
+
+- 校验 `salesProductId` 必填且产品存在;不存在时返回业务错误。
+
+##### 保存排货规则(POST /channel/sales-product/allocation/save)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/allocation/save**(POST)
+
+```json
+{
+ "salesProductId": 1,
+ "publish": false,
+ "scheme": "costLowest",
+ "providers": [
+ {
+ "id": 0,
+ "serialNumber": 1,
+ "providerName": "一代物流",
+ "serviceChannelId": 10,
+ "weight": 100,
+ "customerIds": [1, 2]
+ }
+ ]
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 保存排货策略与服务商配置,按 `id` 判定新增或更新。
+- 当 `publish=true` 时触发规则发布并生成版本记录。
+
+####### 校验与异常
+
+- 校验 `salesProductId`、`scheme` 与 `providers` 合法性。
+- 保存失败回滚事务并返回统一错误信息。
+
+#### 发布记录(子表接口)
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 价格发布记录列表 | GET | /channel/sales-product/publish/price/list | 按产品ID获取价格发布历史 |
+| 规则发布记录列表 | GET | /channel/sales-product/publish/rules/list | 按产品ID获取规则发布历史 |
+| 发布 | POST | /channel/sales-product/publish | 发布当前配置(价格或规则) |
+| 回滚到版本 | POST | /channel/sales-product/publish/rollback | 回滚到指定版本 |
+
+##### 价格发布记录列表(GET /channel/sales-product/publish/price/list)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/publish/price/list**(GET,Query 参数)
+
+```
+?salesProductId=1&page=1&pageSize=20
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "salesProductId": 1,
+ "version": "20231227112339",
+ "operatorType": "price",
+ "status": "生效",
+ "publishUser": "admin",
+ "publishTime": "2023-12-27 11:23:39",
+ "description": "价格调整",
+ "content": {},
+ "createTime": "2023-12-27 11:23:39"
+ }
+ ],
+ "total": 5
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按产品ID分页查询价格类发布历史,返回版本与发布人信息。
+
+####### 校验与异常
+
+- 校验 `salesProductId` 与分页参数合法性,异常时返回统一错误码。
+
+##### 规则发布记录列表(GET /channel/sales-product/publish/rules/list)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/publish/rules/list**(GET,Query 参数)
+
+```
+?salesProductId=1&page=1&pageSize=20
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 2,
+ "salesProductId": 1,
+ "version": "20231228103000",
+ "operatorType": "rules",
+ "status": "生效",
+ "publishUser": "admin",
+ "publishTime": "2023-12-28 10:30:00",
+ "description": "规则调整",
+ "content": {},
+ "createTime": "2023-12-28 10:30:00"
+ }
+ ],
+ "total": 3
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按产品ID分页查询规则类发布历史,`operatorType` 固定为 `rules`。
+
+####### 校验与异常
+
+- 校验 `salesProductId` 与分页参数合法性,异常时返回统一错误码。
+
+##### 发布(POST /channel/sales-product/publish)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/publish**(POST)
+
+```json
+{
+ "salesProductId": 1,
+ "operatorType": "price",
+ "description": "价格调整"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 将当前配置发布为新版本并写入发布记录。
+
+####### 校验与异常
+
+- 校验 `salesProductId`、`operatorType` 必填且可发布。
+
+##### 回滚到版本(POST /channel/sales-product/publish/rollback)
+
+###### 1 接口请求示例
+
+**请求 /channel/sales-product/publish/rollback**(POST)
+
+```json
+{
+ "salesProductId": 1,
+ "recordId": 1,
+ "operatorType": "price"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 将配置回滚到指定发布版本并生成回滚记录。
+
+####### 校验与异常
+
+- 校验 `recordId` 版本存在且与产品、类型一致,不一致时返回业务错误。
+
+#### 服务渠道
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 渠道列表 | GET | /channel/service-channel/list | 渠道列表(含渠道组层级) |
+| 渠道详情 | GET | /channel/service-channel/detail | 渠道详情 |
+| 新增渠道 | POST | /channel/service-channel/create | 新增渠道 |
+| 更新渠道 | POST | /channel/service-channel/update | 更新渠道(包含渠道配置 tab 保存) |
+| 删除渠道 | POST | /channel/service-channel/delete | 删除渠道 |
+| 新增服务商 | POST | /channel/service-channel/group/create | 新增服务商分组(写入 `t_service_channel_group`,保存应用ID与API服务商ID) |
+| 编辑服务商 | POST | /channel/service-channel/group/update | 编辑服务商分组(更新名称/应用ID/API服务商ID) |
+| 删除服务商 | POST | /channel/service-channel/group/delete | 删除服务商分组(若分组下存在渠道需禁止删除) |
+
+##### 渠道列表(GET /channel/service-channel/list)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/list**(GET,Query 参数)
+
+```
+?page=1&pageSize=20&groupId=&keyword=&isActive=1
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "groupId": 1,
+ "groupName": "DHL渠道组",
+ "channelCode": "DHL-US",
+ "channelName": "DHL美国专线",
+ "minTime": 3,
+ "maxTime": 7,
+ "forecastType": "waybill",
+ "forecastCode": "",
+ "description": "DHL美国专线渠道",
+ "isActive": 1,
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ],
+ "total": 10
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按分页与筛选条件查询渠道列表,返回渠道及所属分组信息。
+
+####### 校验与异常
+
+- 校验分页参数合法性,异常时返回统一错误码。
+
+##### 渠道详情(GET /channel/service-channel/detail)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/detail**(GET,Query 参数)
+
+```
+?id=1
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "id": 1,
+ "groupId": 1,
+ "groupName": "DHL渠道组",
+ "channelCode": "DHL-US",
+ "channelName": "DHL美国专线",
+ "minTime": 3,
+ "maxTime": 7,
+ "forecastType": "waybill",
+ "forecastCode": "",
+ "description": "DHL美国专线渠道",
+ "preScript": "",
+ "isActive": 1,
+ "createTime": "2024-01-01 12:00:00",
+ "createBy": "admin",
+ "updateTime": "2024-01-01 12:00:00",
+ "updateBy": "admin"
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据渠道 `id` 查询渠道详情与配置信息。
+
+####### 校验与异常
+
+- 校验 `id` 必填且存在,不存在时返回业务错误。
+
+##### 新增渠道(POST /channel/service-channel/create)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/create**(POST)
+
+```json
+{
+ "groupId": 1,
+ "channelCode": "DHL-US",
+ "channelName": "DHL美国专线",
+ "minTime": 3,
+ "maxTime": 7,
+ "forecastType": "waybill",
+ "forecastCode": "",
+ "description": "DHL美国专线渠道",
+ "preScript": "",
+ "isActive": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 新增渠道基础配置并写入审计字段。
+
+####### 校验与异常
+
+- 校验 `groupId`、`channelCode`、`channelName` 等必填字段,编码冲突时返回业务错误。
+
+##### 新增服务商(POST /channel/service-channel/group/create)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/group/create**(POST)
+
+```json
+{
+ "name": "DHL服务商",
+ "appId": 1,
+ "apiServiceProviderId": 12
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 在 `t_service_channel_group` 新增服务商分组并写入应用ID与API服务商ID。
+
+####### 校验与异常
+
+- 校验 `name`、`appId`、`apiServiceProviderId` 必填且有效。
+
+##### 编辑服务商(POST /channel/service-channel/group/update)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/group/update**(POST)
+
+```json
+{
+ "id": 1,
+ "name": "DHL服务商(更新)",
+ "appId": 1,
+ "apiServiceProviderId": 12
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据分组 `id` 更新服务商名称、应用ID与API服务商ID。
+
+####### 校验与异常
+
+- 校验 `id` 存在且可编辑,不存在时返回业务错误。
+
+##### 删除服务商(POST /channel/service-channel/group/delete)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/group/delete**(POST)
+
+```json
+{
+ "id": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据分组 `id` 执行逻辑删除。
+
+####### 校验与异常
+
+- 若分组下存在渠道则禁止删除并返回业务错误。
+
+##### 更新渠道(POST /channel/service-channel/update)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/update**(POST)
+
+```json
+{
+ "id": 1,
+ "groupId": 1,
+ "channelCode": "DHL-US",
+ "channelName": "DHL美国专线",
+ "minTime": 3,
+ "maxTime": 7,
+ "forecastType": "waybill",
+ "forecastCode": "",
+ "description": "DHL美国专线渠道",
+ "preScript": "",
+ "isActive": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据 `id` 更新渠道基础信息与渠道配置 tab 数据。
+
+####### 校验与异常
+
+- 校验 `id` 存在及字段合法性,更新失败返回业务错误。
+
+##### 删除渠道(POST /channel/service-channel/delete)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/delete**(POST)
+
+```json
+{
+ "id": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 校验渠道是否可删除后执行逻辑删除并保留历史审计信息。
+
+####### 校验与异常
+
+- 存在关联配置或不可删除状态时返回业务错误。
+#### 渠道买价(子表接口)
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 买价项列表 | GET | /channel/service-channel/price/list | 按渠道ID获取买价项列表 |
+| 新增买价项 | POST | /channel/service-channel/price/create | 新增买价项(含基础信息+计费因素+价格表) |
+| 更新买价项 | POST | /channel/service-channel/price/update | 更新买价项 |
+| 删除买价项 | POST | /channel/service-channel/price/delete | 删除买价项 |
+| 切换买价项状态 | POST | /channel/service-channel/price/toggleStatus | 启用/禁用买价项 |
+| 保存买价配置 | POST | /channel/service-channel/price/save | 批量保存渠道买价(保存并可选发布) |
+
+##### 买价项列表(GET /channel/service-channel/price/list)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/price/list**(GET,Query 参数)
+
+```
+?serviceChannelId=1
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "serviceChannelId": 1,
+ "serialNumber": 1,
+ "status": 1,
+ "priceName": "运费",
+ "costItem": "freight",
+ "costUnit": "kg",
+ "currency": "RMB",
+ "chargingTime": "出库",
+ "chargingCondition": "",
+ "factors": [],
+ "details": [],
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ]
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按 `serviceChannelId` 查询买价项及其因素、明细子项。
+
+####### 校验与异常
+
+- 校验 `serviceChannelId` 必填且渠道存在;异常返回统一错误码。
+
+##### 新增买价项(POST /channel/service-channel/price/create)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/price/create**(POST)
+
+```json
+{
+ "serviceChannelId": 1,
+ "serialNumber": 1,
+ "status": 1,
+ "priceName": "运费",
+ "costItem": "freight",
+ "costUnit": "kg",
+ "currency": "RMB",
+ "chargingTime": "出库",
+ "chargingCondition": "",
+ "factors": [
+ {
+ "serialNumber": 1,
+ "factor": "货物类型",
+ "factorValues": ["普货", "敏感货"]
+ }
+ ],
+ "details": [
+ {
+ "factorValuesJson": {"分区": "欧洲", "货物类型": "普货", "重量": "0-0.5"},
+ "calculationFormula": "单价",
+ "calculatedValue": 45,
+ "customerGroupValues": {}
+ }
+ ]
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 新增买价主记录并写入因素、明细子表。
+
+####### 校验与异常
+
+- 校验必填参数与序号冲突规则,校验失败返回业务错误。
+
+##### 更新买价项(POST /channel/service-channel/price/update)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/price/update**(POST)
+
+```json
+{
+ "id": 1,
+ "serviceChannelId": 1,
+ "serialNumber": 1,
+ "status": 1,
+ "priceName": "运费",
+ "costItem": "freight",
+ "costUnit": "kg",
+ "currency": "RMB",
+ "chargingTime": "出库",
+ "chargingCondition": "",
+ "factors": [],
+ "details": []
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按 `id` 更新买价主数据并同步子项明细。
+
+####### 校验与异常
+
+- 校验 `id` 存在且归属渠道一致,校验失败返回业务错误。
+
+##### 删除买价项(POST /channel/service-channel/price/delete)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/price/delete**(POST)
+
+```json
+{
+ "id": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据 `id` 逻辑删除买价项并处理关联子项可见性。
+
+####### 校验与异常
+
+- 校验 `id` 存在且允许删除。
+
+##### 切换买价项状态(POST /channel/service-channel/price/toggleStatus)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/price/toggleStatus**(POST)
+
+```json
+{
+ "id": 1,
+ "status": 0
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按 `id` 更新买价项启用状态并刷新缓存。
+
+####### 校验与异常
+
+- 校验 `status` 枚举与 `id` 存在性,不合法时返回业务错误。
+
+##### 保存买价配置(POST /channel/service-channel/price/save)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/price/save**(POST)
+
+```json
+{
+ "serviceChannelId": 1,
+ "publish": false,
+ "prices": [
+ {
+ "id": 1,
+ "serialNumber": 1,
+ "status": 1,
+ "priceName": "运费",
+ "costItem": "freight",
+ "costUnit": "kg",
+ "currency": "RMB",
+ "chargingTime": "出库",
+ "chargingCondition": "",
+ "factors": [],
+ "details": []
+ }
+ ]
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 批量保存买价配置,按 `id` 区分新增/更新;`publish=true` 时触发发布。
+
+####### 校验与异常
+
+- 校验 `serviceChannelId`、`prices` 合法;任一子项失败则事务回滚。
+#### 服务渠道发布记录(子表接口)
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 价格发布记录列表 | GET | /channel/service-channel/publish/price/list | 按渠道ID获取买价发布历史 |
+| 规则发布记录列表 | GET | /channel/service-channel/publish/rules/list | 按渠道ID获取规则发布历史 |
+| 发布 | POST | /channel/service-channel/publish | 发布当前配置(买价或规则) |
+| 回滚到版本 | POST | /channel/service-channel/publish/rollback | 回滚到指定版本 |
+
+##### 价格发布记录列表(GET /channel/service-channel/publish/price/list)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/publish/price/list**(GET,Query 参数)
+
+```
+?serviceChannelId=1&page=1&pageSize=20
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "serviceChannelId": 1,
+ "version": "20231227112339",
+ "operatorType": "price",
+ "status": "生效",
+ "publishUser": "admin",
+ "publishTime": "2023-12-27 11:23:39",
+ "description": "买价调整",
+ "content": {},
+ "createTime": "2023-12-27 11:23:39"
+ }
+ ],
+ "total": 5
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按渠道ID分页查询买价发布记录并返回版本信息。
+
+####### 校验与异常
+
+- 校验 `serviceChannelId` 与分页参数合法性。
+
+##### 规则发布记录列表(GET /channel/service-channel/publish/rules/list)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/publish/rules/list**(GET,Query 参数)
+
+```
+?serviceChannelId=1&page=1&pageSize=20
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 2,
+ "serviceChannelId": 1,
+ "version": "20231228103000",
+ "operatorType": "rules",
+ "status": "生效",
+ "publishUser": "admin",
+ "publishTime": "2023-12-28 10:30:00",
+ "description": "规则调整",
+ "content": {},
+ "createTime": "2023-12-28 10:30:00"
+ }
+ ],
+ "total": 3
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按渠道ID分页查询规则发布记录,`operatorType` 固定为 `rules`。
+
+####### 校验与异常
+
+- 校验 `serviceChannelId` 与分页参数合法性。
+
+##### 发布(POST /channel/service-channel/publish)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/publish**(POST)
+
+```json
+{
+ "serviceChannelId": 1,
+ "operatorType": "price",
+ "description": "买价调整"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 发布当前渠道配置并生成新的发布版本记录。
+
+####### 校验与异常
+
+- 校验 `serviceChannelId`、`operatorType` 必填且可发布。
+
+##### 回滚到版本(POST /channel/service-channel/publish/rollback)
+
+###### 1 接口请求示例
+
+**请求 /channel/service-channel/publish/rollback**(POST)
+
+```json
+{
+ "serviceChannelId": 1,
+ "recordId": 1,
+ "operatorType": "price"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 回滚到指定版本并生成回滚操作记录。
+
+####### 校验与异常
+
+- 校验 `recordId` 与渠道、类型匹配关系,异常时返回业务错误。
+#### 黑名单区 / 偏远分区 / 价格分区(共用 KpiArea)
+
+> 共用 `/channel/kpi-area/*` 接口,仅通过请求/响应中的 `areaType`(对应表字段 `AreaType`)区分分区类型。
+
+| 类型 | areaType |
+| --- | --- |
+| 黑名单区 | blacklist |
+| 偏远分区 | remote |
+| 价格分区 | price |
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 创建分区 | POST | /channel/kpi-area/create | 创建分区 |
+| 修改分区 | POST | /channel/kpi-area/update | 修改分区 |
+| 删除分区 | POST | /channel/kpi-area/delete | 删除分区 |
+| 分区列表 | POST | /channel/kpi-area/list | 分区列表 |
+| 新增分区明细 | POST | /channel/kpi-area/info/add | 新增分区明细 |
+| 修改分区明细 | POST | /channel/kpi-area/info/update | 修改分区明细 |
+| 删除分区明细 | POST | /channel/kpi-area/info/delete | 删除分区明细 |
+| 分区明细列表 | POST | /channel/kpi-area/info/list | 分区明细列表 |
+
+##### 创建分区(POST /channel/kpi-area/create)
+
+###### 1 接口请求示例
+
+**请求 /channel/kpi-area/create**(POST)
+
+```json
+{
+ "areaType": "blacklist",
+ "name": "黑名单区1",
+ "systemName": "系统黑名单"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据 `areaType` 创建分区主记录。
+
+####### 校验与异常
+
+- 校验 `areaType`、`name` 必填且合法。
+
+##### 修改分区(POST /channel/kpi-area/update)
+
+###### 1 接口请求示例
+
+**请求 /channel/kpi-area/update**(POST)
+
+```json
+{
+ "id": 1,
+ "areaType": "blacklist",
+ "name": "黑名单区1",
+ "systemName": "系统黑名单"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据 `id` 更新分区名称与系统名称。
+
+####### 校验与异常
+
+- 校验 `id` 存在且与 `areaType` 匹配。
+
+##### 删除分区(POST /channel/kpi-area/delete)
+
+###### 1 接口请求示例
+
+**请求 /channel/kpi-area/delete**(POST)
+
+```json
+{
+ "id": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据 `id` 执行分区逻辑删除。
+
+####### 校验与异常
+
+- 分区存在明细时按业务规则拦截或级联处理。
+
+##### 分区列表(POST /channel/kpi-area/list)
+
+###### 1 接口请求示例
+
+**请求 /channel/kpi-area/list**(POST)
+
+```json
+{
+ "areaType": "blacklist",
+ "page": 1,
+ "pageSize": 20
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "areaType": "blacklist",
+ "name": "黑名单区1",
+ "systemName": "系统黑名单",
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ],
+ "total": 5
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按 `areaType` 分页查询分区列表。
+
+####### 校验与异常
+
+- 校验分页参数与 `areaType` 合法性。
+
+##### 新增分区明细(POST /channel/kpi-area/info/add)
+
+###### 1 接口请求示例
+
+**请求 /channel/kpi-area/info/add**(POST)
+
+```json
+{
+ "areaId": 1,
+ "countryCode": "US",
+ "countryName": "美国",
+ "province": "加州",
+ "city": "洛杉矶",
+ "leftPostcode": "90001",
+ "rightPostcode": "90099"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 向指定分区新增明细记录(国家/省市/邮编区间)。
+
+####### 校验与异常
+
+- 校验 `areaId` 存在且区间数据合法。
+
+##### 修改分区明细(POST /channel/kpi-area/info/update)
+
+###### 1 接口请求示例
+
+**请求 /channel/kpi-area/info/update**(POST)
+
+```json
+{
+ "id": 1,
+ "areaId": 1,
+ "countryCode": "US",
+ "countryName": "美国",
+ "province": "加州",
+ "city": "洛杉矶",
+ "leftPostcode": "90001",
+ "rightPostcode": "90099"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据明细 `id` 更新分区明细字段。
+
+####### 校验与异常
+
+- 校验 `id` 存在,更新内容合法。
+
+##### 删除分区明细(POST /channel/kpi-area/info/delete)
+
+###### 1 接口请求示例
+
+**请求 /channel/kpi-area/info/delete**(POST)
+
+```json
+{
+ "id": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据明细 `id` 执行逻辑删除。
+
+####### 校验与异常
+
+- 校验 `id` 存在,不存在时返回业务错误。
+
+##### 分区明细列表(POST /channel/kpi-area/info/list)
+
+###### 1 接口请求示例
+
+**请求 /channel/kpi-area/info/list**(POST)
+
+```json
+{
+ "areaId": 1,
+ "page": 1,
+ "pageSize": 20
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "areaId": 1,
+ "countryCode": "US",
+ "countryName": "美国",
+ "province": "加州",
+ "city": "洛杉矶",
+ "leftPostcode": "90001",
+ "rightPostcode": "90099",
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ],
+ "total": 10
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按 `areaId` 分页查询分区明细列表。
+
+####### 校验与异常
+
+- 校验 `areaId` 与分页参数合法性。
+#### 计重规则 / 计重进位
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 规则列表 | POST | /channel/weight-rule/list | 规则列表 |
+| 创建规则 | POST | /channel/weight-rule/create | 创建规则 |
+| 更新规则 | POST | /channel/weight-rule/update | 更新规则 |
+| 删除规则 | POST | /channel/weight-rule/delete | 删除规则 |
+| 规则明细列表 | POST | /channel/weight-rule/info/list | 规则明细列表 |
+| 新增明细 | POST | /channel/weight-rule/info/add | 新增明细 |
+| 更新明细 | POST | /channel/weight-rule/info/update | 更新明细 |
+| 删除明细 | POST | /channel/weight-rule/info/delete | 删除明细 |
+| 进位列表 | POST | /channel/weight-rounding/list | 进位列表 |
+| 创建进位 | POST | /channel/weight-rounding/create | 创建进位 |
+| 更新进位 | POST | /channel/weight-rounding/update | 更新进位 |
+| 删除进位 | POST | /channel/weight-rounding/delete | 删除进位 |
+| 进位明细列表 | POST | /channel/weight-rounding/info/list | 进位明细列表 |
+| 新增进位明细 | POST | /channel/weight-rounding/info/add | 新增进位明细 |
+| 更新进位明细 | POST | /channel/weight-rounding/info/update | 更新进位明细 |
+| 删除进位明细 | POST | /channel/weight-rounding/info/delete | 删除进位明细 |
+
+##### 规则列表(POST /channel/weight-rule/list)
+
+###### 1 接口请求示例
+**请求 /channel/weight-rule/list**(POST)
+
+```json
+{
+ "page": 1,
+ "pageSize": 20
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": { "items": [{ "id": 1, "name": "标准计重规则" }], "total": 5 }
+}
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 分页查询计重规则主表。
+####### 校验与异常
+- 校验分页参数合法性。
+
+##### 创建规则(POST /channel/weight-rule/create)
+###### 1 接口请求示例
+**请求 /channel/weight-rule/create**(POST)
+
+```json
+{ "name": "标准计重规则" }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 新增计重规则并写入审计信息。
+####### 校验与异常
+- 校验 `name` 必填且不重复。
+
+##### 更新规则(POST /channel/weight-rule/update)
+###### 1 接口请求示例
+**请求 /channel/weight-rule/update**(POST)
+
+```json
+{ "id": 1, "name": "标准计重规则" }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 根据 `id` 更新规则信息。
+####### 校验与异常
+- 校验 `id` 存在。
+
+##### 删除规则(POST /channel/weight-rule/delete)
+###### 1 接口请求示例
+**请求 /channel/weight-rule/delete**(POST)
+
+```json
+{ "id": 1 }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 对规则执行逻辑删除。
+####### 校验与异常
+- 校验是否存在关联明细。
+
+##### 规则明细列表(POST /channel/weight-rule/info/list)
+###### 1 接口请求示例
+**请求 /channel/weight-rule/info/list**(POST)
+
+```json
+{ "ruleId": 1, "page": 1, "pageSize": 20 }
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": { "items": [{ "id": 1, "ruleId": 1, "client": "公共" }], "total": 3 }
+}
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 按 `ruleId` 分页查询规则明细。
+####### 校验与异常
+- 校验 `ruleId` 必填且有效。
+
+##### 新增明细(POST /channel/weight-rule/info/add)
+###### 1 接口请求示例
+**请求 /channel/weight-rule/info/add**(POST)
+
+```json
+{ "ruleId": 1, "client": "公共", "condition": "gw>vw", "billableWeight": "vw", "volumetricFactor": "6000" }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 新增规则明细。
+####### 校验与异常
+- 校验 `ruleId` 与字段合法性。
+
+##### 更新明细(POST /channel/weight-rule/info/update)
+###### 1 接口请求示例
+**请求 /channel/weight-rule/info/update**(POST)
+
+```json
+{ "id": 1, "ruleId": 1, "client": "公共", "condition": "gw>vw", "billableWeight": "vw", "volumetricFactor": "6000" }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 根据 `id` 更新规则明细。
+####### 校验与异常
+- 校验 `id` 存在且归属正确。
+
+##### 删除明细(POST /channel/weight-rule/info/delete)
+###### 1 接口请求示例
+**请求 /channel/weight-rule/info/delete**(POST)
+
+```json
+{ "id": 1 }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 删除规则明细(逻辑删除)。
+####### 校验与异常
+- 校验 `id` 存在。
+
+##### 进位列表(POST /channel/weight-rounding/list)
+###### 1 接口请求示例
+**请求 /channel/weight-rounding/list**(POST)
+
+```json
+{ "page": 1, "pageSize": 20 }
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": { "items": [{ "id": 1, "name": "标准进位" }], "total": 5 }
+}
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 分页查询计重进位主表。
+####### 校验与异常
+- 校验分页参数。
+
+##### 创建进位(POST /channel/weight-rounding/create)
+###### 1 接口请求示例
+**请求 /channel/weight-rounding/create**(POST)
+
+```json
+{ "name": "标准进位" }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 新增进位规则。
+####### 校验与异常
+- 校验 `name` 必填。
+
+##### 更新进位(POST /channel/weight-rounding/update)
+###### 1 接口请求示例
+**请求 /channel/weight-rounding/update**(POST)
+
+```json
+{ "id": 1, "name": "标准进位" }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 根据 `id` 更新进位规则。
+####### 校验与异常
+- 校验 `id` 存在。
+
+##### 删除进位(POST /channel/weight-rounding/delete)
+###### 1 接口请求示例
+**请求 /channel/weight-rounding/delete**(POST)
+
+```json
+{ "id": 1 }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 逻辑删除进位规则。
+####### 校验与异常
+- 校验是否可删除。
+
+##### 进位明细列表(POST /channel/weight-rounding/info/list)
+###### 1 接口请求示例
+**请求 /channel/weight-rounding/info/list**(POST)
+
+```json
+{ "roundingId": 1, "page": 1, "pageSize": 20 }
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": { "items": [{ "id": 1, "roundingId": 1, "startValue": 0, "endValue": 1 }], "total": 5 }
+}
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 按 `roundingId` 分页查询进位明细。
+####### 校验与异常
+- 校验 `roundingId` 存在。
+
+##### 新增进位明细(POST /channel/weight-rounding/info/add)
+###### 1 接口请求示例
+**请求 /channel/weight-rounding/info/add**(POST)
+
+```json
+{ "roundingId": 1, "startValue": 0, "endValue": 1, "public": "0.5", "customerGroup1": "0.5", "customerGroup2": "0.25" }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 新增进位明细。
+####### 校验与异常
+- 校验区间与分组字段合法性。
+
+##### 更新进位明细(POST /channel/weight-rounding/info/update)
+###### 1 接口请求示例
+**请求 /channel/weight-rounding/info/update**(POST)
+
+```json
+{ "id": 1, "roundingId": 1, "startValue": 0, "endValue": 1, "public": "0.5", "customerGroup1": "0.5", "customerGroup2": "0.25" }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 根据 `id` 更新进位明细。
+####### 校验与异常
+- 校验 `id` 存在且归属正确。
+
+##### 删除进位明细(POST /channel/weight-rounding/info/delete)
+###### 1 接口请求示例
+**请求 /channel/weight-rounding/info/delete**(POST)
+
+```json
+{ "id": 1 }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 删除进位明细(逻辑删除)。
+####### 校验与异常
+- 校验 `id` 存在。
+
+#### 自动触发
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 列表 | GET | /channel/auto-trigger/list | 列表 |
+| 新增 | POST | /channel/auto-trigger/create | 新增 |
+| 更新 | POST | /channel/auto-trigger/update | 更新 |
+| 删除 | POST | /channel/auto-trigger/delete | 删除 |
+| 切换启用状态 | POST | /channel/auto-trigger/toggleStatus | 切换启用状态 |
+
+##### 列表(GET /channel/auto-trigger/list)
+###### 1 接口请求示例
+**请求 /channel/auto-trigger/list**(GET,Query 参数)
+
+```
+?page=1&pageSize=20&operation=&triggerTiming=
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": { "items": [{ "id": 1, "operation": "拦截", "triggerTiming": "下单" }], "total": 5 }
+}
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 分页查询自动触发规则列表。
+####### 校验与异常
+- 校验分页与筛选参数合法。
+
+##### 新增(POST /channel/auto-trigger/create)
+###### 1 接口请求示例
+**请求 /channel/auto-trigger/create**(POST)
+
+```json
+{
+ "status": 1,
+ "operation": "拦截",
+ "triggerTiming": "下单",
+ "triggerCondition": "weight>30",
+ "promptMessage": "重量超过30kg,请确认",
+ "notifier": "admin@example.com"
+}
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 新增自动触发规则。
+####### 校验与异常
+- 校验关键字段必填。
+
+##### 更新(POST /channel/auto-trigger/update)
+###### 1 接口请求示例
+**请求 /channel/auto-trigger/update**(POST)
+
+```json
+{
+ "id": 1,
+ "status": 1,
+ "operation": "拦截",
+ "triggerTiming": "下单",
+ "triggerCondition": "weight>30",
+ "promptMessage": "重量超过30kg,请确认",
+ "notifier": "admin@example.com"
+}
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 根据 `id` 更新自动触发规则。
+####### 校验与异常
+- 校验 `id` 存在且可编辑。
+
+##### 删除(POST /channel/auto-trigger/delete)
+###### 1 接口请求示例
+**请求 /channel/auto-trigger/delete**(POST)
+
+```json
+{ "id": 1 }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 逻辑删除自动触发规则。
+####### 校验与异常
+- 校验 `id` 存在且可删除。
+
+##### 切换启用状态(POST /channel/auto-trigger/toggleStatus)
+###### 1 接口请求示例
+**请求 /channel/auto-trigger/toggleStatus**(POST)
+
+```json
+{ "id": 1, "status": 0 }
+```
+
+**响应**
+
+```json
+{ "code": 0, "msg": "success", "data": null }
+```
+
+###### 2 接口业务逻辑
+####### 核心流程
+- 按 `id` 切换规则启用状态。
+####### 校验与异常
+- 校验 `status` 枚举合法性。
+#### 标签设计
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 保存标签模板 | POST | /channel/label-template/save | 保存标签模板 |
+
+###### 1 接口请求示例
+
+**请求 /channel/label-template/save**(POST)
+
+```json
+{
+ "id": 0,
+ "name": "尾程标签模板1",
+ "templateType": "lastMile",
+ "templateJson": {}
+}
+```
+
+> 参数说明:`id` 模板ID,0为新增;`templateType` 类型:lastMile(尾程)/customer(客户)/inbound(入库)/outbound(出库)/sku;`templateJson` vg-print 导出的模板 JSON。
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "id": 1,
+ "name": "尾程标签模板1",
+ "templateType": "lastMile",
+ "templateJson": {},
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+}
+```
+
+----------------------------------------------------------------------------------------
+
+----------------------------------------------------------------------------------------
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按本节接口定义执行业务处理流程,完成参数解析、数据查询/写入与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态流转与数据存在性进行校验;异常场景返回统一错误码与错误信息。
+## 四、操作模块
+
+### 3.1 核心需求说明
+
+- **入库扫描**:扫描运单号完成入库,记录实重、尺寸、销售产品、目的国、仓库、操作人、操作时间;支持查看最近入库记录。
+- **出库扫描**:扫描运单号完成出库,记录仓库、操作人、操作时间;支持查看最近出库记录。
+- **标签打印**:按运单号/客户单号/跟踪号/其他单号选择标签模板打印;记录打印结果与操作人。
+
+### 3.2 表结构设计
+
+#### 入库记录表 `t_inbound_record`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| WaybillNo | VARCHAR | 64 | - | 是 | 运单号 |
+| Weight | DECIMAL | 10,2 | - | 否 | 实重(kg) |
+| Length | DECIMAL | 10,2 | - | 否 | 长(cm) |
+| Width | DECIMAL | 10,2 | - | 否 | 宽(cm) |
+| Height | DECIMAL | 10,2 | - | 否 | 高(cm) |
+| SalesProductId | BIGINT | - | 外键 | 否 | 销售产品ID |
+| DestinationCountry | VARCHAR | 8 | - | 否 | 目的国 |
+| WarehouseId | BIGINT | - | 外键 | 否 | 仓库ID |
+| OperateTime | DATETIME | - | - | 是 | 操作时间 |
+| OperatorId | BIGINT | - | 外键 | 否 | 操作人ID |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 出库记录表 `t_outbound_record`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| WaybillNo | VARCHAR | 64 | - | 是 | 运单号 |
+| WarehouseId | BIGINT | - | 外键 | 否 | 仓库ID |
+| OperateTime | DATETIME | - | - | 是 | 操作时间 |
+| OperatorId | BIGINT | - | 外键 | 否 | 操作人ID |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 标签打印记录表 `t_label_print_record`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| WaybillNo | VARCHAR | 64 | - | 是 | 运单号 |
+| CustomerOrderNo | VARCHAR | 64 | - | 否 | 客户单号 |
+| TrackingNo | VARCHAR | 64 | - | 否 | 跟踪号 |
+| OtherNumber | VARCHAR | 64 | - | 否 | 其他单号 |
+| LabelTemplateId | BIGINT | - | 外键 | 否 | 标签模板ID |
+| PrintSuccess | TINYINT | - | - | 是 | 是否打印成功 |
+| PrintTime | DATETIME | - | - | 是 | 打印时间 |
+| OperatorId | BIGINT | - | 外键 | 否 | 操作人ID |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+### 3.3 核心接口设计
+
+#### 入库扫描
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 入库 | POST | /operation/inbound/create | 入库 |
+| 最近入库记录 | GET | /operation/inbound/recent-list | 最近入库记录 |
+
+##### 入库(POST /operation/inbound/create)
+
+###### 1 接口请求示例
+
+**请求 /operation/inbound/create**(POST)
+
+```json
+{
+ "waybillNo": "LP1234567890",
+ "weight": 2.5,
+ "length": 20,
+ "width": 15,
+ "height": 10,
+ "salesProductId": 1,
+ "destinationCountry": "US",
+ "warehouseId": 1
+}
+```
+
+> 参数说明:`waybillNo` 运单号;`weight` 实重(kg);`length/width/height` 长宽高(cm);`salesProductId` 销售产品ID;`destinationCountry` 目的国二字码;`warehouseId` 仓库ID。操作时间、操作人由服务端从当前会话获取。
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "id": 1,
+ "waybillNo": "LP1234567890",
+ "weight": 2.5,
+ "length": 20,
+ "width": 15,
+ "height": 10,
+ "salesProductId": 1,
+ "destinationCountry": "US",
+ "warehouseId": 1,
+ "operateTime": "2024-01-01 12:00:00",
+ "operatorId": 1,
+ "createTime": "2024-01-01 12:00:00"
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 最近入库记录(GET /operation/inbound/recent-list)
+
+###### 1 接口请求示例
+
+**请求 /operation/inbound/recent-list**(GET,Query 参数)
+
+```
+?limit=20&warehouseId=
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "waybillNo": "LP1234567890",
+ "weight": 2.5,
+ "length": 20,
+ "width": 15,
+ "height": 10,
+ "salesProductId": 1,
+ "salesProductName": "美国空运专线(普货)",
+ "destinationCountry": "US",
+ "warehouseId": 1,
+ "warehouseName": "深圳仓",
+ "operateTime": "2024-01-01 12:00:00",
+ "operatorId": 1,
+ "operatorName": "张三",
+ "createTime": "2024-01-01 12:00:00"
+ }
+ ]
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+#### 出库扫描
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 出库 | POST | /operation/outbound/create | 出库 |
+| 最近出库记录 | GET | /operation/outbound/recent-list | 最近出库记录 |
+
+##### 出库(POST /operation/outbound/create)
+
+###### 1 接口请求示例
+
+**请求 /operation/outbound/create**(POST)
+
+```json
+{
+ "waybillNo": "LP1234567890",
+ "warehouseId": 1
+}
+```
+
+> 参数说明:`waybillNo` 运单号;`warehouseId` 仓库ID(可选,不传则从订单或默认仓库获取)。操作时间、操作人由服务端从当前会话获取。
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "id": 1,
+ "waybillNo": "LP1234567890",
+ "warehouseId": 1,
+ "operateTime": "2024-01-01 12:00:00",
+ "operatorId": 1,
+ "createTime": "2024-01-01 12:00:00"
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 最近出库记录(GET /operation/outbound/recent-list)
+
+###### 1 接口请求示例
+
+**请求 /operation/outbound/recent-list**(GET,Query 参数)
+
+```
+?limit=20&warehouseId=
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "waybillNo": "LP1234567890",
+ "warehouseId": 1,
+ "warehouseName": "深圳仓",
+ "operateTime": "2024-01-01 12:00:00",
+ "operatorId": 1,
+ "operatorName": "张三",
+ "createTime": "2024-01-01 12:00:00"
+ }
+ ]
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+#### 标签打印
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 打印标签 | POST | /operation/print-label/print | 打印标签 |
+| 最近打印记录 | GET | /operation/print-label/recent-list | 最近打印记录 |
+| 标签模板列表 | GET | /operation/print-label/template-list | 标签模板列表 |
+
+##### 打印标签(POST /operation/print-label/print)
+
+###### 1 接口请求示例
+
+**请求 /operation/print-label/print**(POST)
+
+```json
+{
+ "number": "LP1234567890",
+ "numberType": "waybill",
+ "labelTemplateId": 1
+}
+```
+
+> 参数说明:`number` 运单号/客户单号/跟踪号/其他单号;`numberType` 单号类型:waybill(运单号)/customerOrder(客户单号)/tracking(跟踪号)/other(其他单号);`labelTemplateId` 标签模板ID,可选,不传则按 templateType 取默认模板。也可传 `templateType`:lastMile(尾程)/customer(客户)/inbound(入库)/outbound(出库)/sku。
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "id": 1,
+ "waybillNo": "LP1234567890",
+ "customerOrderNo": "",
+ "trackingNo": "",
+ "otherNumber": "",
+ "labelTemplateId": 1,
+ "labelTemplateName": "尾程标签模板1",
+ "printSuccess": 1,
+ "printTime": "2024-01-01 12:00:00",
+ "operatorId": 1,
+ "createTime": "2024-01-01 12:00:00"
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 最近打印记录(GET /operation/print-label/recent-list)
+
+###### 1 接口请求示例
+
+**请求 /operation/print-label/recent-list**(GET,Query 参数)
+
+```
+?limit=20&labelTemplateId=
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "waybillNo": "LP1234567890",
+ "customerOrderNo": "CUST001",
+ "trackingNo": "TRK001",
+ "otherNumber": "",
+ "labelTemplateId": 1,
+ "labelTemplateName": "尾程标签模板1",
+ "printSuccess": 1,
+ "printTime": "2024-01-01 12:00:00",
+ "operatorId": 1,
+ "operatorName": "张三",
+ "createTime": "2024-01-01 12:00:00"
+ }
+ ]
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+
+##### 标签模板列表(GET /operation/print-label/template-list)
+
+###### 1 接口请求示例
+
+**请求 /operation/print-label/template-list**(GET,Query 参数)
+
+```
+?templateType=lastMile
+```
+
+> 参数说明:`templateType` 可选,筛选模板类型:lastMile/customer/inbound/outbound/sku。不传则返回全部可用模板。
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "name": "尾程标签模板1",
+ "templateType": "lastMile",
+ "templateJson": {},
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ]
+ }
+}
+```
+
+----------------------------------------------------------------------------------------
+
+----------------------------------------------------------------------------------------
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按接口定义执行业务流程,完成参数解析、数据处理与结果返回。
+
+####### 校验与异常
+
+- 对必填参数、状态与数据存在性进行校验,异常场景返回统一错误码与错误信息。
+## 五、客户模块
+
+### 4.1 核心需求说明
+
+- **客户管理**:客户信息的增删改查,支持客户编号、手机、邮箱、余额、额度、状态等管理;支持重置密码;已有 customer API。
+- **子账号**:主客户可创建子账号,子账号共享主客户余额与额度,独立登录与权限。
+
+### 4.2 表结构设计
+
+#### 客户表 `t_customer`
+
+> 已有 customer API,表结构参考 CustomerDto。
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| CustomerCode | VARCHAR | 32 | - | 是 | 客户编号,规则:前缀w+年份+序号(如W2501) |
+| CustomerName | VARCHAR | 128 | - | 是 | 客户名称 |
+| WechatAvatar | VARCHAR | 255 | - | 否 | 微信头像URL |
+| WechatNickname | VARCHAR | 64 | - | 否 | 微信昵称 |
+| Email | VARCHAR | 64 | - | 否 | 邮箱,唯一,用于登录/找回密码 |
+| Phone | VARCHAR | 16 | - | 是 | 手机号,唯一,用于登录/验证 |
+| Balance | DECIMAL | 18,2 | - | 是 | 账户余额,默认0 |
+| TotalQuota | DECIMAL | 18,2 | - | 是 | 总额度,默认0 |
+| UsedQuota | DECIMAL | 18,2 | - | 是 | 已用额度,默认0 |
+| Status | TINYINT | - | - | 是 | 状态:0-禁用,1-正常,2-冻结 |
+| Remark | VARCHAR | 512 | - | 否 | 备注信息 |
+| CreateUserId | INT | - | - | 是 | 创建人(系统/管理员账号) |
+| CreateTime | DATETIME | - | - | 是 | 创建时间,默认当前时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+| Salesman | VARCHAR | 32 | - | 否 | 所属销售员账号 |
+| CustomerService | VARCHAR | 32 | - | 否 | 所属客服账号 |
+| CustomerGroup | TINYINT | - | - | 否 | 客户分组:0-普通客户,1-VIP1,2-VIP2,3-VIP3,4-VIP4,5-VIP5 |
+| Role | VARCHAR | 64 | - | 否 | 角色,用于菜单权限 |
+| LastLoginIp | VARCHAR | 64 | - | 否 | 最后在线IP |
+| LastLoginTime | DATETIME | - | - | 否 | 最后在线时间 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| WechatOpenid | VARCHAR | 64 | - | 否 | 微信OpenID,唯一,关联微信账号 |
+| Password | VARCHAR | 64 | - | 是 | 密码(MD5加密存储) |
+
+**CustomerGroup 枚举**:0-普通客户、1-VIP1、2-VIP2、3-VIP3、4-VIP4、5-VIP5
+
+#### 子账号表 `t_customer_sub_account`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| MainCustomerId | BIGINT | - | 外键(t_customer.Id) | 是 | 主客户ID |
+| SubAccountCode | VARCHAR | 32 | - | 是 | 子账号编号,规则:主客户编号+"_"+序号 |
+| SubWechatAvatar | VARCHAR | 255 | - | 否 | 子账号微信头像 |
+| SubWechatNickname | VARCHAR | 64 | - | 否 | 子账号微信昵称 |
+| SubPhone | VARCHAR | 16 | - | 是 | 子账号手机号,唯一 |
+| SubEmail | VARCHAR | 64 | - | 否 | 子账号邮箱 |
+| SubPassword | VARCHAR | 64 | - | 是 | 子账号密码(MD5加密) |
+| SubStatus | TINYINT | - | - | 是 | 子账号状态:0-禁用,1-正常 |
+| CreateUserId | INT | - | - | 是 | 创建人(主客户/管理员) |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是 |
+
+### 4.3 核心接口设计
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 客户列表 | GET | /customer/list | 客户列表 |
+| 客户详情 | GET | /customer/get | 客户详情 |
+| 创建客户 | POST | /customer/create | 创建客户 |
+| 更新客户 | POST | /customer/update | 更新客户 |
+| 删除客户 | POST | /customer/delete | 删除客户 |
+| 重置密码 | POST | /customer/reset-password | 重置密码 |
+
+> 个人中心可复用 `/customer/get`,传入当前登录客户 ID。
+
+##### 客户列表(GET /customer/list)
+
+###### 1 接口请求示例
+**请求 /customer/list**(GET,Query 参数)
+
+```
+?page=1&pageSize=20&keyword=&status=1&customerGroup=&salesman=&customerService=
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "customerCode": "W2501",
+ "wechatAvatar": "",
+ "wechatNickname": "",
+ "email": "customer@example.com",
+ "phone": "13800138000",
+ "balance": 1000.00,
+ "totalQuota": 5000.00,
+ "usedQuota": 500.00,
+ "status": 1,
+ "remark": "",
+ "salesman": "sales1",
+ "customerService": "cs1",
+ "customerGroup": 1,
+ "lastLoginIp": "192.168.1.1",
+ "lastLoginTime": "2024-01-01 12:00:00",
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ],
+ "total": 50
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据筛选参数分页查询客户列表,返回客户基础信息、状态与余额额度信息。
+
+####### 校验与异常
+
+- 校验分页参数合法性;当筛选条件无匹配数据时返回空列表与 total=0。
+
+##### 客户详情(GET /customer/get)
+
+###### 1 接口请求示例
+
+**请求 /customer/get**(GET,Query 参数)
+
+```
+?id=1
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "id": 1,
+ "customerCode": "W2501",
+ "wechatAvatar": "",
+ "wechatNickname": "",
+ "email": "customer@example.com",
+ "phone": "13800138000",
+ "balance": 1000.00,
+ "totalQuota": 5000.00,
+ "usedQuota": 500.00,
+ "status": 1,
+ "remark": "",
+ "salesman": "sales1",
+ "customerService": "cs1",
+ "customerGroup": 1,
+ "lastLoginIp": "192.168.1.1",
+ "lastLoginTime": "2024-01-01 12:00:00",
+ "createTime": "2024-01-01 12:00:00",
+ "createBy": "admin",
+ "updateTime": "2024-01-01 12:00:00",
+ "updateBy": "admin"
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按客户 ID 查询客户详情,返回完整客户资料与审计字段。
+
+####### 校验与异常
+
+- 校验 `id` 必填且存在;不存在时返回业务错误码。
+
+##### 创建客户(POST /customer/create)
+
+###### 1 接口请求示例
+
+**请求 /customer/create**(POST)
+
+```json
+{
+ "customerCode": "W2501",
+ "customerName": "张三客户",
+ "wechatAvatar": "https://cdn.example.com/avatar/w2501.png",
+ "wechatNickname": "张三",
+ "phone": "13800138000",
+ "email": "customer@example.com",
+ "password": "123456",
+ "balance": 1000.00,
+ "totalQuota": 5000.00,
+ "usedQuota": 500.00,
+ "status": 1,
+ "role": "customer",
+ "remark": "",
+ "salesman": "sales1",
+ "customerService": "cs1",
+ "customerGroup": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 新增客户账号并写入客户基础资料(客户编号/名称、微信信息、联系方式)、余额额度、角色与业务归属信息。
+
+####### 校验与异常
+
+- 校验手机号/邮箱唯一性与必填字段,冲突时返回业务错误。
+
+##### 更新客户(POST /customer/update)
+
+###### 1 接口请求示例
+
+**请求 /customer/update**(POST)
+
+```json
+{
+ "id": 1,
+ "customerCode": "W2501",
+ "customerName": "张三客户",
+ "wechatAvatar": "https://cdn.example.com/avatar/w2501.png",
+ "wechatNickname": "张三",
+ "phone": "13800138000",
+ "email": "customer@example.com",
+ "balance": 1000.00,
+ "totalQuota": 5000.00,
+ "usedQuota": 500.00,
+ "status": 1,
+ "role": "customer",
+ "remark": "",
+ "salesman": "sales1",
+ "customerService": "cs1",
+ "customerGroup": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据客户 ID 更新客户资料(编号/名称、微信信息、联系方式)、余额额度、状态、角色与业务归属字段。
+
+####### 校验与异常
+
+- 校验 `id` 存在与可更新状态;非法状态流转时返回业务错误。
+
+##### 删除客户(POST /customer/delete)
+
+###### 1 接口请求示例
+
+**请求 /customer/delete**(POST)
+
+```json
+{
+ "id": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按客户 ID 执行逻辑删除,保留历史审计数据。
+
+####### 校验与异常
+
+- 校验 `id` 存在且可删除;存在关联数据限制时返回业务错误。
+
+##### 重置密码(POST /customer/reset-password)
+
+###### 1 接口请求示例
+
+**请求 /customer/reset-password**(POST)
+
+```json
+{
+ "id": 1,
+ "newPassword": "123456"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据客户 ID 重置登录密码并更新密码变更时间。
+
+####### 校验与异常
+
+- 校验 `id` 与 `newPassword` 合法性,不满足密码策略时返回业务错误。
+
+----------------------------------------------------------------------------------------
+
+----------------------------------------------------------------------------------------
+
+## 六、充值管理
+
+### 5.1 核心需求说明
+
+- **充值流程**:客户提交充值金额 → 选择支付方式(微信/支付宝)→ 上传充值截图 → 提交审核 → 管理员审核(通过/驳回)→ 审核通过后更新客户余额。
+- **充值记录**:完整留存充值全流程信息,支持查询、导出;充值状态包含:待审核、审核通过、审核驳回。
+
+### 5.2 表结构设计
+
+#### 充值记录表 `t_recharge_record`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| CustomerId | BIGINT | - | 外键(t_customer.Id) | 是 | 客户ID(主账号/子账号关联主账号ID) |
+| SerialNumber | VARCHAR | 64 | - | 是 | 流水号,规则:RECHARGE+日期+随机6位数字 |
+| RechargeTime | DATETIME | - | - | 是 | 充值提交时间,默认当前时间 |
+| PayType | TINYINT | - | - | 是 | 支付方式:1-微信,2-支付宝 |
+| RechargeAmount | DECIMAL | 18,2 | - | 是 | 充值金额,≥0.01 |
+| RechargeScreenshot | VARCHAR | 255 | - | 否 | 充值截图URL(存储文件服务器地址) |
+| RechargeStatus | TINYINT | - | - | 是 | 充值状态:0-待审核,1-审核通过,2-审核驳回 |
+| Remark | VARCHAR | 512 | - | 否 | 备注(客户/审核人填写) |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+| AuditTime | DATETIME | - | - | 否 | 审核时间 |
+| AuditBy | VARCHAR | 32 | - | 否 | 审核人(管理员账号) |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+
+### 5.3 核心接口设计
+
+| 接口名称 | 请求方式 | 请求路径 | 请求参数 | 响应参数 | 接口说明 |
+| --- | --- | --- | --- | --- | --- |
+| 提交充值申请 | POST | /recharge/recharge/submit | customerId, payType, rechargeAmount, screenshotUrl, remark | code, msg, data.serialNumber | 生成流水号,保存充值记录,状态为待审核 |
+| 审核充值申请 | POST | /recharge/recharge-record/approve 或 reject | id, auditStatus, auditBy, remark | code, msg | 审核通过则更新客户余额,更新审核时间、状态、审核人 |
+| 查询充值记录 | GET | /recharge/recharge-record/list | customerId, serialNumber, startTime, endTime, status | list, total | 多条件分页查询充值记录 |
+| 导出充值记录 | GET | /recharge/export | customerId, startTime, endTime | 文件流 | 导出充值记录为Excel文件 |
+
+##### 提交充值申请(POST /recharge/recharge/submit)
+
+###### 1 接口请求示例
+**请求 /recharge/recharge/submit**(POST)
+
+```json
+{
+ "customerId": 1,
+ "rechargeAmount": 500,
+ "payType": 1,
+ "rechargeScreenshot": "https://example.com/upload/screenshot.jpg",
+ "remark": "充值500元"
+}
+```
+
+> 参数说明:`customerId` 客户ID(客户端提交时可不传,取当前登录客户);`rechargeAmount` 充值金额,≥0.01;`payType` 支付方式:1-微信,2-支付宝;`rechargeScreenshot` 充值截图URL;`remark` 备注。
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "id": 1,
+ "serialNumber": "RECHARGE2024010100123456",
+ "customerId": 1,
+ "rechargeAmount": 500.00,
+ "payType": 1,
+ "rechargeScreenshot": "https://example.com/upload/screenshot.jpg",
+ "rechargeStatus": 0,
+ "remark": "充值500元",
+ "rechargeTime": "2024-01-01 12:00:00",
+ "createTime": "2024-01-01 12:00:00"
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 创建充值记录并生成流水号,初始状态为待审核。
+
+####### 校验与异常
+
+- 校验金额、支付方式与截图信息合法性,不合法时返回业务错误。
+
+##### 审核充值申请-通过(POST /recharge/recharge-record/approve)
+
+###### 1 接口请求示例
+
+**请求 /recharge/recharge-record/approve**(POST)
+
+```json
+{
+ "id": 1,
+ "remark": "审核通过"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 审核通过后更新充值状态与审核信息,并增加客户余额。
+
+####### 校验与异常
+
+- 校验记录状态必须为待审核;重复审核或状态冲突时返回业务错误。
+
+##### 审核充值申请-驳回(POST /recharge/recharge-record/reject)
+
+###### 1 接口请求示例
+
+**请求 /recharge/recharge-record/reject**(POST)
+
+```json
+{
+ "id": 1,
+ "remark": "截图不清晰,请重新上传"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 审核驳回后更新充值状态与审核备注,不变更客户余额。
+
+####### 校验与异常
+
+- 校验记录状态与审核权限,非法操作时返回业务错误。
+
+##### 查询充值记录(GET /recharge/recharge-record/list)
+
+###### 1 接口请求示例
+
+**请求 /recharge/recharge-record/list**(GET,Query 参数)
+
+```
+?page=1&pageSize=20&customerId=1&serialNumber=&rechargeStatus=0&startTime=2024-01-01&endTime=2024-01-31
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "customerId": 1,
+ "customerCode": "W2501",
+ "customerPhone": "13800138000",
+ "serialNumber": "RECHARGE2024010100123456",
+ "rechargeTime": "2024-01-01 12:00:00",
+ "payType": 1,
+ "payTypeName": "微信",
+ "rechargeAmount": 500.00,
+ "rechargeScreenshot": "https://example.com/upload/screenshot.jpg",
+ "rechargeStatus": 0,
+ "rechargeStatusName": "待审核",
+ "remark": "充值500元",
+ "auditTime": null,
+ "auditBy": null,
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ],
+ "total": 20
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按客户、流水号、状态和时间区间进行分页查询,返回记录列表与总数。
+
+####### 校验与异常
+
+- 校验时间区间与分页参数合法性,查询异常时返回统一错误码。
+
+##### 导出充值记录(GET /recharge/export)
+
+###### 1 接口请求示例
+
+**请求 /recharge/export**(GET,Query 参数)
+
+```
+?customerId=1&startTime=2024-01-01&endTime=2024-01-31
+```
+
+**响应**
+
+> 返回 Excel 文件流,Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据筛选条件导出充值记录为 Excel 文件流。
+
+####### 校验与异常
+
+- 校验导出条件与数据权限,导出失败时返回统一错误信息。
+
+----------------------------------------------------------------------------------------
+
+----------------------------------------------------------------------------------------
+
+## 七、系统模块
+
+### 6.1 核心需求说明
+
+- **仓库地址**:管理仓库收货地址信息,包括主仓/中转仓/退货仓/其他类型;支持收件人、联系电话、收货地址、客服电话、客服微信及二维码等配置。
+- **国家管理**:维护国家基础资料(中文名称、英文名称、二字代码),供渠道分区、下单地址等模块复用。
+- **字典管理**:维护系统字典项,支持按字典类型分组管理字典编码、名称和排序,供下拉选项、状态映射等场景统一复用。
+
+### 6.2 表结构设计
+
+#### 国家表 `t_country`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| ChineseName | VARCHAR | 128 | - | 是 | 中文名称 |
+| EnglishName | VARCHAR | 128 | - | 是 | 英文名称 |
+| CountryCode | VARCHAR | 2 | - | 是 | 国家二字代码(ISO 3166-1 alpha-2) |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 字典表 `t_dict`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| DictType | VARCHAR | 64 | - | 是 | 字典类型(如 notice_type、warehouse_type) |
+| DictSort | INT | - | - | 是 | 字典排序,值越小越靠前 |
+| DictCode | VARCHAR | 64 | - | 是 | 字典编码(同类型下唯一) |
+| DictName | VARCHAR | 128 | - | 是 | 字典名称 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 公告通知表 `t_notice`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| NoticeType | VARCHAR | 16 | - | 是 | 通知类型:notice-公告,message-站内信 |
+| Title | VARCHAR | 200 | - | 是 | 标题 |
+| Content | TEXT | - | - | 是 | 内容 |
+| IsPopup | TINYINT | - | - | 是 | 是否弹窗:0-否,1-是 |
+| ReceiverType | VARCHAR | 16 | - | 是 | 接收对象:user-用户,customer-客户 |
+| ReceiverIds | JSON | - | - | 是 | 接收人 ID 列表 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+#### 通知已读明细表 `t_notice_read`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| NoticeId | BIGINT | - | 外键(关联 `t_notice.Id`) | 是 | 通知 ID |
+| ReceiverType | VARCHAR | 16 | - | 是 | 接收对象:user-用户,customer-客户 |
+| ReceiverId | BIGINT | - | - | 是 | 已读主体 ID(用户 ID/客户 ID) |
+| ReadTime | DATETIME | - | - | 是 | 已读时间 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+
+#### 仓库地址表 `t_warehouse_address`
+
+| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| Id | BIGINT | - | 主键 | 是 | 自增唯一标识 |
+| WarehouseName | VARCHAR | 128 | - | 是 | 仓库名称 |
+| WarehouseType | VARCHAR | 32 | - | 是 | main/transit/return/other |
+| Recipient | VARCHAR | 64 | - | 否 | 收件人 |
+| ContactPhone | VARCHAR | 32 | - | 否 | 联系电话 |
+| ShippingAddress | TEXT | - | - | 否 | 收货地址 |
+| ServicePhone | VARCHAR | 32 | - | 否 | 客服电话 |
+| ServiceWechat | VARCHAR | 64 | - | 否 | 客服微信 |
+| ServiceWechatFiles | JSON | - | - | 否 | 微信二维码图片 |
+| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
+| CreateUserId | INT | - | - | 是 | 创建人 |
+| CreateTime | DATETIME | - | - | 是 | 创建时间 |
+| UpdateUserId | INT | - | - | 否 | 更新人 |
+| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
+
+### 6.3 核心接口设计
+
+| 接口名称 | 请求方式 | 请求路径 | 说明 |
+| --- | --- | --- | --- |
+| 国家列表 | GET | /system/country/list | 查询国家列表 |
+| 字典列表 | GET | /system/dict/list | 按类型分页查询字典 |
+| 新增字典 | POST | /system/dict/create | 新增字典项 |
+| 更新字典 | POST | /system/dict/update | 更新字典项 |
+| 删除字典 | POST | /system/dict/delete | 删除字典项 |
+| 仓库列表 | GET | /system/warehouse/list | 仓库列表 |
+| 仓库详情 | GET | /system/warehouse/detail | 仓库详情 |
+| 新增仓库 | POST | /system/warehouse/create | 新增 |
+| 更新仓库 | POST | /system/warehouse/update | 更新 |
+| 删除仓库 | POST | /system/warehouse/delete | 删除 |
+| 通知列表 | GET | /system/notice/list | 分页查询通知 |
+| 新增通知 | POST | /system/notice/create | 新增公告/站内信 |
+| 编辑通知 | POST | /system/notice/update | 编辑公告/站内信 |
+| 删除通知 | POST | /system/notice/delete | 删除公告/站内信 |
+| 确认已读 | POST | /system/notice/read-confirm | 当前用户/客户确认已读 |
+
+##### 国家列表(GET /system/country/list)
+
+###### 1 接口请求示例
+**请求 /system/country/list**(GET)
+
+> 无请求参数,返回全部国家字段。
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": [
+ {
+ "id": 1,
+ "chineseName": "中国",
+ "englishName": "China",
+ "countryCode": "CN"
+ },
+ {
+ "id": 2,
+ "chineseName": "美国",
+ "englishName": "United States",
+ "countryCode": "US"
+ }
+ ]
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 查询 `t_country` 全量有效数据,按 `countryCode` 或 `englishName` 排序返回。
+
+####### 校验与异常
+
+- 仅返回未删除记录;无数据时返回空数组。
+
+##### 字典列表(GET /system/dict/list)
+
+###### 1 接口请求示例
+**请求 /system/dict/list**(GET,Query 参数)
+
+```
+?page=1&pageSize=20&dictType=warehouse_type&keyword=主仓
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "dictType": "warehouse_type",
+ "dictSort": 1,
+ "dictCode": "main",
+ "dictName": "主仓",
+ "createTime": "2026-04-27 10:00:00",
+ "updateTime": "2026-04-27 10:00:00"
+ }
+ ],
+ "total": 1
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按 `dictType`、关键词分页查询 `t_dict`,按 `dictSort` 升序、`id` 降序返回。
+
+####### 校验与异常
+
+- 校验分页参数合法性;仅返回未删除记录,查询异常时返回统一错误码。
+
+##### 新增字典(POST /system/dict/create)
+
+###### 1 接口请求示例
+
+**请求 /system/dict/create**(POST)
+
+```json
+{
+ "dictType": "warehouse_type",
+ "dictSort": 1,
+ "dictCode": "main",
+ "dictName": "主仓"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 新增字典项并写入创建审计信息。
+
+####### 校验与异常
+
+- 校验 `dictType`、`dictSort`、`dictCode`、`dictName` 必填及格式合法性。
+- 校验同一 `dictType` 下 `dictCode` 唯一,重复时返回业务错误。
+
+##### 更新字典(POST /system/dict/update)
+
+###### 1 接口请求示例
+
+**请求 /system/dict/update**(POST)
+
+```json
+{
+ "id": 1,
+ "dictType": "warehouse_type",
+ "dictSort": 2,
+ "dictCode": "transit",
+ "dictName": "中转仓"
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据字典 ID 更新字典类型、排序、编码、名称及审计信息。
+
+####### 校验与异常
+
+- 校验 `id` 存在且未删除,更新字段合法。
+- 若更新后 `dictType + dictCode` 与其他记录冲突,返回业务错误。
+
+##### 删除字典(POST /system/dict/delete)
+
+###### 1 接口请求示例
+
+**请求 /system/dict/delete**(POST)
+
+```json
+{
+ "id": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据字典 ID 执行逻辑删除并保留历史审计信息。
+
+####### 校验与异常
+
+- 校验 `id` 存在与删除前置条件,不存在或已删除时返回业务错误。
+
+##### 仓库列表(GET /system/warehouse/list)
+
+###### 1 接口请求示例
+**请求 /system/warehouse/list**(GET,Query 参数)
+
+```
+?page=1&pageSize=20&warehouseType=main&keyword=
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "warehouseName": "深圳主仓",
+ "warehouseType": "main",
+ "warehouseTypeName": "主仓",
+ "recipient": "张三",
+ "contactPhone": "0755-12345678",
+ "shippingAddress": "广东省深圳市南山区xxx路xxx号",
+ "servicePhone": "400-xxx-xxxx",
+ "serviceWechat": "itfx_service",
+ "serviceWechatFiles": ["https://example.com/qrcode1.jpg"],
+ "createTime": "2024-01-01 12:00:00",
+ "updateTime": "2024-01-01 12:00:00"
+ }
+ ],
+ "total": 5
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按仓库类型与关键词分页查询仓库地址信息列表。
+
+####### 校验与异常
+
+- 校验分页参数与筛选条件合法性,异常时返回统一错误码。
+
+##### 仓库详情(GET /system/warehouse/detail)
+
+###### 1 接口请求示例
+
+**请求 /system/warehouse/detail**(GET,Query 参数)
+
+```
+?id=1
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "id": 1,
+ "warehouseName": "深圳主仓",
+ "warehouseType": "main",
+ "recipient": "张三",
+ "contactPhone": "0755-12345678",
+ "shippingAddress": "广东省深圳市南山区xxx路xxx号",
+ "servicePhone": "400-xxx-xxxx",
+ "serviceWechat": "itfx_service",
+ "serviceWechatFiles": ["https://example.com/qrcode1.jpg"],
+ "createTime": "2024-01-01 12:00:00",
+ "createBy": "admin",
+ "updateTime": "2024-01-01 12:00:00",
+ "updateBy": "admin"
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据仓库 ID 查询仓库详情,返回完整地址与客服信息。
+
+####### 校验与异常
+
+- 校验 `id` 必填且存在,不存在时返回业务错误。
+
+##### 新增仓库(POST /system/warehouse/create)
+
+###### 1 接口请求示例
+
+**请求 /system/warehouse/create**(POST)
+
+```json
+{
+ "warehouseName": "深圳主仓",
+ "warehouseType": "main",
+ "recipient": "张三",
+ "contactPhone": "0755-12345678",
+ "shippingAddress": "广东省深圳市南山区xxx路xxx号",
+ "servicePhone": "400-xxx-xxxx",
+ "serviceWechat": "itfx_service",
+ "serviceWechatFiles": ["https://example.com/qrcode1.jpg"]
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 新增仓库地址配置并写入创建审计信息。
+
+####### 校验与异常
+
+- 校验仓库名称、类型及地址字段完整性,不合法时返回业务错误。
+
+##### 更新仓库(POST /system/warehouse/update)
+
+###### 1 接口请求示例
+
+**请求 /system/warehouse/update**(POST)
+
+```json
+{
+ "id": 1,
+ "warehouseName": "深圳主仓",
+ "warehouseType": "main",
+ "recipient": "张三",
+ "contactPhone": "0755-12345678",
+ "shippingAddress": "广东省深圳市南山区xxx路xxx号",
+ "servicePhone": "400-xxx-xxxx",
+ "serviceWechat": "itfx_service",
+ "serviceWechatFiles": ["https://example.com/qrcode1.jpg"]
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据仓库 ID 更新仓库基础信息与联系方式。
+
+####### 校验与异常
+
+- 校验 `id` 存在与更新字段合法性,更新失败时返回业务错误。
+
+##### 删除仓库(POST /system/warehouse/delete)
+
+###### 1 接口请求示例
+
+**请求 /system/warehouse/delete**(POST)
+
+```json
+{
+ "id": 1
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据仓库 ID 执行逻辑删除并保留历史记录。
+
+####### 校验与异常
+
+- 校验 `id` 存在与删除前置条件,冲突时返回业务错误。
+
+##### 通知列表(GET /system/notice/list)
+
+###### 1 接口请求示例
+
+**请求 /system/notice/list**(GET,Query 参数)
+
+```
+?page=1&pageSize=20¬iceType=notice&receiverType=user&keyword=活动
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "items": [
+ {
+ "id": 101,
+ "noticeType": "notice",
+ "noticeTypeName": "公告",
+ "title": "五一放假通知",
+ "content": "仓库 5 月 1 日-5 月 3 日暂停收货。",
+ "isPopup": 1,
+ "receiverType": "user",
+ "receiverTypeName": "用户",
+ "receiverIds": [1001, 1002, 1003],
+ "readCount": 2,
+ "receiverCount": 3,
+ "createTime": "2026-04-25 12:00:00",
+ "updateTime": "2026-04-25 12:00:00"
+ }
+ ],
+ "total": 1
+ }
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 按通知类型、接收对象、关键词分页查询 `t_notice`。
+- 已读数通过聚合 `t_notice_read`(按 `NoticeId` + `ReceiverId` 去重)计算,接收人总数按 `receiverIds` 统计。
+
+####### 校验与异常
+
+- 校验分页参数合法性,仅返回未删除记录;异常时返回统一错误码。
+
+##### 新增通知(POST /system/notice/create)
+
+###### 1 接口请求示例
+
+**请求 /system/notice/create**(POST)
+
+```json
+{
+ "noticeType": "notice",
+ "title": "五一放假通知",
+ "content": "仓库 5 月 1 日-5 月 3 日暂停收货。",
+ "isPopup": 1,
+ "receiverType": "user",
+ "receiverIds": [1001, 1002, 1003]
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 新增公告/站内信并写入接收对象与接收人列表。
+
+####### 校验与异常
+
+- 校验 `noticeType`、`title`、`content`、`isPopup`、`receiverType`、`receiverIds` 必填及枚举合法性。
+- `receiverIds` 为空或包含无效用户/客户时返回业务错误。
+
+##### 编辑通知(POST /system/notice/update)
+
+###### 1 接口请求示例
+
+**请求 /system/notice/update**(POST)
+
+```json
+{
+ "id": 101,
+ "noticeType": "message",
+ "title": "账单补充说明",
+ "content": "请于本周内完成 4 月账单核对。",
+ "isPopup": 0,
+ "receiverType": "customer",
+ "receiverIds": [2001, 2002]
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据通知 ID 更新通知主体内容与接收范围。
+- 如接收人发生变化,清理 `t_notice_read` 中不在新接收范围内的已读明细。
+
+####### 校验与异常
+
+- 校验 `id` 存在且未删除,更新字段合法。
+- 已发布通知可编辑但必须保留至少 1 个接收人。
+
+##### 删除通知(POST /system/notice/delete)
+
+###### 1 接口请求示例
+
+**请求 /system/notice/delete**(POST)
+
+```json
+{
+ "id": 101
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 根据通知 ID 执行逻辑删除,保留历史审计信息。
+
+####### 校验与异常
+
+- 校验 `id` 必填且存在,不存在或已删除时返回业务错误。
+
+##### 确认已读(POST /system/notice/read-confirm)
+
+###### 1 接口请求示例
+
+**请求 /system/notice/read-confirm**(POST)
+
+```json
+{
+ "id": 101
+}
+```
+
+**响应**
+
+```json
+{
+ "code": 0,
+ "msg": "success",
+ "data": null
+}
+```
+
+###### 2 接口业务逻辑
+
+####### 核心流程
+
+- 当前登录用户/客户调用后,在 `t_notice_read` 中按 `NoticeId + ReceiverType + ReceiverId` 幂等写入已读记录,并记录 `readTime`。
+
+####### 校验与异常
+
+- 校验通知存在且未删除。
+- 校验当前主体在通知接收范围内,不在范围内时返回无权限错误。
+
+----------------------------------------------------------------------------------------
+
+----------------------------------------------------------------------------------------
+
+## 附录
+
+### 附录一:接口路径规范(kebab-case 汇总)
+
+| 模块 | 路径前缀 | 示例 |
+| --- | --- | --- |
+| 订单 | /order/ | list, detail, pay, pre-declare, cancel-intercept, delete, price-check |
+| 渠道-销售产品 | /channel/sales-product/ | list, detail, create, update, delete, price/list, allocation/get |
+| 渠道-服务渠道 | /channel/service-channel/ | list, detail, create, update, delete, group/create, group/update, group/delete, price/list |
+| 渠道-计重 | /channel/weight-rule/, weight-rounding/ | list, create, update, delete, info/list |
+| 渠道-自动触发 | /channel/auto-trigger/ | list, create, update, delete, toggle-status |
+| 渠道-分区(KpiArea) | /channel/kpi-area/ | create, update, delete, list, info/add, info/update, info/delete, info/list |
+| 渠道-标签 | /channel/label-template/save | |
+| 操作 | /operation/ | inbound/create, inbound/recent-list, outbound/create, print-label/print |
+| 客户 | /customer/ | list, get, create, update, delete, reset-password |
+| 充值 | /recharge/ | recharge/submit, recharge-record/list, approve, reject |
+| 系统 | /system/ | country/list, warehouse/list, warehouse/detail, warehouse/create, warehouse/update, warehouse/delete, notice/list, notice/create, notice/update, notice/delete, notice/read-confirm |
+
+### 附录二:分区类型说明(黑名单/偏远/价格)
+
+| 页面 | areaType | 说明 |
+| --- | --- | --- |
+| 黑名单区 | blacklist | 黑名单分区 |
+| 偏远分区 | remote | 偏远分区 |
+| 价格分区 | price | 用于计价/价格表关联的分区 |
+
+调用 KpiArea 接口时,通过 `areaType` 区分不同业务分区,无需新建表或接口。
+
+### 附录三:状态码与枚举
+
+#### 订单状态
+
+| status | 中文 |
+| --- | --- |
+| draft | 草稿 |
+| predeclared | 已预报 |
+| received | 已收货 |
+| pendingPayment | 待支付 |
+| pendingShip | 待发货 |
+| pendingOnline | 待上网 |
+| shipping | 运输中 |
+| deliverySuccess | 派送成功 |
+| deliveryFailed | 派送失败 |
+| problematic | 问题件 |
+| deleted | 已删除 |
+| claimed | 已理赔 |
+
+#### 充值状态
+
+| payStatus | 中文 |
+| --- | --- |
+| 0 | 待审核 |
+| 1 | 成功 |
+| 2 | 失败 |
+
+#### 仓库类型
+
+| warehouseType | 中文 |
+| --- | --- |
+| main | 主仓 |
+| transit | 中转仓 |
+| return | 退货仓 |
+| other | 其他 |
+
+#### 通知类型
+
+| noticeType | 中文 |
+| --- | --- |
+| notice | 公告 |
+| message | 站内信 |
+
+#### 接收对象类型
+
+| receiverType | 中文 |
+| --- | --- |
+| user | 用户 |
+| customer | 客户 |
+
+#### 客户分组(客户表)
+
+| customerGroup | 中文 |
+| --- | --- |
+| 0 | 普通客户 |
+| 1 | VIP1 |
+| 2 | VIP2 |
+| 3 | VIP3 |
+| 4 | VIP4 |
+| 5 | VIP5 |
+
+----------------------------------------------------------------------------------------
+
+*文档版本:v1.0 | 最后更新:2025-03-04*
+
+
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs
index 0fd4435..9b9e5b8 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs
@@ -50,6 +50,11 @@ public class SalesProductDetailOutput
///
public long? RemoteAreaId { get; set; }
+ ///
+ /// 价格分区主键。
+ ///
+ public long? PriceAreaId { get; set; }
+
///
/// 计重规则主键。
///
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
index a994dc4..b032cd8 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
@@ -50,6 +50,11 @@ public class SalesProductListOutput
///
public long? RemoteAreaId { get; set; }
+ ///
+ /// 价格分区主键。
+ ///
+ public long? PriceAreaId { get; set; }
+
///
/// 计重规则主键。
///
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs
index c64ec9f..c6f30fb 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs
@@ -52,6 +52,11 @@ public class SalesProductSaveInput
///
public long? RemoteAreaId { get; set; }
+ ///
+ /// 价格分区主键。
+ ///
+ public long? PriceAreaId { get; set; }
+
///
/// 计重规则主键。
///
diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelGroupSaveInput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelGroupSaveInput.cs
new file mode 100644
index 0000000..e5918af
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/ServiceChannelGroupSaveInput.cs
@@ -0,0 +1,31 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 服务渠道分组保存入参。
+///
+public class ServiceChannelGroupSaveInput
+{
+ ///
+ /// 分组主键,新增时可不传。
+ ///
+ public long? Id { get; set; }
+
+ [Required]
+ [StringLength(128)]
+ ///
+ /// 分组名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 业务应用ID。
+ ///
+ public long AppId { get; set; }
+
+ ///
+ /// API服务商ID。
+ ///
+ public long ApiServiceProviderId { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs
index 30ef744..342c579 100644
--- a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs
+++ b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs
@@ -8,58 +8,102 @@ namespace Oms.Application.Dtos.Customers;
///
public class CreateCustomerInput
{
+ ///
+ /// 客户编号。
+ ///
[Required]
- [StringLength(16)]
+ [StringLength(32)]
+ public string CustomerCode { get; set; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ [Required]
+ [StringLength(128)]
+ public string CustomerName { get; set; } = string.Empty;
+
///
/// 手机号。
///
+ [Required]
+ [StringLength(16)]
public string Phone { get; set; } = string.Empty;
- [EmailAddress]
- [StringLength(64)]
///
/// 邮箱。
///
+ [EmailAddress]
+ [StringLength(64)]
public string? Email { get; set; }
- [Required]
+ ///
+ /// 微信头像地址。
+ ///
+ [StringLength(255)]
+ public string? WechatAvatar { get; set; }
+
+ ///
+ /// 微信昵称。
+ ///
[StringLength(64)]
+ public string? WechatNickname { get; set; }
+
///
/// 密码。
///
+ [Required]
+ [StringLength(64)]
public string Password { get; set; } = string.Empty;
+ ///
+ /// 账户余额。
+ ///
[Range(typeof(decimal), "0", "999999999999")]
+ public decimal Balance { get; set; }
+
///
/// 总额度。
///
+ [Range(typeof(decimal), "0", "999999999999")]
public decimal TotalQuota { get; set; }
+ ///
+ /// 已用额度。
+ ///
+ [Range(typeof(decimal), "0", "999999999999")]
+ public decimal UsedQuota { get; set; }
+
///
/// 状态。
///
public CustomerStatus Status { get; set; } = CustomerStatus.Normal;
- [StringLength(512)]
///
/// 备注。
///
+ [StringLength(512)]
public string? Remark { get; set; }
- [StringLength(32)]
///
/// 销售人员。
///
+ [StringLength(32)]
public string? Salesman { get; set; }
- [StringLength(32)]
///
/// 客服人员。
///
+ [StringLength(32)]
public string? CustomerService { get; set; }
///
/// 客户分组。
///
public CustomerGroup? CustomerGroup { get; set; }
+
+ ///
+ /// 角色编码,控制客户登录后的菜单权限。
+ ///
+ [StringLength(64)]
+ public string? Role { get; set; }
}
diff --git a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs
index 0daed14..fe18d98 100644
--- a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs
+++ b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs
@@ -17,6 +17,11 @@ public class CustomerOutput
///
public string CustomerCode { get; set; } = string.Empty;
+ ///
+ /// 客户名称。
+ ///
+ public string CustomerName { get; set; } = string.Empty;
+
///
/// WechatAvatar。
///
diff --git a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs
index 9b914b6..ca3bd09 100644
--- a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs
+++ b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs
@@ -17,6 +17,11 @@ public class CustomerPaginatedOutput
///
public string CustomerCode { get; set; } = string.Empty;
+ ///
+ /// 客户名称。
+ ///
+ public string CustomerName { get; set; } = string.Empty;
+
///
/// WechatAvatar。
///
@@ -77,6 +82,11 @@ public class CustomerPaginatedOutput
///
public CustomerGroup? CustomerGroup { get; set; }
+ ///
+ /// 角色编码。
+ ///
+ public string? Role { get; set; }
+
///
/// 最后登录 IP。
///
diff --git a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs
index 5a0cab0..6aabf86 100644
--- a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs
+++ b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs
@@ -8,50 +8,101 @@ namespace Oms.Application.Dtos.Customers;
///
public class UpdateCustomerInput
{
- [Required]
///
/// 主键。
///
+ [Required]
public long Id { get; set; }
- [EmailAddress]
- [StringLength(64)]
+ ///
+ /// 客户编号。
+ ///
+ [Required]
+ [StringLength(32)]
+ public string CustomerCode { get; set; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ [Required]
+ [StringLength(128)]
+ public string CustomerName { get; set; } = string.Empty;
+
+ ///
+ /// 手机号。
+ ///
+ [Required]
+ [StringLength(16)]
+ public string Phone { get; set; } = string.Empty;
+
///
/// 邮箱。
///
+ [EmailAddress]
+ [StringLength(64)]
public string? Email { get; set; }
+ ///
+ /// 微信头像地址。
+ ///
+ [StringLength(255)]
+ public string? WechatAvatar { get; set; }
+
+ ///
+ /// 微信昵称。
+ ///
+ [StringLength(64)]
+ public string? WechatNickname { get; set; }
+
+ ///
+ /// 账户余额。
+ ///
[Range(typeof(decimal), "0", "999999999999")]
+ public decimal Balance { get; set; }
+
///
/// 总额度。
///
+ [Range(typeof(decimal), "0", "999999999999")]
public decimal TotalQuota { get; set; }
+ ///
+ /// 已用额度。
+ ///
+ [Range(typeof(decimal), "0", "999999999999")]
+ public decimal UsedQuota { get; set; }
+
///
/// 状态。
///
public CustomerStatus Status { get; set; } = CustomerStatus.Normal;
- [StringLength(512)]
///
/// 备注。
///
+ [StringLength(512)]
public string? Remark { get; set; }
- [StringLength(32)]
///
/// 销售人员。
///
+ [StringLength(32)]
public string? Salesman { get; set; }
- [StringLength(32)]
///
/// 客服人员。
///
+ [StringLength(32)]
public string? CustomerService { get; set; }
///
/// 客户分组。
///
public CustomerGroup? CustomerGroup { get; set; }
+
+ ///
+ /// 角色编码,控制客户登录后的菜单权限。
+ ///
+ [StringLength(64)]
+ public string? Role { get; set; }
}
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateInput.cs
new file mode 100644
index 0000000..1eb2c65
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateInput.cs
@@ -0,0 +1,8 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 批量创建小包入参,数组内每项与单票创建请求结构一致。
+///
+public class OrderParcelBatchCreateInput : List
+{
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateItemOutput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateItemOutput.cs
new file mode 100644
index 0000000..cea3089
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateItemOutput.cs
@@ -0,0 +1,47 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 批量创建小包单条处理结果。
+///
+public class OrderParcelBatchCreateItemOutput
+{
+ ///
+ /// 原始请求序号,从 1 开始。
+ ///
+ public int Index { get; set; }
+
+ ///
+ /// 幂等请求号。
+ ///
+ public string? RequestId { get; set; }
+
+ ///
+ /// 客户单号。
+ ///
+ public string? ClientNo { get; set; }
+
+ ///
+ /// 运单号。
+ ///
+ public string? ServiceNo { get; set; }
+
+ ///
+ /// 跟踪号。
+ ///
+ public string? TrackNo { get; set; }
+
+ ///
+ /// 面单地址。
+ ///
+ public string? LabelUrl { get; set; }
+
+ ///
+ /// 是否成功。
+ ///
+ public bool Success { get; set; }
+
+ ///
+ /// 处理说明。
+ ///
+ public string? Message { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateOutput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateOutput.cs
new file mode 100644
index 0000000..98a74c2
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelBatchCreateOutput.cs
@@ -0,0 +1,27 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 批量创建小包出参。
+///
+public class OrderParcelBatchCreateOutput
+{
+ ///
+ /// 本次批量处理批次号。
+ ///
+ public string BatchNo { get; set; } = string.Empty;
+
+ ///
+ /// 成功数量。
+ ///
+ public int SuccessCount { get; set; }
+
+ ///
+ /// 失败数量。
+ ///
+ public int FailCount { get; set; }
+
+ ///
+ /// 逐条处理结果。
+ ///
+ public List Items { get; set; } = [];
+}
diff --git a/src/Oms.Application/Dtos/System/CountryOutput.cs b/src/Oms.Application/Dtos/System/CountryOutput.cs
new file mode 100644
index 0000000..a31fb97
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/CountryOutput.cs
@@ -0,0 +1,27 @@
+namespace Oms.Application.Dtos.System;
+
+///
+/// 国家列表项出参。
+///
+public class CountryOutput
+{
+ ///
+ /// 主键。
+ ///
+ public long Id { get; set; }
+
+ ///
+ /// 国家中文名。
+ ///
+ public string ChineseName { get; set; } = string.Empty;
+
+ ///
+ /// 国家英文名。
+ ///
+ public string EnglishName { get; set; } = string.Empty;
+
+ ///
+ /// ISO 3166-1 alpha-2 国家二字码。
+ ///
+ public string CountryCode { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Dtos/System/DictCreateInput.cs b/src/Oms.Application/Dtos/System/DictCreateInput.cs
new file mode 100644
index 0000000..c0a1325
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/DictCreateInput.cs
@@ -0,0 +1,35 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.System;
+
+///
+/// 新增字典入参。
+///
+public class DictCreateInput
+{
+ [Required]
+ [StringLength(64)]
+ ///
+ /// 字典类型。
+ ///
+ public string DictType { get; set; } = string.Empty;
+
+ ///
+ /// 排序值。
+ ///
+ public int DictSort { get; set; }
+
+ [Required]
+ [StringLength(64)]
+ ///
+ /// 字典编码。
+ ///
+ public string DictCode { get; set; } = string.Empty;
+
+ [Required]
+ [StringLength(128)]
+ ///
+ /// 字典名称。
+ ///
+ public string DictName { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Dtos/System/DictListInput.cs b/src/Oms.Application/Dtos/System/DictListInput.cs
new file mode 100644
index 0000000..20b762f
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/DictListInput.cs
@@ -0,0 +1,27 @@
+namespace Oms.Application.Dtos.System;
+
+///
+/// 字典分页查询入参。
+///
+public class DictListInput
+{
+ ///
+ /// 页码,从 1 开始。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 20;
+
+ ///
+ /// 字典类型。
+ ///
+ public string? DictType { get; set; }
+
+ ///
+ /// 关键词,匹配编码或名称。
+ ///
+ public string? Keyword { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/System/DictOutput.cs b/src/Oms.Application/Dtos/System/DictOutput.cs
new file mode 100644
index 0000000..eb2e97a
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/DictOutput.cs
@@ -0,0 +1,42 @@
+namespace Oms.Application.Dtos.System;
+
+///
+/// 字典列表项出参。
+///
+public class DictOutput
+{
+ ///
+ /// 主键。
+ ///
+ public long Id { get; set; }
+
+ ///
+ /// 字典类型。
+ ///
+ public string DictType { get; set; } = string.Empty;
+
+ ///
+ /// 排序值。
+ ///
+ public int DictSort { get; set; }
+
+ ///
+ /// 字典编码。
+ ///
+ public string DictCode { get; set; } = string.Empty;
+
+ ///
+ /// 字典名称。
+ ///
+ public string DictName { get; set; } = string.Empty;
+
+ ///
+ /// 创建时间。
+ ///
+ public DateTime CreateTime { get; set; }
+
+ ///
+ /// 更新时间。
+ ///
+ public DateTime? UpdateTime { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/System/DictUpdateInput.cs b/src/Oms.Application/Dtos/System/DictUpdateInput.cs
new file mode 100644
index 0000000..a4b282e
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/DictUpdateInput.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.System;
+
+///
+/// 更新字典入参。
+///
+public class DictUpdateInput : DictCreateInput
+{
+ [Required]
+ ///
+ /// 字典主键。
+ ///
+ public long Id { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/System/NoticeCreateInput.cs b/src/Oms.Application/Dtos/System/NoticeCreateInput.cs
new file mode 100644
index 0000000..b05c324
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/NoticeCreateInput.cs
@@ -0,0 +1,46 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.System;
+
+///
+/// 新增通知入参。
+///
+public class NoticeCreateInput
+{
+ [Required]
+ [StringLength(16)]
+ ///
+ /// 通知类型:notice/message。
+ ///
+ public string NoticeType { get; set; } = string.Empty;
+
+ [Required]
+ [StringLength(128)]
+ ///
+ /// 标题。
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ [Required]
+ ///
+ /// 正文内容。
+ ///
+ public string Content { get; set; } = string.Empty;
+
+ ///
+ /// 是否弹窗:0-否,1-是。
+ ///
+ public byte IsPopup { get; set; }
+
+ [Required]
+ [StringLength(16)]
+ ///
+ /// 接收对象类型:user/customer。
+ ///
+ public string ReceiverType { get; set; } = string.Empty;
+
+ ///
+ /// 接收主体 ID 列表。
+ ///
+ public List ReceiverIds { get; set; } = [];
+}
diff --git a/src/Oms.Application/Dtos/System/NoticeListInput.cs b/src/Oms.Application/Dtos/System/NoticeListInput.cs
new file mode 100644
index 0000000..1ebc69a
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/NoticeListInput.cs
@@ -0,0 +1,32 @@
+namespace Oms.Application.Dtos.System;
+
+///
+/// 通知分页查询入参。
+///
+public class NoticeListInput
+{
+ ///
+ /// 页码,从 1 开始。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 20;
+
+ ///
+ /// 通知类型。
+ ///
+ public string? NoticeType { get; set; }
+
+ ///
+ /// 接收对象类型。
+ ///
+ public string? ReceiverType { get; set; }
+
+ ///
+ /// 关键词,匹配标题或内容。
+ ///
+ public string? Keyword { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/System/NoticeOutput.cs b/src/Oms.Application/Dtos/System/NoticeOutput.cs
new file mode 100644
index 0000000..3ea8631
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/NoticeOutput.cs
@@ -0,0 +1,72 @@
+namespace Oms.Application.Dtos.System;
+
+///
+/// 通知列表项出参。
+///
+public class NoticeOutput
+{
+ ///
+ /// 主键。
+ ///
+ public long Id { get; set; }
+
+ ///
+ /// 通知类型。
+ ///
+ public string NoticeType { get; set; } = string.Empty;
+
+ ///
+ /// 通知类型中文名。
+ ///
+ public string NoticeTypeName { get; set; } = string.Empty;
+
+ ///
+ /// 标题。
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// 正文内容。
+ ///
+ public string Content { get; set; } = string.Empty;
+
+ ///
+ /// 是否弹窗:0-否,1-是。
+ ///
+ public byte IsPopup { get; set; }
+
+ ///
+ /// 接收对象类型。
+ ///
+ public string ReceiverType { get; set; } = string.Empty;
+
+ ///
+ /// 接收对象类型中文名。
+ ///
+ public string ReceiverTypeName { get; set; } = string.Empty;
+
+ ///
+ /// 接收主体 ID 列表。
+ ///
+ public List ReceiverIds { get; set; } = [];
+
+ ///
+ /// 已读人数。
+ ///
+ public long ReadCount { get; set; }
+
+ ///
+ /// 接收总人数。
+ ///
+ public int ReceiverCount { get; set; }
+
+ ///
+ /// 创建时间。
+ ///
+ public DateTime CreateTime { get; set; }
+
+ ///
+ /// 更新时间。
+ ///
+ public DateTime? UpdateTime { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/System/NoticeUpdateInput.cs b/src/Oms.Application/Dtos/System/NoticeUpdateInput.cs
new file mode 100644
index 0000000..68e40f4
--- /dev/null
+++ b/src/Oms.Application/Dtos/System/NoticeUpdateInput.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.System;
+
+///
+/// 更新通知入参。
+///
+public class NoticeUpdateInput : NoticeCreateInput
+{
+ [Required]
+ ///
+ /// 通知主键。
+ ///
+ public long Id { get; set; }
+}
diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj
index d2db6e8..6c0747a 100644
--- a/src/Oms.Application/Oms.Application.csproj
+++ b/src/Oms.Application/Oms.Application.csproj
@@ -17,6 +17,7 @@
+
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
index ed8829e..26fa1de 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
@@ -179,6 +179,7 @@ public partial class SalesProductService
CustomerWaybill = product.CustomerWaybill,
BlacklistAreaId = product.BlacklistAreaId,
RemoteAreaId = product.RemoteAreaId,
+ PriceAreaId = product.PriceAreaId,
WeightRuleId = product.WeightRuleId,
WeightRoundingId = product.WeightRoundingId,
ProductDescription = product.ProductDescription,
@@ -269,6 +270,7 @@ public partial class SalesProductService
entity.CustomerWaybill = input.CustomerWaybill;
entity.BlacklistAreaId = input.BlacklistAreaId;
entity.RemoteAreaId = input.RemoteAreaId;
+ entity.PriceAreaId = input.PriceAreaId;
entity.WeightRuleId = input.WeightRuleId;
entity.WeightRoundingId = input.WeightRoundingId;
entity.ProductDescription = input.ProductDescription;
@@ -290,6 +292,7 @@ public partial class SalesProductService
CustomerWaybill = entity.CustomerWaybill,
BlacklistAreaId = entity.BlacklistAreaId,
RemoteAreaId = entity.RemoteAreaId,
+ PriceAreaId = entity.PriceAreaId,
WeightRuleId = entity.WeightRuleId,
WeightRoundingId = entity.WeightRoundingId,
ProductDescription = entity.ProductDescription,
@@ -313,6 +316,7 @@ public partial class SalesProductService
CustomerWaybill = entity.CustomerWaybill,
BlacklistAreaId = entity.BlacklistAreaId,
RemoteAreaId = entity.RemoteAreaId,
+ PriceAreaId = entity.PriceAreaId,
WeightRuleId = entity.WeightRuleId,
WeightRoundingId = entity.WeightRoundingId,
ProductDescription = entity.ProductDescription,
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs
index aa8f540..528bdc8 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.cs
@@ -135,6 +135,7 @@ public partial class SalesProductService : ApplicationService
/// 2. 按入参更新主体数据和关联配置。
/// 3. 写入更新审计字段并保存结果。
///
+ [ApiExplorerSettings(IgnoreApi = true)]
[HttpPost("/channel/sales-product/config/save")]
public async Task SaveConfigAsync(SalesProductSaveInput input)
{
diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
index 7d4d487..fcf6925 100644
--- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
@@ -13,7 +13,53 @@ public partial class ServiceChannelService
///
private async Task> GetGroupMapAsync()
{
- return (await ServiceChannelRepository.GetGroupsAsync()).ToDictionary(x => x.Id, x => x.Name);
+ return (await ServiceChannelGroupRepository.GetListAsync()).ToDictionary(x => x.Id, x => x.Name);
+ }
+
+ ///
+ /// 获取服务渠道分组,不存在时直接抛异常。
+ ///
+ private async Task GetGroupOrThrowAsync(long? id)
+ {
+ if (!id.HasValue || id.Value <= 0)
+ {
+ throw new Fake.Application.BusinessException("服务渠道分组不存在");
+ }
+
+ var group = await ServiceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == id.Value);
+ if (group == null)
+ {
+ throw new Fake.Application.BusinessException("服务渠道分组不存在");
+ }
+
+ return group;
+ }
+
+ ///
+ /// 校验服务渠道分组基础信息和名称唯一性。
+ ///
+ private async Task ValidateGroupInputAsync(ServiceChannelGroupSaveInput input, long? id)
+ {
+ if (string.IsNullOrWhiteSpace(input.Name))
+ {
+ throw new Fake.Application.BusinessException("分组名称不能为空");
+ }
+
+ if (input.AppId <= 0)
+ {
+ throw new Fake.Application.BusinessException("应用ID不能为空");
+ }
+
+ if (input.ApiServiceProviderId <= 0)
+ {
+ throw new Fake.Application.BusinessException("API服务商ID不能为空");
+ }
+
+ var existed = await ServiceChannelGroupRepository.GetByNameAsync(input.Name.Trim());
+ if (existed != null && existed.Id != id)
+ {
+ throw new Fake.Application.BusinessException("分组名称已存在");
+ }
}
///
diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs
index dfa0f83..f141f2a 100644
--- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs
+++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs
@@ -16,6 +16,7 @@ public partial class ServiceChannelService : ApplicationService
private const string RulesOperatorType = "rules";
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService();
+ private IServiceChannelGroupRepository ServiceChannelGroupRepository => LazyServiceProvider.GetRequiredService();
///
/// 查询服务渠道列表。
@@ -124,6 +125,74 @@ public partial class ServiceChannelService : ApplicationService
await ServiceChannelRepository.DeleteAsync(entity);
}
+ ///
+ /// 新增服务渠道分组。
+ ///
+ /// 入参:服务渠道分组保存参数。
+ /// 无返回体,仅完成新增服务渠道分组。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 校验分组名称、应用ID、API服务商ID等基础字段。
+ /// 2. 检查分组名称唯一性,避免重复配置。
+ /// 3. 保存分组主数据供服务渠道绑定。
+ ///
+ [HttpPost("/channel/service-channel/group/create")]
+ public async Task CreateGroupAsync(ServiceChannelGroupSaveInput input)
+ {
+ await ValidateGroupInputAsync(input, null);
+ await ServiceChannelGroupRepository.InsertAsync(new ServiceChannelGroup
+ {
+ Name = input.Name.Trim(),
+ AppId = input.AppId,
+ ApiServiceProviderId = input.ApiServiceProviderId
+ });
+ }
+
+ ///
+ /// 更新服务渠道分组。
+ ///
+ /// 入参:服务渠道分组保存参数。
+ /// 无返回体,仅完成更新服务渠道分组。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 查询并校验目标分组是否存在。
+ /// 2. 检查名称唯一性并更新分组主数据。
+ /// 3. 保留既有渠道关联,只更新分组属性。
+ ///
+ [HttpPost("/channel/service-channel/group/update")]
+ public async Task UpdateGroupAsync(ServiceChannelGroupSaveInput input)
+ {
+ var group = await GetGroupOrThrowAsync(input.Id);
+ await ValidateGroupInputAsync(input, group.Id);
+ group.Name = input.Name.Trim();
+ group.AppId = input.AppId;
+ group.ApiServiceProviderId = input.ApiServiceProviderId;
+ await ServiceChannelGroupRepository.UpdateAsync(group);
+ }
+
+ ///
+ /// 删除服务渠道分组。
+ ///
+ /// 入参:目标分组主键。
+ /// 无返回体,仅完成删除服务渠道分组。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 查询并校验目标分组是否存在。
+ /// 2. 检查分组下是否仍有关联渠道。
+ /// 3. 无关联时执行删除,保留框架软删语义。
+ ///
+ [HttpPost("/channel/service-channel/group/delete")]
+ public async Task DeleteGroupAsync(IdInput input)
+ {
+ var group = await GetGroupOrThrowAsync(input.Id);
+ if (await ServiceChannelRepository.AnyAsync(x => x.GroupId == group.Id))
+ {
+ throw new BusinessException("当前分组下仍有关联渠道,无法删除");
+ }
+
+ await ServiceChannelGroupRepository.DeleteAsync(group);
+ }
+
///
/// 保存服务渠道配置。
///
@@ -135,6 +204,7 @@ public partial class ServiceChannelService : ApplicationService
/// 2. 按入参更新主体数据和关联配置。
/// 3. 写入更新审计字段并保存结果。
///
+ [ApiExplorerSettings(IgnoreApi = true)]
[HttpPost("/channel/service-channel/config/save")]
public async Task SaveConfigAsync(ServiceChannelSaveInput input)
{
diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs
index 74c7432..9bd28d5 100644
--- a/src/Oms.Application/Services/Customers/CustomerService.cs
+++ b/src/Oms.Application/Services/Customers/CustomerService.cs
@@ -84,14 +84,24 @@ public class CustomerService : ApplicationService
[HttpPost("/customer/create")]
public async Task CreateAsync(CreateCustomerInput input)
{
- if (await CustomerRepository.ExistsPhoneAsync(input.Phone))
+ var customerCode = NormalizeRequiredText(input.CustomerCode, "客户编号");
+ var customerName = NormalizeRequiredText(input.CustomerName, "客户名称");
+ var phone = NormalizeRequiredText(input.Phone, "手机号");
+ var email = NormalizeOptionalText(input.Email);
+
+ if (await CustomerRepository.GetByCustomerCodeAsync(customerCode) != null)
+ {
+ throw new BusinessException("客户编号已存在");
+ }
+
+ if (await CustomerRepository.GetByPhoneAsync(phone) != null)
{
throw new BusinessException("手机号已存在");
}
- if (!string.IsNullOrWhiteSpace(input.Email))
+ if (email != null)
{
- var duplicatedEmail = await CustomerRepository.GetByEmailAsync(input.Email);
+ var duplicatedEmail = await CustomerRepository.GetByEmailAsync(email);
if (duplicatedEmail != null)
{
throw new BusinessException("邮箱已存在");
@@ -100,18 +110,22 @@ public class CustomerService : ApplicationService
var entity = new Customer
{
- CustomerCode = await GenerateCustomerCodeAsync(),
- Phone = input.Phone,
- Email = input.Email,
+ CustomerCode = customerCode,
+ CustomerName = customerName,
+ WechatAvatar = NormalizeOptionalText(input.WechatAvatar),
+ WechatNickname = NormalizeOptionalText(input.WechatNickname),
+ Phone = phone,
+ Email = email,
Password = OmsPasswordHelper.ToMd5(input.Password),
+ Balance = input.Balance,
TotalQuota = input.TotalQuota,
- UsedQuota = 0,
- Balance = 0,
+ UsedQuota = input.UsedQuota,
Status = input.Status,
- Remark = input.Remark,
- Salesman = input.Salesman,
- CustomerService = input.CustomerService,
- CustomerGroup = input.CustomerGroup
+ Remark = NormalizeOptionalText(input.Remark),
+ Salesman = NormalizeOptionalText(input.Salesman),
+ CustomerService = NormalizeOptionalText(input.CustomerService),
+ CustomerGroup = input.CustomerGroup,
+ Role = NormalizeOptionalText(input.Role)
};
await CustomerRepository.InsertAsync(entity);
@@ -132,23 +146,47 @@ public class CustomerService : ApplicationService
public async Task UpdateAsync(UpdateCustomerInput input)
{
var entity = await GetCustomerOrThrowAsync(input.Id);
+ var customerCode = NormalizeRequiredText(input.CustomerCode, "客户编号");
+ var customerName = NormalizeRequiredText(input.CustomerName, "客户名称");
+ var phone = NormalizeRequiredText(input.Phone, "手机号");
+ var email = NormalizeOptionalText(input.Email);
- if (!string.IsNullOrWhiteSpace(input.Email))
+ var duplicatedCode = await CustomerRepository.GetByCustomerCodeAsync(customerCode);
+ if (duplicatedCode != null && duplicatedCode.Id != entity.Id)
{
- var duplicatedEmail = await CustomerRepository.GetByEmailAsync(input.Email);
+ throw new BusinessException("客户编号已存在");
+ }
+
+ var duplicatedPhone = await CustomerRepository.GetByPhoneAsync(phone);
+ if (duplicatedPhone != null && duplicatedPhone.Id != entity.Id)
+ {
+ throw new BusinessException("手机号已存在");
+ }
+
+ if (email != null)
+ {
+ var duplicatedEmail = await CustomerRepository.GetByEmailAsync(email);
if (duplicatedEmail != null && duplicatedEmail.Id != entity.Id)
{
throw new BusinessException("邮箱已存在");
}
}
- entity.Email = input.Email;
+ entity.CustomerCode = customerCode;
+ entity.CustomerName = customerName;
+ entity.Phone = phone;
+ entity.Email = email;
+ entity.WechatAvatar = NormalizeOptionalText(input.WechatAvatar);
+ entity.WechatNickname = NormalizeOptionalText(input.WechatNickname);
+ entity.Balance = input.Balance;
entity.TotalQuota = input.TotalQuota;
+ entity.UsedQuota = input.UsedQuota;
entity.Status = input.Status;
- entity.Remark = input.Remark;
- entity.Salesman = input.Salesman;
- entity.CustomerService = input.CustomerService;
+ entity.Remark = NormalizeOptionalText(input.Remark);
+ entity.Salesman = NormalizeOptionalText(input.Salesman);
+ entity.CustomerService = NormalizeOptionalText(input.CustomerService);
entity.CustomerGroup = input.CustomerGroup;
+ entity.Role = NormalizeOptionalText(input.Role);
await CustomerRepository.UpdateAsync(entity);
}
@@ -203,6 +241,7 @@ public class CustomerService : ApplicationService
return customer =>
(string.IsNullOrWhiteSpace(keyword) ||
customer.CustomerCode.Contains(keyword) ||
+ customer.CustomerName.Contains(keyword) ||
customer.Phone.Contains(keyword) ||
(customer.Email ?? string.Empty).Contains(keyword)) &&
(!hasStatusFilter || customer.Status == statusValue) &&
@@ -222,18 +261,20 @@ public class CustomerService : ApplicationService
return entity;
}
- private async Task GenerateCustomerCodeAsync()
+ private static string NormalizeRequiredText(string? value, string fieldName)
{
- for (var i = 0; i < 5; i++)
+ var normalized = value?.Trim();
+ if (string.IsNullOrWhiteSpace(normalized))
{
- var code = $"W{DateTime.Now:yyMMddHHmmss}{Random.Shared.Next(10, 99)}";
- var exists = await CustomerRepository.GetByCustomerCodeAsync(code);
- if (exists == null)
- {
- return code;
- }
+ throw new BusinessException($"{fieldName}不能为空");
}
- throw new BusinessException("客户编号生成失败");
+ return normalized;
+ }
+
+ private static string? NormalizeOptionalText(string? value)
+ {
+ var normalized = value?.Trim();
+ return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index 27fce63..db88bf4 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -335,6 +335,72 @@ public partial class OrderService : ApplicationService
return await CreateParcelCoreAsync(input);
}
+ ///
+ /// 批量创建订单包裹。
+ ///
+ /// 入参:批量创建小包请求集合,每条记录沿用单票创建结构。
+ /// 批量处理结果,包含批次号、成功失败数量与逐条执行明细。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 校验批量请求非空后逐条复用单票创建逻辑。
+ /// 2. 单条失败不会中断整批处理,允许部分成功部分失败。
+ /// 3. 返回逐条执行结果,方便调用方定位失败原因并重试。
+ ///
+ [HttpPost("/order/parcel/batch-create")]
+ public async Task BatchCreateParcelAsync(OrderParcelBatchCreateInput input)
+ {
+ if (input == null || input.Count == 0)
+ {
+ throw new BusinessException("批量创建请求不能为空");
+ }
+
+ var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}";
+ var items = new List(input.Count);
+ var successCount = 0;
+
+ for (var index = 0; index < input.Count; index++)
+ {
+ var request = input[index];
+ try
+ {
+ var output = await CreateParcelCoreAsync(request);
+ successCount++;
+ items.Add(new OrderParcelBatchCreateItemOutput
+ {
+ Index = index + 1,
+ RequestId = request.RequestId,
+ ClientNo = output.ClientNo ?? request.ClientNo,
+ ServiceNo = output.ServiceNo ?? request.ServiceNo,
+ TrackNo = output.TrackNo ?? request.TrackNo,
+ LabelUrl = output.LabelUrl,
+ Success = true,
+ Message = "success"
+ });
+ }
+ catch (Exception ex)
+ {
+ items.Add(new OrderParcelBatchCreateItemOutput
+ {
+ Index = index + 1,
+ RequestId = request.RequestId,
+ ClientNo = request.ClientNo,
+ ServiceNo = request.ServiceNo,
+ TrackNo = request.TrackNo,
+ Success = false,
+ Message = ex.Message
+ });
+ }
+ }
+
+ return new OrderParcelBatchCreateOutput
+ {
+ BatchNo = batchNo,
+ SuccessCount = successCount,
+ FailCount = items.Count - successCount,
+ Items = items
+ };
+ }
+
///
/// 将订单列表请求对象转换为仓储可执行的查询表达式。
///
diff --git a/src/Oms.Application/Services/System/CountryService.cs b/src/Oms.Application/Services/System/CountryService.cs
new file mode 100644
index 0000000..3f465cd
--- /dev/null
+++ b/src/Oms.Application/Services/System/CountryService.cs
@@ -0,0 +1,38 @@
+using Fake.Application;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.System;
+using Oms.Domain.SystemAggregate.Repositories;
+
+namespace Oms.Application.Services.System;
+
+[ApiExplorerSettings(GroupName = "OMS-System")]
+public class CountryService : ApplicationService
+{
+ private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 查询国家列表。
+ ///
+ /// 国家列表,返回全部有效国家主数据。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 查询全部国家主数据。
+ /// 2. 按国家代码和英文名稳定排序。
+ /// 3. 返回页面选择器可直接消费的静态结果集。
+ ///
+ [HttpGet("/system/country/list")]
+ public async Task> GetListAsync()
+ {
+ return (await CountryRepository.GetListAsync())
+ .OrderBy(x => x.CountryCode)
+ .ThenBy(x => x.EnglishName)
+ .Select(x => new CountryOutput
+ {
+ Id = x.Id,
+ ChineseName = x.ChineseName,
+ EnglishName = x.EnglishName,
+ CountryCode = x.CountryCode
+ })
+ .ToList();
+ }
+}
diff --git a/src/Oms.Application/Services/System/DictService.cs b/src/Oms.Application/Services/System/DictService.cs
new file mode 100644
index 0000000..b9a2c5b
--- /dev/null
+++ b/src/Oms.Application/Services/System/DictService.cs
@@ -0,0 +1,172 @@
+using System.Linq.Expressions;
+using Fake.Application;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Shared;
+using Oms.Application.Dtos.System;
+using Oms.Application.Shared;
+using Oms.Domain.SystemAggregate.Entities;
+using Oms.Domain.SystemAggregate.Repositories;
+
+namespace Oms.Application.Services.System;
+
+[ApiExplorerSettings(GroupName = "OMS-System")]
+public class DictService : ApplicationService
+{
+ private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 查询字典列表。
+ ///
+ /// 入参:字典分页查询条件。
+ /// 字典分页结果,包含字典列表与总记录数。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 根据字典类型和关键词拼装筛选条件。
+ /// 2. 按排序值升序、主键降序返回分页结果。
+ /// 3. 供系统配置页直接展示和编辑。
+ ///
+ [HttpGet("/system/dict/list")]
+ public async Task> GetListAsync([FromQuery] DictListInput input)
+ {
+ var predicate = BuildListPredicate(input);
+ var pageIndex = OmsDocHelper.NormalizePage(input.Page);
+ var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
+ var items = await DictRepository.GetPagedListAsync(
+ predicate,
+ pageIndex,
+ pageSize,
+ new Dictionary { [nameof(Dict.DictSort)] = true, [nameof(Dict.Id)] = false });
+ var total = await DictRepository.CountAsync(predicate);
+
+ return new OmsPageResult
+ {
+ Items = items.Select(MapDictOutput).ToList(),
+ Total = total
+ };
+ }
+
+ ///
+ /// 新增字典。
+ ///
+ /// 入参:字典新增参数。
+ /// 无返回体,仅完成新增字典。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 校验类型、编码、名称等基础字段。
+ /// 2. 检查同类型下编码唯一性。
+ /// 3. 写入字典主数据供系统配置复用。
+ ///
+ [HttpPost("/system/dict/create")]
+ public async Task CreateAsync(DictCreateInput input)
+ {
+ await ValidateDictInputAsync(input.DictType, input.DictCode, input.DictName, null);
+ await DictRepository.InsertAsync(new Dict
+ {
+ DictType = input.DictType.Trim(),
+ DictSort = input.DictSort,
+ DictCode = input.DictCode.Trim(),
+ DictName = input.DictName.Trim()
+ });
+ }
+
+ ///
+ /// 更新字典。
+ ///
+ /// 入参:字典更新参数。
+ /// 无返回体,仅完成更新字典。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 查询并校验目标字典是否存在。
+ /// 2. 检查更新后的类型与编码组合唯一性。
+ /// 3. 保存排序、编码和名称等最新配置。
+ ///
+ [HttpPost("/system/dict/update")]
+ public async Task UpdateAsync(DictUpdateInput input)
+ {
+ var entity = await GetDictOrThrowAsync(input.Id);
+ await ValidateDictInputAsync(input.DictType, input.DictCode, input.DictName, entity.Id);
+ entity.DictType = input.DictType.Trim();
+ entity.DictSort = input.DictSort;
+ entity.DictCode = input.DictCode.Trim();
+ entity.DictName = input.DictName.Trim();
+ await DictRepository.UpdateAsync(entity);
+ }
+
+ ///
+ /// 删除字典。
+ ///
+ /// 入参:目标主键。
+ /// 无返回体,仅完成删除字典。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 查询并校验目标字典是否存在。
+ /// 2. 走框架删除通道保留软删语义。
+ /// 3. 让下游列表自动隐藏已删除记录。
+ ///
+ [HttpPost("/system/dict/delete")]
+ public async Task DeleteAsync(IdInput input)
+ {
+ var entity = await GetDictOrThrowAsync(input.Id);
+ await DictRepository.DeleteAsync(entity);
+ }
+
+ private static Expression> BuildListPredicate(DictListInput input)
+ {
+ var dictType = input.DictType?.Trim();
+ var keyword = input.Keyword?.Trim();
+ return x =>
+ (string.IsNullOrWhiteSpace(dictType) || x.DictType == dictType) &&
+ (string.IsNullOrWhiteSpace(keyword) ||
+ x.DictCode.Contains(keyword) ||
+ x.DictName.Contains(keyword));
+ }
+
+ private async Task GetDictOrThrowAsync(long id)
+ {
+ var entity = await DictRepository.FirstOrDefaultAsync(x => x.Id == id);
+ if (entity == null)
+ {
+ throw new BusinessException("字典不存在");
+ }
+
+ return entity;
+ }
+
+ private async Task ValidateDictInputAsync(string dictType, string dictCode, string dictName, long? excludeId)
+ {
+ if (string.IsNullOrWhiteSpace(dictType))
+ {
+ throw new BusinessException("字典类型不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(dictCode))
+ {
+ throw new BusinessException("字典编码不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(dictName))
+ {
+ throw new BusinessException("字典名称不能为空");
+ }
+
+ var existed = await DictRepository.GetByTypeAndCodeAsync(dictType.Trim(), dictCode.Trim());
+ if (existed != null && existed.Id != excludeId)
+ {
+ throw new BusinessException("同类型下字典编码已存在");
+ }
+ }
+
+ private static DictOutput MapDictOutput(Dict entity)
+ {
+ return new DictOutput
+ {
+ Id = entity.Id,
+ DictType = entity.DictType,
+ DictSort = entity.DictSort,
+ DictCode = entity.DictCode,
+ DictName = entity.DictName,
+ CreateTime = entity.CreateTime,
+ UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
+ };
+ }
+}
diff --git a/src/Oms.Application/Services/System/NoticeService.cs b/src/Oms.Application/Services/System/NoticeService.cs
new file mode 100644
index 0000000..4a94dd2
--- /dev/null
+++ b/src/Oms.Application/Services/System/NoticeService.cs
@@ -0,0 +1,335 @@
+using System.Linq.Expressions;
+using Fake.Application;
+using Fake.Rbac.Infrastructure;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Oms.Application.Dtos.Shared;
+using Oms.Application.Dtos.System;
+using Oms.Application.Shared;
+using Oms.Domain.CustomersAggregate.Repositories;
+using Oms.Domain.SystemAggregate.Entities;
+using Oms.Domain.SystemAggregate.Repositories;
+
+namespace Oms.Application.Services.System;
+
+[ApiExplorerSettings(GroupName = "OMS-System")]
+public class NoticeService : ApplicationService
+{
+ private const string NoticeTypeNotice = "notice";
+ private const string NoticeTypeMessage = "message";
+ private const string ReceiverTypeUser = "user";
+ private const string ReceiverTypeCustomer = "customer";
+ private const string CustomerIdClaim = "customerId";
+
+ private INoticeRepository NoticeRepository => LazyServiceProvider.GetRequiredService();
+ private INoticeReadRepository NoticeReadRepository => LazyServiceProvider.GetRequiredService();
+ private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService();
+ private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 查询通知列表。
+ ///
+ /// 入参:通知分页查询条件。
+ /// 通知分页结果,包含通知主体、接收范围与已读统计。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 根据通知类型、接收对象和关键词过滤通知。
+ /// 2. 聚合已读明细,计算每条通知的已读数。
+ /// 3. 返回接收范围、弹窗标记与统计字段供管理端展示。
+ ///
+ [HttpGet("/system/notice/list")]
+ public async Task> GetListAsync([FromQuery] NoticeListInput input)
+ {
+ var predicate = BuildListPredicate(input);
+ var pageIndex = OmsDocHelper.NormalizePage(input.Page);
+ var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
+ var notices = await NoticeRepository.GetPagedListAsync(
+ predicate,
+ pageIndex,
+ pageSize,
+ new Dictionary { [nameof(Notice.CreateTime)] = false, [nameof(Notice.Id)] = false });
+ var total = await NoticeRepository.CountAsync(predicate);
+
+ var noticeIds = notices.Select(x => x.Id).Distinct().ToArray();
+ var reads = noticeIds.Length == 0
+ ? []
+ : await NoticeReadRepository.GetListAsync(x => noticeIds.Contains(x.NoticeId));
+ var readCountMap = reads
+ .GroupBy(x => x.NoticeId)
+ .ToDictionary(
+ x => x.Key,
+ x => (long)x.Select(item => $"{item.ReceiverType}:{item.ReceiverId}").Distinct(StringComparer.Ordinal).Count());
+
+ return new OmsPageResult
+ {
+ Items = notices.Select(x => MapNoticeOutput(
+ x,
+ readCountMap.TryGetValue(x.Id, out var readCount) ? readCount : 0)).ToList(),
+ Total = total
+ };
+ }
+
+ ///
+ /// 新增通知。
+ ///
+ /// 入参:通知新增参数。
+ /// 无返回体,仅完成新增通知。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 校验通知类型、接收对象和接收范围。
+ /// 2. 验证用户或客户接收主体真实存在。
+ /// 3. 保存公告或站内信主体,已读状态由确认接口单独落库。
+ ///
+ [HttpPost("/system/notice/create")]
+ public async Task CreateAsync(NoticeCreateInput input)
+ {
+ ValidateNoticeText(input.Title, input.Content);
+ var receiverIds = await NormalizeAndValidateReceiverIdsAsync(input.ReceiverType, input.ReceiverIds);
+ await NoticeRepository.InsertAsync(new Notice
+ {
+ NoticeType = NormalizeNoticeType(input.NoticeType),
+ Title = input.Title.Trim(),
+ Content = input.Content.Trim(),
+ IsPopup = NormalizePopupValue(input.IsPopup),
+ ReceiverType = NormalizeReceiverType(input.ReceiverType),
+ ReceiverIds = OmsDocHelper.SerializeJson(receiverIds) ?? "[]"
+ });
+ }
+
+ ///
+ /// 更新通知。
+ ///
+ /// 入参:通知更新参数。
+ /// 无返回体,仅完成更新通知。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 查询并校验目标通知是否存在。
+ /// 2. 更新主体内容与接收范围。
+ /// 3. 清理不在新接收范围内的历史已读明细。
+ ///
+ [HttpPost("/system/notice/update")]
+ public async Task UpdateAsync(NoticeUpdateInput input)
+ {
+ var entity = await GetNoticeOrThrowAsync(input.Id);
+ ValidateNoticeText(input.Title, input.Content);
+ var receiverIds = await NormalizeAndValidateReceiverIdsAsync(input.ReceiverType, input.ReceiverIds);
+ entity.NoticeType = NormalizeNoticeType(input.NoticeType);
+ entity.Title = input.Title.Trim();
+ entity.Content = input.Content.Trim();
+ entity.IsPopup = NormalizePopupValue(input.IsPopup);
+ entity.ReceiverType = NormalizeReceiverType(input.ReceiverType);
+ entity.ReceiverIds = OmsDocHelper.SerializeJson(receiverIds) ?? "[]";
+ await NoticeRepository.UpdateAsync(entity);
+
+ var existsReads = await NoticeReadRepository.GetListAsync(x => x.NoticeId == entity.Id);
+ var receiverIdSet = receiverIds.ToHashSet();
+ foreach (var read in existsReads.Where(x => x.ReceiverType != entity.ReceiverType || !receiverIdSet.Contains(x.ReceiverId)))
+ {
+ await NoticeReadRepository.DeleteAsync(read);
+ }
+ }
+
+ ///
+ /// 删除通知。
+ ///
+ /// 入参:目标主键。
+ /// 无返回体,仅完成删除通知。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 查询并校验目标通知是否存在。
+ /// 2. 走框架删除通道保留软删语义。
+ /// 3. 让通知与已读统计在常规查询中自动不可见。
+ ///
+ [HttpPost("/system/notice/delete")]
+ public async Task DeleteAsync(IdInput input)
+ {
+ var entity = await GetNoticeOrThrowAsync(input.Id);
+ await NoticeRepository.DeleteAsync(entity);
+ }
+
+ ///
+ /// 确认通知已读。
+ ///
+ /// 入参:目标通知主键。
+ /// 无返回体,仅完成当前主体的已读确认。
+ ///
+ /// 核心业务逻辑:
+ /// 1. 根据当前登录主体解析 user/customer 接收身份。
+ /// 2. 校验当前主体在通知接收范围内。
+ /// 3. 按通知、接收类型、接收ID 幂等写入已读明细。
+ ///
+ [HttpPost("/system/notice/read-confirm")]
+ public async Task ReadConfirmAsync(IdInput input)
+ {
+ var notice = await GetNoticeOrThrowAsync(input.Id);
+ var receiverIds = OmsDocHelper.DeserializeJson>(notice.ReceiverIds) ?? [];
+ var isCustomerPrincipal = CurrentUser.FindClaimOrNull(CustomerIdClaim) != null;
+ var currentReceiverType = isCustomerPrincipal ? ReceiverTypeCustomer : ReceiverTypeUser;
+ var currentReceiverId = CurrentUser.GetId();
+ if (currentReceiverId <= 0)
+ {
+ throw new BusinessException("当前登录主体无效");
+ }
+
+ if (!string.Equals(notice.ReceiverType, currentReceiverType, StringComparison.OrdinalIgnoreCase) ||
+ !receiverIds.Contains(currentReceiverId))
+ {
+ throw new BusinessException("当前主体不在通知接收范围内");
+ }
+
+ var existed = await NoticeReadRepository.GetByNoticeReceiverAsync(notice.Id, currentReceiverType, currentReceiverId);
+ if (existed != null)
+ {
+ return;
+ }
+
+ await NoticeReadRepository.InsertAsync(new NoticeRead
+ {
+ NoticeId = notice.Id,
+ ReceiverType = currentReceiverType,
+ ReceiverId = currentReceiverId,
+ ReadTime = DateTime.Now
+ });
+ }
+
+ private static Expression> BuildListPredicate(NoticeListInput input)
+ {
+ var noticeType = input.NoticeType?.Trim();
+ var receiverType = input.ReceiverType?.Trim();
+ var keyword = input.Keyword?.Trim();
+ return x =>
+ (string.IsNullOrWhiteSpace(noticeType) || x.NoticeType == noticeType) &&
+ (string.IsNullOrWhiteSpace(receiverType) || x.ReceiverType == receiverType) &&
+ (string.IsNullOrWhiteSpace(keyword) ||
+ x.Title.Contains(keyword) ||
+ x.Content.Contains(keyword));
+ }
+
+ private async Task GetNoticeOrThrowAsync(long id)
+ {
+ var entity = await NoticeRepository.FirstOrDefaultAsync(x => x.Id == id);
+ if (entity == null)
+ {
+ throw new BusinessException("通知不存在");
+ }
+
+ return entity;
+ }
+
+ private async Task> NormalizeAndValidateReceiverIdsAsync(string receiverType, IEnumerable? receiverIds)
+ {
+ var normalizedReceiverType = NormalizeReceiverType(receiverType);
+ var normalizedIds = (receiverIds ?? [])
+ .Where(x => x > 0)
+ .Distinct()
+ .ToList();
+
+ if (normalizedIds.Count == 0)
+ {
+ throw new BusinessException("接收人不能为空");
+ }
+
+ if (normalizedReceiverType == ReceiverTypeCustomer)
+ {
+ var customers = await CustomerRepository.GetListAsync(x => normalizedIds.Contains(x.Id));
+ if (customers.Count != normalizedIds.Count)
+ {
+ throw new BusinessException("接收客户包含无效数据");
+ }
+
+ return normalizedIds;
+ }
+
+ var validUserIds = await QueryValidUserIdsAsync(normalizedIds);
+ if (validUserIds.Count != normalizedIds.Count)
+ {
+ throw new BusinessException("接收用户包含无效数据");
+ }
+
+ return normalizedIds;
+ }
+
+ private async Task> QueryValidUserIdsAsync(IReadOnlyCollection userIds)
+ {
+ if (userIds.Count == 0)
+ {
+ return [];
+ }
+
+ var sql = $"SELECT `userid` AS `UserId` FROM `user` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", userIds)})";
+ return await RbacDbContext.Database.SqlQueryRaw(sql)
+ .Select(x => x.UserId)
+ .ToListAsync();
+ }
+
+ private static void ValidateNoticeText(string title, string content)
+ {
+ if (string.IsNullOrWhiteSpace(title))
+ {
+ throw new BusinessException("通知标题不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ throw new BusinessException("通知内容不能为空");
+ }
+ }
+
+ private static NoticeOutput MapNoticeOutput(Notice entity, long readCount)
+ {
+ var receiverIds = OmsDocHelper.DeserializeJson>(entity.ReceiverIds) ?? [];
+ return new NoticeOutput
+ {
+ Id = entity.Id,
+ NoticeType = entity.NoticeType,
+ NoticeTypeName = entity.NoticeType == NoticeTypeNotice ? "公告" : "站内信",
+ Title = entity.Title,
+ Content = entity.Content,
+ IsPopup = entity.IsPopup,
+ ReceiverType = entity.ReceiverType,
+ ReceiverTypeName = entity.ReceiverType == ReceiverTypeUser ? "用户" : "客户",
+ ReceiverIds = receiverIds,
+ ReadCount = readCount,
+ ReceiverCount = receiverIds.Distinct().Count(),
+ CreateTime = entity.CreateTime,
+ UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
+ };
+ }
+
+ private static string NormalizeNoticeType(string noticeType)
+ {
+ var normalized = noticeType.Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ NoticeTypeNotice => NoticeTypeNotice,
+ NoticeTypeMessage => NoticeTypeMessage,
+ _ => throw new BusinessException("通知类型无效")
+ };
+ }
+
+ private static string NormalizeReceiverType(string receiverType)
+ {
+ var normalized = receiverType.Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ ReceiverTypeUser => ReceiverTypeUser,
+ ReceiverTypeCustomer => ReceiverTypeCustomer,
+ _ => throw new BusinessException("接收对象类型无效")
+ };
+ }
+
+ private static byte NormalizePopupValue(byte value)
+ {
+ return value switch
+ {
+ 0 => 0,
+ 1 => 1,
+ _ => throw new BusinessException("弹窗标记无效")
+ };
+ }
+
+ private sealed class UserIdQueryResult
+ {
+ public long UserId { get; set; }
+ }
+}
diff --git a/src/Oms.Application/Shared/OmsPasswordHelper.cs b/src/Oms.Application/Shared/OmsPasswordHelper.cs
index 37eda0b..2342e33 100644
--- a/src/Oms.Application/Shared/OmsPasswordHelper.cs
+++ b/src/Oms.Application/Shared/OmsPasswordHelper.cs
@@ -3,8 +3,14 @@ using System.Text;
namespace Oms.Application.Shared;
-internal static class OmsPasswordHelper
+///
+/// 统一的密码摘要工具。
+///
+public static class OmsPasswordHelper
{
+ ///
+ /// 将明文密码转换为小写 MD5 字符串。
+ ///
public static string ToMd5(string password)
{
var bytes = MD5.HashData(Encoding.UTF8.GetBytes(password));
diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs
index d2eab3e..27f9fe3 100644
--- a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs
+++ b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs
@@ -23,6 +23,8 @@ public class SalesProduct : OmsAuditedAggregateRoot
public long? RemoteAreaId { get; set; }
+ public long? PriceAreaId { get; set; }
+
public long? WeightRuleId { get; set; }
public long? WeightRoundingId { get; set; }
diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannelGroup.cs b/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannelGroup.cs
index a76ac82..65683a1 100644
--- a/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannelGroup.cs
+++ b/src/Oms.Domain/ChannelsAggregate/Entities/ServiceChannelGroup.cs
@@ -4,7 +4,11 @@ using SqlSugar;
namespace Oms.Domain.ChannelsAggregate.Entities;
[SugarTable("t_service_channel_group")]
-public class ServiceChannelGroup : OmsAuditedEntity
+public class ServiceChannelGroup : OmsAuditedAggregateRoot
{
public string Name { get; set; } = string.Empty;
+
+ public long AppId { get; set; }
+
+ public long ApiServiceProviderId { get; set; }
}
diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelGroupRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelGroupRepository.cs
new file mode 100644
index 0000000..749f4a0
--- /dev/null
+++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelGroupRepository.cs
@@ -0,0 +1,9 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.ChannelsAggregate.Entities;
+
+namespace Oms.Domain.ChannelsAggregate.Repositories;
+
+public interface IServiceChannelGroupRepository : IRepository
+{
+ Task GetByNameAsync(string name, CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs
index 3a6f369..e20c460 100644
--- a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs
+++ b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs
@@ -9,6 +9,8 @@ public class Customer : OmsAuditedAggregateRoot
{
public string CustomerCode { get; set; } = string.Empty;
+ public string CustomerName { get; set; } = string.Empty;
+
public string? WechatAvatar { get; set; }
public string? WechatNickname { get; set; }
diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerSubAccountRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerSubAccountRepository.cs
index fea0fc0..7c2a498 100644
--- a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerSubAccountRepository.cs
+++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerSubAccountRepository.cs
@@ -9,5 +9,7 @@ public interface ICustomerSubAccountRepository : IRepository
Task GetBySubPhoneAsync(string subPhone, CancellationToken cancellationToken = default);
+ Task GetBySubEmailAsync(string subEmail, CancellationToken cancellationToken = default);
+
Task ExistsSubPhoneAsync(string subPhone, CancellationToken cancellationToken = default);
}
diff --git a/src/Oms.Domain/SystemAggregate/Entities/Country.cs b/src/Oms.Domain/SystemAggregate/Entities/Country.cs
new file mode 100644
index 0000000..e76f7d5
--- /dev/null
+++ b/src/Oms.Domain/SystemAggregate/Entities/Country.cs
@@ -0,0 +1,14 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.SystemAggregate.Entities;
+
+[SugarTable("t_country")]
+public class Country : OmsAuditedAggregateRoot
+{
+ public string ChineseName { get; set; } = string.Empty;
+
+ public string EnglishName { get; set; } = string.Empty;
+
+ public string CountryCode { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Domain/SystemAggregate/Entities/Dict.cs b/src/Oms.Domain/SystemAggregate/Entities/Dict.cs
new file mode 100644
index 0000000..8378007
--- /dev/null
+++ b/src/Oms.Domain/SystemAggregate/Entities/Dict.cs
@@ -0,0 +1,16 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.SystemAggregate.Entities;
+
+[SugarTable("t_dict")]
+public class Dict : OmsAuditedAggregateRoot
+{
+ public string DictType { get; set; } = string.Empty;
+
+ public int DictSort { get; set; }
+
+ public string DictCode { get; set; } = string.Empty;
+
+ public string DictName { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Domain/SystemAggregate/Entities/Notice.cs b/src/Oms.Domain/SystemAggregate/Entities/Notice.cs
new file mode 100644
index 0000000..e8fb134
--- /dev/null
+++ b/src/Oms.Domain/SystemAggregate/Entities/Notice.cs
@@ -0,0 +1,20 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.SystemAggregate.Entities;
+
+[SugarTable("t_notice")]
+public class Notice : OmsAuditedAggregateRoot
+{
+ public string NoticeType { get; set; } = string.Empty;
+
+ public string Title { get; set; } = string.Empty;
+
+ public string Content { get; set; } = string.Empty;
+
+ public byte IsPopup { get; set; }
+
+ public string ReceiverType { get; set; } = string.Empty;
+
+ public string ReceiverIds { get; set; } = "[]";
+}
diff --git a/src/Oms.Domain/SystemAggregate/Entities/NoticeRead.cs b/src/Oms.Domain/SystemAggregate/Entities/NoticeRead.cs
new file mode 100644
index 0000000..7a35d41
--- /dev/null
+++ b/src/Oms.Domain/SystemAggregate/Entities/NoticeRead.cs
@@ -0,0 +1,16 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.SystemAggregate.Entities;
+
+[SugarTable("t_notice_read")]
+public class NoticeRead : OmsAuditedAggregateRoot
+{
+ public long NoticeId { get; set; }
+
+ public string ReceiverType { get; set; } = string.Empty;
+
+ public long ReceiverId { get; set; }
+
+ public DateTime ReadTime { get; set; }
+}
diff --git a/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs
new file mode 100644
index 0000000..3ca1add
--- /dev/null
+++ b/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs
@@ -0,0 +1,9 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.SystemAggregate.Entities;
+
+namespace Oms.Domain.SystemAggregate.Repositories;
+
+public interface ICountryRepository : IRepository
+{
+ Task GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Domain/SystemAggregate/Repositories/IDictRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/IDictRepository.cs
new file mode 100644
index 0000000..9384d44
--- /dev/null
+++ b/src/Oms.Domain/SystemAggregate/Repositories/IDictRepository.cs
@@ -0,0 +1,9 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.SystemAggregate.Entities;
+
+namespace Oms.Domain.SystemAggregate.Repositories;
+
+public interface IDictRepository : IRepository
+{
+ Task GetByTypeAndCodeAsync(string dictType, string dictCode, CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Domain/SystemAggregate/Repositories/INoticeReadRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/INoticeReadRepository.cs
new file mode 100644
index 0000000..d8b1a0f
--- /dev/null
+++ b/src/Oms.Domain/SystemAggregate/Repositories/INoticeReadRepository.cs
@@ -0,0 +1,9 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.SystemAggregate.Entities;
+
+namespace Oms.Domain.SystemAggregate.Repositories;
+
+public interface INoticeReadRepository : IRepository
+{
+ Task GetByNoticeReceiverAsync(long noticeId, string receiverType, long receiverId, CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Domain/SystemAggregate/Repositories/INoticeRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/INoticeRepository.cs
new file mode 100644
index 0000000..b8d02c9
--- /dev/null
+++ b/src/Oms.Domain/SystemAggregate/Repositories/INoticeRepository.cs
@@ -0,0 +1,8 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.SystemAggregate.Entities;
+
+namespace Oms.Domain.SystemAggregate.Repositories;
+
+public interface INoticeRepository : IRepository
+{
+}
diff --git a/src/Oms.SqlSugarCore/Channels/ServiceChannelGroupRepository.cs b/src/Oms.SqlSugarCore/Channels/ServiceChannelGroupRepository.cs
new file mode 100644
index 0000000..22c5b78
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Channels/ServiceChannelGroupRepository.cs
@@ -0,0 +1,14 @@
+using Fake.DependencyInjection;
+using Oms.Domain.ChannelsAggregate.Entities;
+using Oms.Domain.ChannelsAggregate.Repositories;
+using Oms.SqlSugarCore.Repositories;
+
+namespace Oms.SqlSugarCore.Channels;
+
+public class ServiceChannelGroupRepository : OmsSqlSugarRepository, IServiceChannelGroupRepository, IScopedDependency
+{
+ public Task GetByNameAsync(string name, CancellationToken cancellationToken = default)
+ {
+ return FirstOrDefaultAsync(x => x.Name == name, cancellationToken);
+ }
+}
diff --git a/src/Oms.SqlSugarCore/Customers/CustomerSubAccountRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerSubAccountRepository.cs
index bc2efaa..4015307 100644
--- a/src/Oms.SqlSugarCore/Customers/CustomerSubAccountRepository.cs
+++ b/src/Oms.SqlSugarCore/Customers/CustomerSubAccountRepository.cs
@@ -17,6 +17,11 @@ public class CustomerSubAccountRepository : OmsSqlSugarRepository x.SubPhone == subPhone, cancellationToken);
}
+ public Task GetBySubEmailAsync(string subEmail, CancellationToken cancellationToken = default)
+ {
+ return FirstOrDefaultAsync(x => x.SubEmail == subEmail, cancellationToken);
+ }
+
public Task ExistsSubPhoneAsync(string subPhone, CancellationToken cancellationToken = default)
{
return AnyAsync(x => x.SubPhone == subPhone, cancellationToken);
diff --git a/src/Oms.SqlSugarCore/System/CountryRepository.cs b/src/Oms.SqlSugarCore/System/CountryRepository.cs
new file mode 100644
index 0000000..988c6ac
--- /dev/null
+++ b/src/Oms.SqlSugarCore/System/CountryRepository.cs
@@ -0,0 +1,14 @@
+using Fake.DependencyInjection;
+using Oms.Domain.SystemAggregate.Entities;
+using Oms.Domain.SystemAggregate.Repositories;
+using Oms.SqlSugarCore.Repositories;
+
+namespace Oms.SqlSugarCore.System;
+
+public class CountryRepository : OmsSqlSugarRepository, ICountryRepository, IScopedDependency
+{
+ public Task GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default)
+ {
+ return FirstOrDefaultAsync(x => x.CountryCode == countryCode, cancellationToken);
+ }
+}
diff --git a/src/Oms.SqlSugarCore/System/DictRepository.cs b/src/Oms.SqlSugarCore/System/DictRepository.cs
new file mode 100644
index 0000000..fd69ef8
--- /dev/null
+++ b/src/Oms.SqlSugarCore/System/DictRepository.cs
@@ -0,0 +1,14 @@
+using Fake.DependencyInjection;
+using Oms.Domain.SystemAggregate.Entities;
+using Oms.Domain.SystemAggregate.Repositories;
+using Oms.SqlSugarCore.Repositories;
+
+namespace Oms.SqlSugarCore.System;
+
+public class DictRepository : OmsSqlSugarRepository, IDictRepository, IScopedDependency
+{
+ public Task GetByTypeAndCodeAsync(string dictType, string dictCode, CancellationToken cancellationToken = default)
+ {
+ return FirstOrDefaultAsync(x => x.DictType == dictType && x.DictCode == dictCode, cancellationToken);
+ }
+}
diff --git a/src/Oms.SqlSugarCore/System/NoticeReadRepository.cs b/src/Oms.SqlSugarCore/System/NoticeReadRepository.cs
new file mode 100644
index 0000000..fd13b7f
--- /dev/null
+++ b/src/Oms.SqlSugarCore/System/NoticeReadRepository.cs
@@ -0,0 +1,16 @@
+using Fake.DependencyInjection;
+using Oms.Domain.SystemAggregate.Entities;
+using Oms.Domain.SystemAggregate.Repositories;
+using Oms.SqlSugarCore.Repositories;
+
+namespace Oms.SqlSugarCore.System;
+
+public class NoticeReadRepository : OmsSqlSugarRepository, INoticeReadRepository, IScopedDependency
+{
+ public Task GetByNoticeReceiverAsync(long noticeId, string receiverType, long receiverId, CancellationToken cancellationToken = default)
+ {
+ return FirstOrDefaultAsync(
+ x => x.NoticeId == noticeId && x.ReceiverType == receiverType && x.ReceiverId == receiverId,
+ cancellationToken);
+ }
+}
diff --git a/src/Oms.SqlSugarCore/System/NoticeRepository.cs b/src/Oms.SqlSugarCore/System/NoticeRepository.cs
new file mode 100644
index 0000000..4eac3c9
--- /dev/null
+++ b/src/Oms.SqlSugarCore/System/NoticeRepository.cs
@@ -0,0 +1,10 @@
+using Fake.DependencyInjection;
+using Oms.Domain.SystemAggregate.Entities;
+using Oms.Domain.SystemAggregate.Repositories;
+using Oms.SqlSugarCore.Repositories;
+
+namespace Oms.SqlSugarCore.System;
+
+public class NoticeRepository : OmsSqlSugarRepository, INoticeRepository, IScopedDependency
+{
+}
diff --git a/src/Oms.Tests/GlobalUsings.cs b/src/Oms.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/src/Oms.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/src/Oms.Tests/Oms.Tests.csproj b/src/Oms.Tests/Oms.Tests.csproj
new file mode 100644
index 0000000..07d7250
--- /dev/null
+++ b/src/Oms.Tests/Oms.Tests.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Oms.Tests/OmsDocHelperTests.cs b/src/Oms.Tests/OmsDocHelperTests.cs
new file mode 100644
index 0000000..c74b36a
--- /dev/null
+++ b/src/Oms.Tests/OmsDocHelperTests.cs
@@ -0,0 +1,90 @@
+using System.Text.Json;
+using FluentAssertions;
+using Oms.Application.Shared;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.SystemAggregate.Consts;
+
+namespace Oms.Tests;
+
+public class OmsDocHelperTests
+{
+ [Theory]
+ [InlineData(0, 1)]
+ [InlineData(-3, 1)]
+ [InlineData(5, 5)]
+ public void NormalizePage_ShouldClampInvalidValuesToOne(int page, int expectedPage)
+ {
+ var normalizedPage = OmsDocHelper.NormalizePage(page);
+
+ normalizedPage.Should().Be(expectedPage);
+ }
+
+ [Theory]
+ [InlineData(0, 20)]
+ [InlineData(-1, 20)]
+ [InlineData(50, 50)]
+ [InlineData(500, 200)]
+ public void NormalizePageSize_ShouldUseExpectedBounds(int pageSize, int expectedPageSize)
+ {
+ var normalizedPageSize = OmsDocHelper.NormalizePageSize(pageSize);
+
+ normalizedPageSize.Should().Be(expectedPageSize);
+ }
+
+ [Fact]
+ public void NullIfDefault_ShouldReturnNullForDefaultDateTime()
+ {
+ var value = OmsDocHelper.NullIfDefault(default);
+
+ value.Should().BeNull();
+ }
+
+ [Fact]
+ public void SerializeJson_AndDeserializeJson_ShouldRoundTripObject()
+ {
+ var payload = new Dictionary
+ {
+ ["customerCode"] = "C20260428",
+ ["amount"] = 128.5m
+ };
+
+ var json = OmsDocHelper.SerializeJson(payload);
+ var result = OmsDocHelper.DeserializeJson>(json);
+
+ result.Should().NotBeNull();
+ result!["customerCode"].GetString().Should().Be("C20260428");
+ result["amount"].GetDecimal().Should().Be(128.5m);
+ }
+
+ [Fact]
+ public void DeserializeStringList_ShouldReturnEmptyListWhenJsonMissing()
+ {
+ var items = OmsDocHelper.DeserializeStringList(null);
+
+ items.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void GetOrderStatusName_ShouldReturnConfiguredName()
+ {
+ var statusName = OmsDocHelper.GetOrderStatusName(OrderStatusCodes.PendingShip);
+
+ statusName.Should().Be("待发货");
+ }
+
+ [Fact]
+ public void GetOrderStatusName_ShouldReturnOriginalCodeWhenNotMapped()
+ {
+ var statusName = OmsDocHelper.GetOrderStatusName("customStatus");
+
+ statusName.Should().Be("customStatus");
+ }
+
+ [Fact]
+ public void GetWarehouseTypeName_ShouldReturnConfiguredName()
+ {
+ var typeName = OmsDocHelper.GetWarehouseTypeName(WarehouseTypeCodes.Transit);
+
+ typeName.Should().Be("中转仓");
+ }
+}
diff --git a/src/Oms.Tests/OmsPasswordHelperTests.cs b/src/Oms.Tests/OmsPasswordHelperTests.cs
new file mode 100644
index 0000000..54b47e7
--- /dev/null
+++ b/src/Oms.Tests/OmsPasswordHelperTests.cs
@@ -0,0 +1,24 @@
+using FluentAssertions;
+using Oms.Application.Shared;
+
+namespace Oms.Tests;
+
+public class OmsPasswordHelperTests
+{
+ [Fact]
+ public void ToMd5_ShouldReturnExpectedLowercaseHash()
+ {
+ var hash = OmsPasswordHelper.ToMd5("123456");
+
+ hash.Should().Be("e10adc3949ba59abbe56e057f20f883e");
+ }
+
+ [Fact]
+ public void ToMd5_ShouldBeDeterministicForSameInput()
+ {
+ var firstHash = OmsPasswordHelper.ToMd5("Nanafa@2026");
+ var secondHash = OmsPasswordHelper.ToMd5("Nanafa@2026");
+
+ firstHash.Should().Be(secondHash);
+ }
+}
diff --git a/src/Oms.Tests/SimpleCurrentUserTests.cs b/src/Oms.Tests/SimpleCurrentUserTests.cs
new file mode 100644
index 0000000..786a1ce
--- /dev/null
+++ b/src/Oms.Tests/SimpleCurrentUserTests.cs
@@ -0,0 +1,73 @@
+using System.Security.Claims;
+using Fake.Security.Claims;
+using FluentAssertions;
+using Oms.Tests.Support;
+using SimpleAdmin.Api;
+
+namespace Oms.Tests;
+
+public class SimpleCurrentUserTests
+{
+ [Fact]
+ public void GetId_ShouldPreferNumericUserIdClaim()
+ {
+ var currentUser = CreateCurrentUser(
+ [
+ new Claim(FakeClaimTypes.UserId, "42"),
+ new Claim("mapId", "99"),
+ new Claim("customerId", "100")
+ ]);
+
+ currentUser.GetId().Should().Be(42);
+ currentUser.GetId().Should().Be(42);
+ }
+
+ [Fact]
+ public void GetId_ShouldFallBackToMapIdWhenUserIdIsGuid()
+ {
+ var currentUser = CreateCurrentUser(
+ [
+ new Claim(FakeClaimTypes.UserId, Guid.NewGuid().ToString()),
+ new Claim("mapId", "88")
+ ]);
+
+ currentUser.GetId().Should().Be(88);
+ currentUser.GetId().Should().Be(88);
+ }
+
+ [Fact]
+ public void GetId_ShouldFallBackToCustomerIdWhenMapIdMissing()
+ {
+ var currentUser = CreateCurrentUser(
+ [
+ new Claim(FakeClaimTypes.UserId, Guid.NewGuid().ToString()),
+ new Claim("customerId", "77")
+ ]);
+
+ currentUser.GetId().Should().Be(77);
+ }
+
+ [Fact]
+ public void Id_ShouldKeepGuidIdentitySemantics()
+ {
+ var guid = Guid.NewGuid();
+ var currentUser = CreateCurrentUser([new Claim(FakeClaimTypes.UserId, guid.ToString())]);
+
+ currentUser.Id.Should().Be(guid);
+ }
+
+ [Fact]
+ public void GetId_ShouldReturnZeroWhenNoNumericClaimsExist()
+ {
+ var currentUser = CreateCurrentUser([new Claim(FakeClaimTypes.UserId, Guid.NewGuid().ToString())]);
+
+ currentUser.GetId().Should().Be(0);
+ }
+
+ private static SimpleCurrentUser CreateCurrentUser(params Claim[] claims)
+ {
+ var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "Test"));
+ var principalAccessor = new TestCurrentPrincipalAccessor(principal);
+ return new SimpleCurrentUser(principalAccessor);
+ }
+}
diff --git a/src/Oms.Tests/Support/TestCurrentPrincipalAccessor.cs b/src/Oms.Tests/Support/TestCurrentPrincipalAccessor.cs
new file mode 100644
index 0000000..f8c1278
--- /dev/null
+++ b/src/Oms.Tests/Support/TestCurrentPrincipalAccessor.cs
@@ -0,0 +1,24 @@
+using System.Security.Claims;
+using Fake.Security.Claims;
+
+namespace Oms.Tests.Support;
+
+internal sealed class TestCurrentPrincipalAccessor(ClaimsPrincipal principal) : ICurrentPrincipalAccessor
+{
+ public ClaimsPrincipal Principal { get; private set; } = principal;
+
+ public IDisposable Change(ClaimsPrincipal principal)
+ {
+ var originalPrincipal = Principal;
+ Principal = principal;
+ return new RestorePrincipalScope(() => Principal = originalPrincipal);
+ }
+
+ private sealed class RestorePrincipalScope(Action restore) : IDisposable
+ {
+ public void Dispose()
+ {
+ restore();
+ }
+ }
+}
diff --git a/src/SimpleAdmin.Api.IntegrationTests/AuthApiIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/AuthApiIntegrationTests.cs
new file mode 100644
index 0000000..21d0194
--- /dev/null
+++ b/src/SimpleAdmin.Api.IntegrationTests/AuthApiIntegrationTests.cs
@@ -0,0 +1,18 @@
+using System.Net;
+using FluentAssertions;
+using SimpleAdmin.Api.IntegrationTests.Support;
+
+namespace SimpleAdmin.Api.IntegrationTests;
+
+public class AuthApiIntegrationTests
+{
+ [Fact]
+ public async Task GetCurrentUser_ShouldReturnUnauthorizedWhenRequestHasNoToken()
+ {
+ await using var host = await SimpleAdminApiTestHost.CreateAsync();
+
+ var response = await host.Client.GetAsync("/rbac/auth/get-current-user");
+
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+}
diff --git a/src/SimpleAdmin.Api.IntegrationTests/CountryApiIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/CountryApiIntegrationTests.cs
new file mode 100644
index 0000000..61595a0
--- /dev/null
+++ b/src/SimpleAdmin.Api.IntegrationTests/CountryApiIntegrationTests.cs
@@ -0,0 +1,38 @@
+using System.Net;
+using FluentAssertions;
+using Moq;
+using Oms.Application.Dtos.System;
+using Oms.Domain.SystemAggregate.Entities;
+using Oms.Domain.SystemAggregate.Repositories;
+using SimpleAdmin.Api.IntegrationTests.Support;
+
+namespace SimpleAdmin.Api.IntegrationTests;
+
+public class CountryApiIntegrationTests
+{
+ [Fact]
+ public async Task GetCountryList_ShouldReturnOrderedItemsFromRepository()
+ {
+ var repository = new Mock();
+ repository
+ .Setup(x => x.GetListAsync(
+ It.IsAny>>(),
+ It.IsAny?>(),
+ It.IsAny()))
+ .ReturnsAsync([
+ new Country { Id = 2, CountryCode = "US", EnglishName = "United States", ChineseName = "美国" },
+ new Country { Id = 1, CountryCode = "AU", EnglishName = "Australia", ChineseName = "澳大利亚" },
+ new Country { Id = 3, CountryCode = "US", EnglishName = "America", ChineseName = "美利坚" }
+ ]);
+
+ await using var host = await SimpleAdminApiTestHost.CreateAsync(repository.Object);
+
+ var response = await host.Client.GetAsync("/system/country/list");
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK, string.Join(" | ", host.RoutePatterns));
+ var items = await response.Content.ReadFromJsonAsync>();
+ items.Should().NotBeNull();
+ items!.Select(x => x.CountryCode).Should().ContainInOrder("AU", "US", "US");
+ items.Select(x => x.EnglishName).Should().ContainInOrder("Australia", "America", "United States");
+ }
+}
diff --git a/src/SimpleAdmin.Api.IntegrationTests/CustomerApiIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/CustomerApiIntegrationTests.cs
new file mode 100644
index 0000000..a9d44c0
--- /dev/null
+++ b/src/SimpleAdmin.Api.IntegrationTests/CustomerApiIntegrationTests.cs
@@ -0,0 +1,286 @@
+using System.Net;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Moq;
+using Oms.Application.Dtos.Customers;
+using Oms.Application.Dtos.Shared;
+using Oms.Application.Shared;
+using Oms.Domain.CustomersAggregate.Entities;
+using Oms.Domain.CustomersAggregate.Enums;
+using Oms.Domain.CustomersAggregate.Repositories;
+using SimpleAdmin.Api.IntegrationTests.Support;
+
+namespace SimpleAdmin.Api.IntegrationTests;
+
+public class CustomerApiIntegrationTests
+{
+ [Fact]
+ public async Task GetCustomerList_ShouldReturnPagedItems()
+ {
+ var repository = new Mock(MockBehavior.Strict);
+ repository
+ .Setup(x => x.GetPagedListAsync(
+ It.IsAny>>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync([
+ new Customer
+ {
+ Id = 1001,
+ CustomerCode = "CUST-001",
+ CustomerName = "Alpha Trade",
+ Phone = "13800138000",
+ Email = "alpha@nanafa.com",
+ Balance = 1200.50m,
+ TotalQuota = 5000m,
+ UsedQuota = 800m,
+ Status = CustomerStatus.Normal,
+ CustomerGroup = CustomerGroup.Vip1,
+ Role = "Customer",
+ Salesman = "sales-a",
+ CustomerService = "service-a",
+ CreateTime = new DateTime(2026, 4, 29, 9, 0, 0),
+ UpdateTime = new DateTime(2026, 4, 29, 12, 0, 0)
+ }
+ ]);
+ repository
+ .Setup(x => x.CountAsync(
+ It.IsAny>>(),
+ It.IsAny()))
+ .ReturnsAsync(1);
+
+ await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(repository.Object);
+ });
+
+ var response = await host.Client.GetAsync("/customer/list?Page=1&PageSize=20&Keyword=Alpha");
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var payload = await response.Content.ReadFromJsonAsync>();
+ payload.Should().NotBeNull();
+ payload!.Total.Should().Be(1);
+ payload.Items.Should().ContainSingle();
+ payload.Items[0].CustomerCode.Should().Be("CUST-001");
+ payload.Items[0].CustomerName.Should().Be("Alpha Trade");
+ payload.Items[0].Balance.Should().Be(1200.50m);
+ }
+
+ [Fact]
+ public async Task GetCustomerDetail_ShouldReturnMappedAuditFields()
+ {
+ var repository = new Mock(MockBehavior.Strict);
+ repository
+ .Setup(x => x.FirstOrDefaultAsync(
+ It.IsAny>>(),
+ It.IsAny()))
+ .ReturnsAsync(new Customer
+ {
+ Id = 2002,
+ CustomerCode = "CUST-002",
+ CustomerName = "Beta Trade",
+ Phone = "13900139000",
+ Email = "beta@nanafa.com",
+ Balance = 100m,
+ TotalQuota = 2000m,
+ UsedQuota = 300m,
+ Status = CustomerStatus.Normal,
+ Role = "Customer",
+ CreateTime = new DateTime(2026, 4, 28, 9, 0, 0),
+ CreateUserId = 9,
+ UpdateTime = new DateTime(2026, 4, 28, 10, 30, 0),
+ UpdateUserId = 18
+ });
+
+ await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(repository.Object);
+ });
+
+ var response = await host.Client.GetAsync("/customer/get?id=2002");
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var payload = await response.Content.ReadFromJsonAsync();
+ payload.Should().NotBeNull();
+ payload!.CustomerName.Should().Be("Beta Trade");
+ payload.CreateBy.Should().Be("9");
+ payload.UpdateBy.Should().Be("18");
+ }
+
+ [Fact]
+ public async Task CreateCustomer_ShouldHashPasswordAndPersistBusinessFields()
+ {
+ Customer? insertedEntity = null;
+ var repository = new Mock(MockBehavior.Strict);
+ repository
+ .Setup(x => x.GetByCustomerCodeAsync("CUST-NEW", It.IsAny()))
+ .ReturnsAsync((Customer?)null);
+ repository
+ .Setup(x => x.GetByPhoneAsync("13700137000", It.IsAny()))
+ .ReturnsAsync((Customer?)null);
+ repository
+ .Setup(x => x.GetByEmailAsync("new@nanafa.com", It.IsAny()))
+ .ReturnsAsync((Customer?)null);
+ repository
+ .Setup(x => x.InsertAsync(It.IsAny(), It.IsAny()))
+ .Callback((entity, _) => insertedEntity = entity)
+ .ReturnsAsync((Customer entity, CancellationToken _) => entity);
+
+ await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(repository.Object);
+ });
+
+ var response = await host.Client.PostAsJsonAsync("/customer/create", new CreateCustomerInput
+ {
+ CustomerCode = "CUST-NEW",
+ CustomerName = "New Customer",
+ Phone = "13700137000",
+ Email = "new@nanafa.com",
+ Password = "P@ssw0rd!",
+ Balance = 88.5m,
+ TotalQuota = 1000m,
+ UsedQuota = 100m,
+ Status = CustomerStatus.Normal,
+ Salesman = "sales-new",
+ CustomerService = "service-new",
+ CustomerGroup = CustomerGroup.Vip1,
+ Role = "Customer",
+ Remark = "integration-test",
+ WechatAvatar = "https://cdn.example.com/avatar.png",
+ WechatNickname = "newbie"
+ });
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ insertedEntity.Should().NotBeNull();
+ insertedEntity!.CustomerCode.Should().Be("CUST-NEW");
+ insertedEntity.CustomerName.Should().Be("New Customer");
+ insertedEntity.Password.Should().Be(OmsPasswordHelper.ToMd5("P@ssw0rd!"));
+ insertedEntity.Balance.Should().Be(88.5m);
+ insertedEntity.UsedQuota.Should().Be(100m);
+ insertedEntity.Role.Should().Be("Customer");
+ }
+
+ [Fact]
+ public async Task UpdateCustomer_ShouldPersistMutatedEntity()
+ {
+ Customer? updatedEntity = null;
+ var currentEntity = new Customer
+ {
+ Id = 3003,
+ CustomerCode = "CUST-OLD",
+ CustomerName = "Old Customer",
+ Phone = "13600136000",
+ Email = "old@nanafa.com",
+ Password = OmsPasswordHelper.ToMd5("old-password"),
+ Balance = 10m,
+ TotalQuota = 100m,
+ UsedQuota = 20m,
+ Status = CustomerStatus.Disabled,
+ Role = "Customer"
+ };
+
+ var repository = new Mock(MockBehavior.Strict);
+ repository
+ .Setup(x => x.FirstOrDefaultAsync(
+ It.IsAny>>(),
+ It.IsAny()))
+ .ReturnsAsync(currentEntity);
+ repository
+ .Setup(x => x.GetByCustomerCodeAsync("CUST-UPD", It.IsAny()))
+ .ReturnsAsync((Customer?)null);
+ repository
+ .Setup(x => x.GetByPhoneAsync("13500135000", It.IsAny()))
+ .ReturnsAsync((Customer?)null);
+ repository
+ .Setup(x => x.GetByEmailAsync("updated@nanafa.com", It.IsAny()))
+ .ReturnsAsync((Customer?)null);
+ repository
+ .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny()))
+ .Callback((entity, _) => updatedEntity = entity)
+ .Returns(Task.CompletedTask);
+
+ await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(repository.Object);
+ });
+
+ var response = await host.Client.PostAsJsonAsync("/customer/update", new UpdateCustomerInput
+ {
+ Id = 3003,
+ CustomerCode = "CUST-UPD",
+ CustomerName = "Updated Customer",
+ Phone = "13500135000",
+ Email = "updated@nanafa.com",
+ Balance = 520.75m,
+ TotalQuota = 8000m,
+ UsedQuota = 1500m,
+ Status = CustomerStatus.Normal,
+ Salesman = "sales-updated",
+ CustomerService = "service-updated",
+ CustomerGroup = CustomerGroup.Normal,
+ Role = "VipCustomer",
+ Remark = "updated by integration test",
+ WechatAvatar = "https://cdn.example.com/new-avatar.png",
+ WechatNickname = "updated-wechat"
+ });
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ updatedEntity.Should().NotBeNull();
+ updatedEntity!.CustomerCode.Should().Be("CUST-UPD");
+ updatedEntity.CustomerName.Should().Be("Updated Customer");
+ updatedEntity.Phone.Should().Be("13500135000");
+ updatedEntity.Balance.Should().Be(520.75m);
+ updatedEntity.Role.Should().Be("VipCustomer");
+ updatedEntity.Password.Should().Be(OmsPasswordHelper.ToMd5("old-password"));
+ }
+
+ [Fact]
+ public async Task ResetPassword_ShouldHashNewPasswordBeforeSaving()
+ {
+ Customer? updatedEntity = null;
+ var currentEntity = new Customer
+ {
+ Id = 4004,
+ CustomerCode = "CUST-PWD",
+ CustomerName = "Password Customer",
+ Phone = "13400134000",
+ Password = OmsPasswordHelper.ToMd5("before-reset"),
+ Status = CustomerStatus.Normal
+ };
+
+ var repository = new Mock(MockBehavior.Strict);
+ repository
+ .Setup(x => x.FirstOrDefaultAsync(
+ It.IsAny>>(),
+ It.IsAny()))
+ .ReturnsAsync(currentEntity);
+ repository
+ .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny()))
+ .Callback((entity, _) => updatedEntity = entity)
+ .Returns(Task.CompletedTask);
+
+ await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(repository.Object);
+ });
+
+ var response = await host.Client.PostAsJsonAsync("/customer/reset-password", new ResetCustomerPasswordInput
+ {
+ Id = 4004,
+ NewPassword = "after-reset"
+ });
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ updatedEntity.Should().NotBeNull();
+ updatedEntity!.Password.Should().Be(OmsPasswordHelper.ToMd5("after-reset"));
+ }
+}
diff --git a/src/SimpleAdmin.Api.IntegrationTests/DictApiIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/DictApiIntegrationTests.cs
new file mode 100644
index 0000000..e31141c
--- /dev/null
+++ b/src/SimpleAdmin.Api.IntegrationTests/DictApiIntegrationTests.cs
@@ -0,0 +1,54 @@
+using System.Net;
+using FluentAssertions;
+using Moq;
+using Oms.Application.Dtos.Shared;
+using Oms.Application.Dtos.System;
+using Oms.Domain.SystemAggregate.Entities;
+using Oms.Domain.SystemAggregate.Repositories;
+using SimpleAdmin.Api.IntegrationTests.Support;
+
+namespace SimpleAdmin.Api.IntegrationTests;
+
+public class DictApiIntegrationTests
+{
+ [Fact]
+ public async Task GetDictList_ShouldReturnPagedResultFromRepository()
+ {
+ var repository = new Mock();
+ repository
+ .Setup(x => x.GetPagedListAsync(
+ It.IsAny>>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync([
+ new Dict
+ {
+ Id = 9,
+ DictType = "orderStatus",
+ DictSort = 1,
+ DictCode = "draft",
+ DictName = "草稿",
+ CreateTime = new DateTime(2026, 4, 28, 10, 0, 0)
+ }
+ ]);
+ repository
+ .Setup(x => x.CountAsync(
+ It.IsAny>>(),
+ It.IsAny()))
+ .ReturnsAsync(1);
+
+ await using var host = await SimpleAdminApiTestHost.CreateAsync(dictRepository: repository.Object);
+
+ var response = await host.Client.GetAsync("/system/dict/list?Page=1&PageSize=20");
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var payload = await response.Content.ReadFromJsonAsync>();
+ payload.Should().NotBeNull();
+ payload!.Total.Should().Be(1);
+ payload.Items.Should().ContainSingle();
+ payload.Items[0].DictCode.Should().Be("draft");
+ payload.Items[0].DictName.Should().Be("草稿");
+ }
+}
diff --git a/src/SimpleAdmin.Api.IntegrationTests/GlobalUsings.cs b/src/SimpleAdmin.Api.IntegrationTests/GlobalUsings.cs
new file mode 100644
index 0000000..ccd2032
--- /dev/null
+++ b/src/SimpleAdmin.Api.IntegrationTests/GlobalUsings.cs
@@ -0,0 +1,2 @@
+global using Xunit;
+global using System.Net.Http.Json;
diff --git a/src/SimpleAdmin.Api.IntegrationTests/SimpleAdmin.Api.IntegrationTests.csproj b/src/SimpleAdmin.Api.IntegrationTests/SimpleAdmin.Api.IntegrationTests.csproj
new file mode 100644
index 0000000..3648313
--- /dev/null
+++ b/src/SimpleAdmin.Api.IntegrationTests/SimpleAdmin.Api.IntegrationTests.csproj
@@ -0,0 +1,38 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SimpleAdmin.Api.IntegrationTests/Support/SimpleAdminApiTestHost.cs b/src/SimpleAdmin.Api.IntegrationTests/Support/SimpleAdminApiTestHost.cs
new file mode 100644
index 0000000..b09794b
--- /dev/null
+++ b/src/SimpleAdmin.Api.IntegrationTests/Support/SimpleAdminApiTestHost.cs
@@ -0,0 +1,108 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Fake.Data.Seeding;
+using Fake.Rbac.Infrastructure;
+using Microsoft.AspNetCore.Routing;
+using Oms.Domain.SystemAggregate.Repositories;
+using SimpleAdmin.Api;
+
+namespace SimpleAdmin.Api.IntegrationTests.Support;
+
+internal sealed class SimpleAdminApiTestHost : IAsyncDisposable
+{
+ private readonly WebApplication _app;
+
+ private SimpleAdminApiTestHost(WebApplication app)
+ {
+ _app = app;
+ Client = app.GetTestClient();
+ }
+
+ public HttpClient Client { get; }
+
+ public IReadOnlyList RoutePatterns =>
+ _app.Services.GetRequiredService()
+ .Endpoints
+ .OfType()
+ .Select(x => x.RoutePattern.RawText ?? string.Empty)
+ .OrderBy(x => x)
+ .ToList();
+
+ public static async Task CreateAsync(
+ ICountryRepository? countryRepository = null,
+ IDictRepository? dictRepository = null,
+ Action? configureServices = null)
+ {
+ var builder = SimpleAdminApiBootstrap.CreateBuilder([], webAppBuilder =>
+ {
+ webAppBuilder.WebHost.UseTestServer();
+ webAppBuilder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["UseLocalConfigs"] = "true",
+ ["ConnectionStrings:Default"] = "Server=127.0.0.1;Port=3306;Database=oms_integration_test;Uid=test;Pwd=test;",
+ ["App:SendStartupNotification"] = "false",
+ ["App:CorsOrigins:0"] = "http://localhost",
+ ["App:Urls:0"] = "http://127.0.0.1:5005",
+ ["Jwt:SecretKey"] = "simple-admin-integration-test-secret-key",
+ ["Jwt:Issuer"] = "SimpleAdmin.Api.IntegrationTests",
+ ["Jwt:Audience"] = "SimpleAdmin.Api.IntegrationTests",
+ ["Jwt:ExpiryInMinutes"] = "60",
+ ["Jwt:RefreshExpiryInDays"] = "7",
+ ["Jwt:ClockSkewMinutes"] = "5",
+ ["JwtSettings:SecretKey"] = "simple-admin-integration-test-secret-key",
+ ["JwtSettings:Issuer"] = "SimpleAdmin.Api.IntegrationTests",
+ ["JwtSettings:Audience"] = "SimpleAdmin.Api.IntegrationTests",
+ ["JwtSettings:ExpiryInMinutes"] = "60",
+ ["JwtSettings:RefreshExpiryInDays"] = "7",
+ ["JwtSettings:ClockSkewMinutes"] = "5",
+ ["FeiShu:Webhook"] = "https://example.com/webhook",
+ ["FeiShu:EnableFeiShuExceptionSubscribe"] = "false",
+ ["FeiShuNotice:Webhook"] = "https://example.com/webhook",
+ ["FeiShuNotice:EnableFeiShuExceptionSubscribe"] = "false"
+ });
+ }, environmentName: "Testing");
+
+ builder.Services.RemoveAll();
+ builder.Services.RemoveAll();
+ builder.Services.RemoveAll>();
+ builder.Services.AddDbContext(options =>
+ {
+ options.UseInMemoryDatabase($"rbac-integration-{Guid.NewGuid():N}");
+ });
+
+ if (countryRepository != null)
+ {
+ ReplaceSingleton(builder.Services, countryRepository);
+ }
+
+ if (dictRepository != null)
+ {
+ ReplaceSingleton(builder.Services, dictRepository);
+ }
+
+ configureServices?.Invoke(builder.Services);
+
+ var app = SimpleAdminApiBootstrap.BuildApplication(builder);
+ await app.StartAsync();
+ return new SimpleAdminApiTestHost(app);
+ }
+
+ private static void ReplaceSingleton(IServiceCollection services, TService implementation)
+ where TService : class
+ {
+ services.RemoveAll();
+ services.AddSingleton(implementation);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ Client.Dispose();
+ await _app.StopAsync();
+ await _app.DisposeAsync();
+ }
+}
diff --git a/src/SimpleAdmin.Api.IntegrationTests/WarehouseApiIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/WarehouseApiIntegrationTests.cs
new file mode 100644
index 0000000..9ed5e56
--- /dev/null
+++ b/src/SimpleAdmin.Api.IntegrationTests/WarehouseApiIntegrationTests.cs
@@ -0,0 +1,203 @@
+using System.Net;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Moq;
+using Oms.Application.Dtos.Shared;
+using Oms.Application.Dtos.System;
+using Oms.Domain.SystemAggregate.Consts;
+using Oms.Domain.SystemAggregate.Entities;
+using Oms.Domain.SystemAggregate.Repositories;
+using SimpleAdmin.Api.IntegrationTests.Support;
+
+namespace SimpleAdmin.Api.IntegrationTests;
+
+public class WarehouseApiIntegrationTests
+{
+ [Fact]
+ public async Task GetWarehouseList_ShouldReturnPagedItemsWithMappedFields()
+ {
+ var repository = new Mock(MockBehavior.Strict);
+ repository
+ .Setup(x => x.GetPagedListAsync(
+ It.IsAny>>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync([
+ new WarehouseAddress
+ {
+ Id = 12,
+ WarehouseName = "UK Transit",
+ WarehouseType = WarehouseTypeCodes.Transit,
+ Recipient = "Alice",
+ ContactPhone = "13800138000",
+ ShippingAddress = "London",
+ ServicePhone = "020-123456",
+ ServiceWechat = "oms-warehouse",
+ ServiceWechatFiles = "[\"wx-1.png\",\"wx-2.png\"]",
+ CreateTime = new DateTime(2026, 4, 29, 9, 0, 0),
+ UpdateTime = new DateTime(2026, 4, 29, 10, 0, 0)
+ }
+ ]);
+ repository
+ .Setup(x => x.CountAsync(
+ It.IsAny>>(),
+ It.IsAny()))
+ .ReturnsAsync(1);
+
+ await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(repository.Object);
+ });
+
+ var response = await host.Client.GetAsync("/system/warehouse/list?Page=1&PageSize=20&WarehouseType=transit&Keyword=UK");
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var payload = await response.Content.ReadFromJsonAsync>();
+ payload.Should().NotBeNull();
+ payload!.Total.Should().Be(1);
+ payload.Items.Should().ContainSingle();
+ payload.Items[0].WarehouseTypeName.Should().Be("中转仓");
+ payload.Items[0].ServiceWechatFiles.Should().ContainInOrder("wx-1.png", "wx-2.png");
+ }
+
+ [Fact]
+ public async Task GetWarehouseDetail_ShouldReturnMappedAuditAndFileFields()
+ {
+ var repository = new Mock(MockBehavior.Strict);
+ repository
+ .Setup(x => x.FirstOrDefaultAsync(
+ It.IsAny>>(),
+ It.IsAny()))
+ .ReturnsAsync(new WarehouseAddress
+ {
+ Id = 8,
+ WarehouseName = "Main Hub",
+ WarehouseType = WarehouseTypeCodes.Main,
+ Recipient = "Bob",
+ ContactPhone = "13900139000",
+ ShippingAddress = "Shenzhen",
+ ServicePhone = "0755-123456",
+ ServiceWechat = "main-hub",
+ ServiceWechatFiles = "[\"main.png\"]",
+ CreateTime = new DateTime(2026, 4, 20, 9, 30, 0),
+ CreateUserId = 66,
+ UpdateTime = new DateTime(2026, 4, 21, 14, 0, 0),
+ UpdateUserId = 88
+ });
+
+ await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(repository.Object);
+ });
+
+ var response = await host.Client.GetAsync("/system/warehouse/detail?id=8");
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var payload = await response.Content.ReadFromJsonAsync();
+ payload.Should().NotBeNull();
+ payload!.CreateBy.Should().Be("66");
+ payload.UpdateBy.Should().Be("88");
+ payload.ServiceWechatFiles.Should().ContainSingle().Which.Should().Be("main.png");
+ }
+
+ [Fact]
+ public async Task CreateWarehouse_ShouldSerializeFilesBeforePersisting()
+ {
+ WarehouseAddress? insertedEntity = null;
+ var repository = new Mock(MockBehavior.Strict);
+ repository
+ .Setup(x => x.GetByWarehouseNameAsync("New Warehouse", It.IsAny()))
+ .ReturnsAsync((WarehouseAddress?)null);
+ repository
+ .Setup(x => x.InsertAsync(It.IsAny(), It.IsAny()))
+ .Callback((entity, _) => insertedEntity = entity)
+ .ReturnsAsync((WarehouseAddress entity, CancellationToken _) => entity);
+
+ await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(repository.Object);
+ });
+
+ var response = await host.Client.PostAsJsonAsync("/system/warehouse/create", new CreateWarehouseInput
+ {
+ WarehouseName = "New Warehouse",
+ WarehouseType = WarehouseTypeCodes.Return,
+ Recipient = "Cindy",
+ ContactPhone = "13700137000",
+ ShippingAddress = "Guangzhou",
+ ServicePhone = "020-888888",
+ ServiceWechat = "return-hub",
+ ServiceWechatFiles = ["qr-a.png", "qr-b.png"]
+ });
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ insertedEntity.Should().NotBeNull();
+ insertedEntity!.WarehouseName.Should().Be("New Warehouse");
+ insertedEntity.WarehouseType.Should().Be(WarehouseTypeCodes.Return);
+ insertedEntity.ServiceWechatFiles.Should().Be("[\"qr-a.png\",\"qr-b.png\"]");
+ }
+
+ [Fact]
+ public async Task UpdateWarehouse_ShouldPersistMutatedEntity()
+ {
+ WarehouseAddress? updatedEntity = null;
+ var currentEntity = new WarehouseAddress
+ {
+ Id = 18,
+ WarehouseName = "Old Warehouse",
+ WarehouseType = WarehouseTypeCodes.Other,
+ Recipient = "Old",
+ ContactPhone = "13600136000",
+ ShippingAddress = "Old Address",
+ ServicePhone = "010-111111",
+ ServiceWechat = "old-wechat",
+ ServiceWechatFiles = "[\"old.png\"]"
+ };
+
+ var repository = new Mock(MockBehavior.Strict);
+ repository
+ .Setup(x => x.FirstOrDefaultAsync(
+ It.IsAny>>(),
+ It.IsAny()))
+ .ReturnsAsync(currentEntity);
+ repository
+ .Setup(x => x.GetByWarehouseNameAsync("Updated Warehouse", It.IsAny()))
+ .ReturnsAsync((WarehouseAddress?)null);
+ repository
+ .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny()))
+ .Callback((entity, _) => updatedEntity = entity)
+ .Returns(Task.CompletedTask);
+
+ await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(repository.Object);
+ });
+
+ var response = await host.Client.PostAsJsonAsync("/system/warehouse/update", new UpdateWarehouseInput
+ {
+ Id = 18,
+ WarehouseName = "Updated Warehouse",
+ WarehouseType = WarehouseTypeCodes.Main,
+ Recipient = "Daisy",
+ ContactPhone = "13500135000",
+ ShippingAddress = "New Address",
+ ServicePhone = "0755-999999",
+ ServiceWechat = "new-wechat",
+ ServiceWechatFiles = ["new-a.png", "new-b.png"]
+ });
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ updatedEntity.Should().NotBeNull();
+ updatedEntity!.WarehouseName.Should().Be("Updated Warehouse");
+ updatedEntity.WarehouseType.Should().Be(WarehouseTypeCodes.Main);
+ updatedEntity.Recipient.Should().Be("Daisy");
+ updatedEntity.ServiceWechatFiles.Should().Be("[\"new-a.png\",\"new-b.png\"]");
+ }
+}
diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs
new file mode 100644
index 0000000..d806a39
--- /dev/null
+++ b/src/SimpleAdmin.Api/Auth/AuthService.cs
@@ -0,0 +1,723 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using Fake.Application;
+using Fake.Domain.Exceptions;
+using Fake.Helpers;
+using Fake.Rbac.Application.Dtos.Auth;
+using Fake.Rbac.Application.Dtos.Menu;
+using Fake.Rbac.Application.Dtos.Role;
+using Fake.Rbac.Application.Dtos.User;
+using Fake.Rbac.Application.Jwt;
+using Fake.Rbac.Application.Services;
+using Fake.Rbac.Domain.Managers;
+using Fake.Rbac.Domain.MenuAggregate;
+using Fake.Rbac.Domain.RoleAggregate;
+using Fake.Rbac.Domain.UserAggregate;
+using Fake.Rbac.Infrastructure;
+using Fake.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Oms.Application.Shared;
+using Oms.Domain.CustomersAggregate.Entities;
+using Oms.Domain.CustomersAggregate.Enums;
+using Oms.Domain.CustomersAggregate.Repositories;
+using Oms.Domain.Shared.Enums;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Processing;
+
+namespace SimpleAdmin.Api.Auth;
+
+///
+/// 接管 OMS 与 RBAC 共用的认证接口,补充客户登录和业务主体 claims 解析。
+///
+[Authorize]
+[ApiExplorerSettings(GroupName = "RBAC")]
+public class AuthService(
+ AccountManager accountManager,
+ IUserService userService,
+ IMenuService menuService,
+ IJwtService jwtService,
+ IUserRepository userRepository,
+ IWebHostEnvironment webHostEnvironment,
+ IHttpContextAccessor httpContextAccessor,
+ FakeRbacDbContext rbacDbContext,
+ ICustomerRepository customerRepository,
+ ICustomerSubAccountRepository customerSubAccountRepository)
+ : ApplicationService
+{
+ private const int AdminLoginType = 0;
+ private const int CustomerLoginType = 1;
+ private const string DefaultCustomerRoleCode = "Customer";
+
+ [AllowAnonymous]
+ [HttpPost("/rbac/auth/login")]
+ public async Task LoginAsync(string account, string password, int type = AdminLoginType, CancellationToken cancellationToken = default)
+ {
+ return type switch
+ {
+ AdminLoginType => await LoginAdminAsync(account, password, cancellationToken),
+ CustomerLoginType => await LoginCustomerAsync(account, password, cancellationToken),
+ _ => throw new BusinessException("不支持的登录类型")
+ };
+ }
+
+ [AllowAnonymous]
+ [HttpPost("/rbac/auth/refresh-token")]
+ public async Task RefreshTokenAsync(string refreshToken, CancellationToken cancellationToken = default)
+ {
+ var userIdText = jwtService.ValidateRefreshToken(refreshToken);
+ if (string.IsNullOrWhiteSpace(userIdText))
+ {
+ throw new BusinessException("无效的刷新令牌");
+ }
+
+ var token = new JwtSecurityTokenHandler().ReadJwtToken(refreshToken);
+ var loginType = ParseLoginType(token.Claims);
+
+ if (loginType == CustomerLoginType)
+ {
+ return await RefreshCustomerTokenAsync(token.Claims, cancellationToken);
+ }
+
+ if (!Guid.TryParse(userIdText, out var userId))
+ {
+ throw new BusinessException("无效的刷新令牌");
+ }
+
+ var user = await userRepository.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken: cancellationToken);
+ if (user == null)
+ {
+ throw new BusinessException("用户不存在");
+ }
+
+ var claims = await BuildAdminClaimsAsync(user.Id, cancellationToken);
+ return BuildLoginResult(user.Id, claims);
+ }
+
+ [HttpGet("/rbac/auth/get-current-user")]
+ public async Task GetCurrentUserAsync(CancellationToken cancellationToken = default)
+ {
+ if (!CurrentUser.IsAuthenticated)
+ {
+ throw new UnauthorizedAccessException("用户未登录");
+ }
+
+ if (IsCustomerPrincipal())
+ {
+ return await BuildCustomerUserInfoAsync(cancellationToken);
+ }
+
+ var userId = CurrentUser.Id ?? throw new UnauthorizedAccessException("用户未登录");
+ return await BuildAdminUserInfoAsync(userId, cancellationToken);
+ }
+
+ [HttpPost("/rbac/auth/change-password")]
+ public async Task ChangePasswordAsync(string oldPassword, string newPassword, CancellationToken cancellationToken = default)
+ {
+ if (IsCustomerPrincipal())
+ {
+ await ChangeCustomerPasswordAsync(oldPassword, newPassword, cancellationToken);
+ return;
+ }
+
+ var userId = CurrentUser.Id ?? throw new UnauthorizedAccessException("用户未登录");
+ await accountManager.UpdatePasswordAsync(userId, oldPassword, newPassword, cancellationToken);
+ }
+
+ [HttpPut("/rbac/auth/update-profile")]
+ public async Task UpdateProfileAsync(string? name, string? email, CancellationToken cancellationToken = default)
+ {
+ EnsureAdminPrincipal();
+
+ var userId = CurrentUser.Id ?? throw new UnauthorizedAccessException("用户未登录");
+ var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken);
+ user.Update(name, email);
+ await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
+ await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken);
+ return await BuildAdminUserInfoAsync(userId, cancellationToken);
+ }
+
+ [HttpPost("/rbac/auth/upload-avatar")]
+ public async Task UploadAvatarAsync(IFormFile file, CancellationToken cancellationToken = default)
+ {
+ EnsureAdminPrincipal();
+
+ var userId = CurrentUser.Id ?? throw new UnauthorizedAccessException("用户未登录");
+ if (file == null || file.Length == 0)
+ {
+ throw new DomainException("请选择要上传的文件");
+ }
+
+ var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" };
+ var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
+ if (!allowedExtensions.Contains(extension))
+ {
+ throw new DomainException("只支持 jpg、jpeg、png、gif、webp 格式的图片");
+ }
+
+ if (file.Length > 10 * 1024 * 1024)
+ {
+ throw new DomainException("文件大小不能超过10MB");
+ }
+
+ var uploadPath = Path.Combine(webHostEnvironment.WebRootPath ?? webHostEnvironment.ContentRootPath, "uploads", "avatars");
+ Directory.CreateDirectory(uploadPath);
+
+ var fileName = $"{userId}_{DateTime.Now:yyyyMMddHHmmss}{extension}";
+ var filePath = Path.Combine(uploadPath, fileName);
+
+ using (var stream = file.OpenReadStream())
+ using (var image = await Image.LoadAsync(stream, cancellationToken))
+ {
+ var maxSize = 200;
+ var width = image.Width;
+ var height = image.Height;
+
+ if (width > maxSize || height > maxSize)
+ {
+ if (width > height)
+ {
+ height = (int)(height * ((float)maxSize / width));
+ width = maxSize;
+ }
+ else
+ {
+ width = (int)(width * ((float)maxSize / height));
+ height = maxSize;
+ }
+ }
+
+ image.Mutate(x => x.Resize(width, height));
+ await image.SaveAsJpegAsync(filePath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
+ {
+ Quality = 80
+ }, cancellationToken);
+ }
+
+ var avatarUrl = $"/uploads/avatars/{fileName}";
+ var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken);
+ user.UpdateAvatar(avatarUrl);
+ await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
+ await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken);
+ return avatarUrl;
+ }
+
+ private async Task LoginAdminAsync(string account, string password, CancellationToken cancellationToken)
+ {
+ var user = await accountManager.ValidateCredentialsAsync(account, password, cancellationToken);
+ var claims = await BuildAdminClaimsAsync(user.Id, cancellationToken);
+ return BuildLoginResult(user.Id, claims);
+ }
+
+ private async Task LoginCustomerAsync(string account, string password, CancellationToken cancellationToken)
+ {
+ var normalizedAccount = account.Trim();
+ var customer = await FindCustomerByAccountAsync(normalizedAccount, cancellationToken);
+ if (customer != null)
+ {
+ ValidateMainCustomerStatus(customer);
+ if (!string.Equals(customer.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase))
+ {
+ throw new BusinessException("客户账号或密码错误");
+ }
+
+ var principalId = CreateStablePrincipalId($"customer:{customer.Id}");
+ customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
+ customer.LastLoginTime = DateTime.Now;
+ await customerRepository.UpdateAsync(customer, cancellationToken);
+
+ var roleCode = GetCustomerRoleCode(customer.Role);
+ var claims = BuildCustomerClaims(
+ principalId,
+ customer.Id,
+ null,
+ roleCode,
+ customer.WechatNickname ?? customer.CustomerCode);
+
+ return BuildLoginResult(principalId, claims);
+ }
+
+ var subAccount = await FindCustomerSubAccountByAccountAsync(normalizedAccount, cancellationToken);
+ if (subAccount == null)
+ {
+ throw new BusinessException("客户账号或密码错误");
+ }
+
+ if (subAccount.SubStatus != EnableStatus.Enabled)
+ {
+ throw new BusinessException("子账号已禁用");
+ }
+
+ if (!string.Equals(subAccount.SubPassword, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase))
+ {
+ throw new BusinessException("客户账号或密码错误");
+ }
+
+ customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == subAccount.MainCustomerId, cancellationToken);
+ if (customer == null)
+ {
+ throw new BusinessException("主客户不存在");
+ }
+
+ ValidateMainCustomerStatus(customer);
+
+ var subPrincipalId = CreateStablePrincipalId($"customer-sub:{subAccount.Id}");
+ var subClaims = BuildCustomerClaims(
+ subPrincipalId,
+ customer.Id,
+ subAccount.Id,
+ GetCustomerRoleCode(customer.Role),
+ subAccount.SubWechatNickname ?? subAccount.SubAccountCode);
+
+ return BuildLoginResult(subPrincipalId, subClaims);
+ }
+
+ private async Task RefreshCustomerTokenAsync(IEnumerable claims, CancellationToken cancellationToken)
+ {
+ var customerId = ParseLongClaim(claims, SimpleOmsClaimTypes.CustomerId);
+ if (customerId <= 0)
+ {
+ throw new BusinessException("无效的刷新令牌");
+ }
+
+ var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customerId, cancellationToken);
+ if (customer == null)
+ {
+ throw new BusinessException("客户不存在");
+ }
+
+ ValidateMainCustomerStatus(customer);
+
+ var subAccountId = ParseNullableLongClaim(claims, SimpleOmsClaimTypes.SubAccountId);
+ if (subAccountId.HasValue)
+ {
+ var subAccount = await customerSubAccountRepository.FirstOrDefaultAsync(x => x.Id == subAccountId.Value, cancellationToken);
+ if (subAccount == null || subAccount.MainCustomerId != customer.Id)
+ {
+ throw new BusinessException("子账号不存在");
+ }
+
+ if (subAccount.SubStatus != EnableStatus.Enabled)
+ {
+ throw new BusinessException("子账号已禁用");
+ }
+
+ var principalId = CreateStablePrincipalId($"customer-sub:{subAccount.Id}");
+ var customerClaims = BuildCustomerClaims(
+ principalId,
+ customer.Id,
+ subAccount.Id,
+ GetCustomerRoleCode(customer.Role),
+ subAccount.SubWechatNickname ?? subAccount.SubAccountCode);
+
+ return BuildLoginResult(principalId, customerClaims);
+ }
+
+ var mainPrincipalId = CreateStablePrincipalId($"customer:{customer.Id}");
+ var mainClaims = BuildCustomerClaims(
+ mainPrincipalId,
+ customer.Id,
+ null,
+ GetCustomerRoleCode(customer.Role),
+ customer.WechatNickname ?? customer.CustomerCode);
+
+ return BuildLoginResult(mainPrincipalId, mainClaims);
+ }
+
+ private async Task BuildAdminUserInfoAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ var userInfo = await userService.GetAsync(userId, cancellationToken);
+ return new UserInfoDto
+ {
+ Id = userInfo.Id,
+ Name = userInfo.Name,
+ Account = userInfo.Account,
+ Email = userInfo.Email,
+ Avatar = userInfo.Avatar,
+ OrganizationId = userInfo.OrganizationId,
+ OrganizationName = userInfo.OrganizationName,
+ Roles = userInfo.Roles,
+ Permissions = await userService.GetUserPermissionsAsync(userId, cancellationToken),
+ Menus = await menuService.GetUserMenusAsync(userId, cancellationToken),
+ CreatedAt = userInfo.CreatedAt,
+ CreatedBy = userInfo.CreatedBy,
+ UpdatedAt = userInfo.UpdatedAt,
+ UpdatedBy = userInfo.UpdatedBy
+ };
+ }
+
+ private async Task BuildCustomerUserInfoAsync(CancellationToken cancellationToken)
+ {
+ var context = await GetCurrentCustomerContextAsync(cancellationToken);
+ var roleBundle = await GetRoleBundleAsync(context.RoleCode, cancellationToken);
+
+ return new UserInfoDto
+ {
+ Id = context.PrincipalId,
+ Name = context.DisplayName,
+ Account = context.AccountCode,
+ Email = context.Email,
+ Avatar = context.Avatar,
+ Roles = roleBundle.Role == null
+ ? [new RoleSimpleDto { Id = Guid.Empty, Code = context.RoleCode, Name = context.RoleCode }]
+ : [new RoleSimpleDto { Id = roleBundle.Role.Id, Code = roleBundle.Role.Code, Name = roleBundle.Role.Name }],
+ Permissions = roleBundle.PermissionCodes,
+ Menus = roleBundle.Menus,
+ CreatedAt = context.CreateTime,
+ UpdatedAt = context.UpdateTime == default ? null : context.UpdateTime
+ };
+ }
+
+ private async Task ChangeCustomerPasswordAsync(string oldPassword, string newPassword, CancellationToken cancellationToken)
+ {
+ accountManager.ValidatePasswordStrength(newPassword);
+ var context = await GetCurrentCustomerContextAsync(cancellationToken);
+
+ if (context.SubAccount != null)
+ {
+ if (!string.Equals(context.SubAccount.SubPassword, OmsPasswordHelper.ToMd5(oldPassword), StringComparison.OrdinalIgnoreCase))
+ {
+ throw new BusinessException("旧密码不正确");
+ }
+
+ context.SubAccount.SubPassword = OmsPasswordHelper.ToMd5(newPassword);
+ await customerSubAccountRepository.UpdateAsync(context.SubAccount, cancellationToken);
+ return;
+ }
+
+ if (!string.Equals(context.Customer.Password, OmsPasswordHelper.ToMd5(oldPassword), StringComparison.OrdinalIgnoreCase))
+ {
+ throw new BusinessException("旧密码不正确");
+ }
+
+ context.Customer.Password = OmsPasswordHelper.ToMd5(newPassword);
+ await customerRepository.UpdateAsync(context.Customer, cancellationToken);
+ }
+
+ private async Task> BuildAdminClaimsAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ var claims = await jwtService.GenerateClaimsByUserIdAsync(userId, cancellationToken);
+ ReplaceOrAppendClaim(claims, SimpleOmsClaimTypes.LoginType, AdminLoginType.ToString());
+ return claims;
+ }
+
+ private List BuildCustomerClaims(Guid principalId, long customerId, long? subAccountId, string roleCode, string userName)
+ {
+ var claims = new List
+ {
+ new(FakeClaimTypes.UserId, principalId.ToString()),
+ new(ClaimTypes.NameIdentifier, principalId.ToString()),
+ new(FakeClaimTypes.UserName, userName),
+ new(FakeClaimTypes.Role, roleCode),
+ new(SimpleOmsClaimTypes.MapId, customerId.ToString()),
+ new(SimpleOmsClaimTypes.LoginType, CustomerLoginType.ToString()),
+ new(SimpleOmsClaimTypes.CustomerId, customerId.ToString())
+ };
+
+ if (subAccountId.HasValue)
+ {
+ claims.Add(new Claim(SimpleOmsClaimTypes.SubAccountId, subAccountId.Value.ToString()));
+ }
+
+ return claims;
+ }
+
+ private LoginResultDto BuildLoginResult(Guid userId, IReadOnlyCollection claims)
+ {
+ var accessClaims = CloneClaims(claims);
+ var refreshClaims = CloneClaims(claims);
+
+ return new LoginResultDto
+ {
+ AccessToken = jwtService.GenerateAccessToken(accessClaims),
+ RefreshToken = jwtService.GenerateRefreshToken(refreshClaims),
+ TokenType = "Bearer",
+ ExpiresIn = jwtService.GetExpiresInSeconds(),
+ UserId = userId
+ };
+ }
+
+ private static List CloneClaims(IEnumerable claims)
+ {
+ return claims.Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer, x.OriginalIssuer)).ToList();
+ }
+
+ private bool IsCustomerPrincipal()
+ {
+ return CurrentUser.FindClaimOrNull(SimpleOmsClaimTypes.LoginType)?.Value == CustomerLoginType.ToString() ||
+ CurrentUser.FindClaimOrNull(SimpleOmsClaimTypes.CustomerId) != null;
+ }
+
+ private void EnsureAdminPrincipal()
+ {
+ if (IsCustomerPrincipal())
+ {
+ throw new BusinessException("当前登录主体暂不支持此操作");
+ }
+ }
+
+ private async Task GetCurrentCustomerContextAsync(CancellationToken cancellationToken)
+ {
+ var customerId = CurrentUser.GetId();
+ if (customerId <= 0)
+ {
+ throw new UnauthorizedAccessException("客户未登录");
+ }
+
+ var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customerId, cancellationToken);
+ if (customer == null)
+ {
+ throw new BusinessException("客户不存在");
+ }
+
+ ValidateMainCustomerStatus(customer);
+
+ var subAccountId = ParseNullableLongClaim(CurrentUser.FindClaims(SimpleOmsClaimTypes.SubAccountId), SimpleOmsClaimTypes.SubAccountId);
+ CustomerSubAccount? subAccount = null;
+ if (subAccountId.HasValue)
+ {
+ subAccount = await customerSubAccountRepository.FirstOrDefaultAsync(x => x.Id == subAccountId.Value, cancellationToken);
+ if (subAccount == null || subAccount.MainCustomerId != customer.Id)
+ {
+ throw new BusinessException("子账号不存在");
+ }
+
+ if (subAccount.SubStatus != EnableStatus.Enabled)
+ {
+ throw new BusinessException("子账号已禁用");
+ }
+ }
+
+ return new CustomerPrincipalContext
+ {
+ PrincipalId = CurrentUser.Id ?? CreateStablePrincipalId(subAccount == null ? $"customer:{customer.Id}" : $"customer-sub:{subAccount.Id}"),
+ Customer = customer,
+ SubAccount = subAccount,
+ RoleCode = GetCustomerRoleCode(customer.Role),
+ DisplayName = subAccount?.SubWechatNickname ?? customer.WechatNickname ?? subAccount?.SubAccountCode ?? customer.CustomerCode,
+ AccountCode = subAccount?.SubAccountCode ?? customer.CustomerCode,
+ Email = subAccount?.SubEmail ?? customer.Email,
+ Avatar = subAccount?.SubWechatAvatar ?? customer.WechatAvatar,
+ CreateTime = subAccount?.CreateTime ?? customer.CreateTime,
+ UpdateTime = subAccount?.UpdateTime ?? customer.UpdateTime
+ };
+ }
+
+ private async Task FindCustomerByAccountAsync(string account, CancellationToken cancellationToken)
+ {
+ var customer = await customerRepository.GetByPhoneAsync(account, cancellationToken);
+ if (customer != null)
+ {
+ return customer;
+ }
+
+ customer = await customerRepository.GetByEmailAsync(account, cancellationToken);
+ if (customer != null)
+ {
+ return customer;
+ }
+
+ return await customerRepository.GetByCustomerCodeAsync(account, cancellationToken);
+ }
+
+ private async Task FindCustomerSubAccountByAccountAsync(string account, CancellationToken cancellationToken)
+ {
+ var subAccount = await customerSubAccountRepository.GetBySubPhoneAsync(account, cancellationToken);
+ if (subAccount != null)
+ {
+ return subAccount;
+ }
+
+ subAccount = await customerSubAccountRepository.GetBySubEmailAsync(account, cancellationToken);
+ if (subAccount != null)
+ {
+ return subAccount;
+ }
+
+ return await customerSubAccountRepository.GetBySubAccountCodeAsync(account, cancellationToken);
+ }
+
+ private async Task GetRoleBundleAsync(string roleCode, CancellationToken cancellationToken)
+ {
+ var role = await rbacDbContext.Roles
+ .Include(x => x.Permissions)
+ .FirstOrDefaultAsync(x => x.Code == roleCode && !x.IsDeleted, cancellationToken);
+
+ if (role == null)
+ {
+ return new CustomerRoleBundle
+ {
+ Menus = [],
+ PermissionCodes = []
+ };
+ }
+
+ var permissionCodes = role.Permissions
+ .Select(x => x.PermissionCode)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var menus = await BuildMenusByPermissionsAsync(permissionCodes, cancellationToken);
+ return new CustomerRoleBundle
+ {
+ Role = role,
+ PermissionCodes = permissionCodes,
+ Menus = menus
+ };
+ }
+
+ private async Task> BuildMenusByPermissionsAsync(IReadOnlyCollection permissionCodes, CancellationToken cancellationToken)
+ {
+ if (permissionCodes.Count == 0)
+ {
+ return [];
+ }
+
+ var allMenus = await rbacDbContext.Menus
+ .Where(x => !x.IsDeleted)
+ .OrderBy(x => x.Order)
+ .ToListAsync(cancellationToken);
+
+ var menuMap = allMenus.ToDictionary(x => x.Id);
+ var selectedMenuIds = new HashSet(allMenus
+ .Where(x => !string.IsNullOrWhiteSpace(x.PermissionCode) && permissionCodes.Contains(x.PermissionCode, StringComparer.OrdinalIgnoreCase))
+ .Select(x => x.Id));
+
+ var queue = new Queue(selectedMenuIds);
+ while (queue.Count > 0)
+ {
+ var menuId = queue.Dequeue();
+ if (!menuMap.TryGetValue(menuId, out var currentMenu))
+ {
+ continue;
+ }
+
+ if (currentMenu.PId != Guid.Empty && selectedMenuIds.Add(currentMenu.PId))
+ {
+ queue.Enqueue(currentMenu.PId);
+ }
+ }
+
+ var menuDtos = allMenus
+ .Where(x => selectedMenuIds.Contains(x.Id))
+ .OrderBy(x => x.Order)
+ .ThenBy(x => x.CreateTime)
+ .Select(x => new MenuTreeDto
+ {
+ Id = x.Id,
+ PId = x.PId,
+ Name = x.Name,
+ PermissionCode = x.PermissionCode,
+ Type = x.Type,
+ Icon = x.Icon,
+ Route = x.Route,
+ Component = x.Component,
+ Order = x.Order,
+ IsHidden = x.IsHidden,
+ IsCached = x.IsCached,
+ Description = x.Description,
+ CreatedAt = x.CreateTime,
+ UpdatedAt = x.UpdateTime == default ? null : x.UpdateTime
+ })
+ .ToList();
+
+ var dtoMap = menuDtos.ToDictionary(x => x.Id);
+ var roots = new List();
+ foreach (var menu in menuDtos)
+ {
+ if (menu.PId == Guid.Empty || !dtoMap.TryGetValue(menu.PId, out var parentMenu))
+ {
+ roots.Add(menu);
+ continue;
+ }
+
+ parentMenu.Children.Add(menu);
+ }
+
+ return roots.OrderBy(x => x.Order).ToList();
+ }
+
+ private static void ValidateMainCustomerStatus(Customer customer)
+ {
+ if (customer.Status == CustomerStatus.Disabled)
+ {
+ throw new BusinessException("客户已禁用");
+ }
+
+ if (customer.Status == CustomerStatus.Frozen)
+ {
+ throw new BusinessException("客户已冻结");
+ }
+ }
+
+ private static string GetCustomerRoleCode(string? roleCode)
+ {
+ return string.IsNullOrWhiteSpace(roleCode) ? DefaultCustomerRoleCode : roleCode.Trim();
+ }
+
+ private static Guid CreateStablePrincipalId(string key)
+ {
+ var hash = MD5.HashData(Encoding.UTF8.GetBytes(key));
+ return new Guid(hash);
+ }
+
+ private static int ParseLoginType(IEnumerable claims)
+ {
+ return int.TryParse(claims.FirstOrDefault(x => x.Type == SimpleOmsClaimTypes.LoginType)?.Value, out var loginType)
+ ? loginType
+ : AdminLoginType;
+ }
+
+ private static long ParseLongClaim(IEnumerable claims, string claimType)
+ {
+ return long.TryParse(claims.FirstOrDefault(x => x.Type == claimType)?.Value, out var value) ? value : 0;
+ }
+
+ private static long? ParseNullableLongClaim(IEnumerable claims, string claimType)
+ {
+ return long.TryParse(claims.FirstOrDefault(x => x.Type == claimType)?.Value, out var value) ? value : null;
+ }
+
+ private static void ReplaceOrAppendClaim(List claims, string claimType, string value)
+ {
+ claims.RemoveAll(x => x.Type == claimType);
+ claims.Add(new Claim(claimType, value));
+ }
+
+ private sealed class CustomerPrincipalContext
+ {
+ public Guid PrincipalId { get; set; }
+
+ public required Customer Customer { get; init; }
+
+ public CustomerSubAccount? SubAccount { get; init; }
+
+ public required string RoleCode { get; init; }
+
+ public required string DisplayName { get; init; }
+
+ public required string AccountCode { get; init; }
+
+ public string? Email { get; init; }
+
+ public string? Avatar { get; init; }
+
+ public DateTime CreateTime { get; init; }
+
+ public DateTime UpdateTime { get; init; }
+ }
+
+ private sealed class CustomerRoleBundle
+ {
+ public Role? Role { get; init; }
+
+ public List PermissionCodes { get; init; } = [];
+
+ public List Menus { get; init; } = [];
+ }
+}
diff --git a/src/SimpleAdmin.Api/Program.cs b/src/SimpleAdmin.Api/Program.cs
index 386aaa7..c76523e 100644
--- a/src/SimpleAdmin.Api/Program.cs
+++ b/src/SimpleAdmin.Api/Program.cs
@@ -1,21 +1,10 @@
using Serilog;
using SimpleAdmin.Api;
-var builder = WebApplication.CreateSlimBuilder(args);
-builder.Configuration.AddConsul("idwms/common", source => source.ReloadOnChange = true);
-builder.Configuration.AddConsul("idwms/admin", source => source.ReloadOnChange = true);
-
-Log.Logger = new LoggerConfiguration()
- .ReadFrom.Configuration(builder.Configuration)
- .CreateLogger();
-
try
{
- builder.WebHost.UseUrls(builder.Configuration.GetSection("App:Urls").Get() ?? []);
- builder.Host.UseAutofac().UseSerilog();
- builder.Services.AddApplication();
- var app = builder.Build();
- app.InitializeApplication();
+ var builder = SimpleAdminApiBootstrap.CreateBuilder(args);
+ var app = SimpleAdminApiBootstrap.BuildApplication(builder);
await app.RunAsync();
}
catch (Exception ex)
@@ -25,4 +14,6 @@ catch (Exception ex)
finally
{
Log.CloseAndFlush();
-}
\ No newline at end of file
+}
+
+public partial class Program;
diff --git a/src/SimpleAdmin.Api/SimpleAdminApiBootstrap.cs b/src/SimpleAdmin.Api/SimpleAdminApiBootstrap.cs
new file mode 100644
index 0000000..1131c4b
--- /dev/null
+++ b/src/SimpleAdmin.Api/SimpleAdminApiBootstrap.cs
@@ -0,0 +1,54 @@
+using Serilog;
+
+namespace SimpleAdmin.Api;
+
+///
+/// 为正式启动与集成测试提供统一的应用构建入口。
+///
+public static class SimpleAdminApiBootstrap
+{
+ ///
+ /// 创建并配置 API 应用构建器。
+ ///
+ /// 启动参数。
+ /// 附加构建配置,主要供测试环境注入本地配置和测试服务器使用。
+ public static WebApplicationBuilder CreateBuilder(
+ string[] args,
+ Action? configureBuilder = null,
+ string? environmentName = null)
+ {
+ var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
+ {
+ Args = args,
+ EnvironmentName = environmentName,
+ ApplicationName = typeof(SimpleAdminApiModule).Assembly.GetName().Name
+ });
+ configureBuilder?.Invoke(builder);
+
+ if (!builder.Configuration.GetValue("UseLocalConfigs"))
+ {
+ builder.Configuration.AddConsul("idwms/common", source => source.ReloadOnChange = true);
+ builder.Configuration.AddConsul("idwms/admin", source => source.ReloadOnChange = true);
+ }
+
+ Log.Logger = new LoggerConfiguration()
+ .ReadFrom.Configuration(builder.Configuration)
+ .CreateLogger();
+
+ builder.WebHost.UseUrls(builder.Configuration.GetSection("App:Urls").Get() ?? []);
+ builder.Host.UseAutofac().UseSerilog();
+ builder.Services.AddApplication();
+
+ return builder;
+ }
+
+ ///
+ /// 根据构建器生成并初始化 WebApplication。
+ ///
+ public static WebApplication BuildApplication(WebApplicationBuilder builder)
+ {
+ var app = builder.Build();
+ app.InitializeApplication();
+ return app;
+ }
+}
diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
index c36f82e..0be6f83 100644
--- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
+++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
@@ -8,8 +8,10 @@ using Fake.AspNetCore.Mvc;
using Fake.FeiShu;
using Fake.Serilog;
using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.Extensions.Logging;
using Oms.Application;
using Oms.SqlSugarCore;
+using SimpleAdmin.Api.Auth;
namespace SimpleAdmin.Api;
@@ -34,9 +36,15 @@ public class SimpleAdminApiModule : FakeModule
services.Configure(options =>
{
options.ApplicationServices2Controller();
+ options.ApplicationServices2Controller(settings =>
+ {
+ settings.RootPath = "rbac";
+ settings.TypePredicate = type => type == typeof(AuthService);
+ });
options.ApplicationServices2Controller(settings =>
{
settings.RootPath = "rbac";
+ settings.TypePredicate = type => type != typeof(Fake.Rbac.Application.Services.AuthService);
});
});
@@ -97,6 +105,21 @@ public class SimpleAdminApiModule : FakeModule
// 端点映射
app.MapControllers();
- app.Services.GetRequiredService().Send("Application started");
+ var shouldSendStartupNotification =
+ !app.Environment.IsEnvironment("Testing") &&
+ app.Configuration.GetValue("App:SendStartupNotification", true);
+ if (!shouldSendStartupNotification)
+ {
+ return;
+ }
+
+ try
+ {
+ app.Services.GetRequiredService().Send("Application started");
+ }
+ catch (Exception ex)
+ {
+ app.Logger.LogWarning(ex, "Failed to send startup notification.");
+ }
}
}
diff --git a/src/SimpleAdmin.Api/SimpleCurrentUser.cs b/src/SimpleAdmin.Api/SimpleCurrentUser.cs
new file mode 100644
index 0000000..47e7c27
--- /dev/null
+++ b/src/SimpleAdmin.Api/SimpleCurrentUser.cs
@@ -0,0 +1,54 @@
+using Fake.DependencyInjection;
+using Fake.Security.Claims;
+using Fake.Users;
+
+namespace SimpleAdmin.Api;
+
+///
+/// 扩展当前用户解析逻辑,支持从 OMS 业务 claims 中读取数值型主体标识。
+///
+[Dependency(Replace = true)]
+public class SimpleCurrentUser(ICurrentPrincipalAccessor currentPrincipalAccessor) : CurrentUser(currentPrincipalAccessor)
+{
+ public override T GetId()
+ {
+ if (typeof(T) == typeof(long))
+ {
+ var userId = base.GetId();
+ if (userId != 0)
+ {
+ return (T)(object)userId;
+ }
+
+ return (T)(object)GetFallbackNumericId();
+ }
+
+ if (typeof(T) == typeof(int))
+ {
+ var userId = base.GetId();
+ if (userId != 0)
+ {
+ return (T)(object)userId;
+ }
+
+ return (T)(object)(int)GetFallbackNumericId();
+ }
+
+ return base.GetId()!;
+ }
+
+ private long GetFallbackNumericId()
+ {
+ if (long.TryParse(FindClaimOrNull(SimpleOmsClaimTypes.MapId)?.Value, out var mapId) && mapId > 0)
+ {
+ return mapId;
+ }
+
+ if (long.TryParse(FindClaimOrNull(SimpleOmsClaimTypes.CustomerId)?.Value, out var customerId) && customerId > 0)
+ {
+ return customerId;
+ }
+
+ return 0;
+ }
+}
diff --git a/src/SimpleAdmin.Api/SimpleJwtService.cs b/src/SimpleAdmin.Api/SimpleJwtService.cs
index 0f2149f..1f392e0 100644
--- a/src/SimpleAdmin.Api/SimpleJwtService.cs
+++ b/src/SimpleAdmin.Api/SimpleJwtService.cs
@@ -20,9 +20,17 @@ public class SimpleJwtService(IOptions jwtOptions, IUserService user
CancellationToken cancellationToken)
{
var claims = await base.GenerateClaimsByUserIdAsync(userId, cancellationToken);
- var mapId = await rbacDbContext.Database.SqlQuery($"select userid as value from user where id = {userId}")
- .FirstAsync(cancellationToken);
- claims.Add(new Claim("mapId", mapId.ToString()));
+ var mapId = await rbacDbContext.Database.SqlQuery(
+ $"select `userid` as `Value` from `user` where `Id` = {userId}")
+ .FirstOrDefaultAsync(cancellationToken);
+ if (mapId.HasValue)
+ {
+ claims.RemoveAll(x => x.Type == SimpleOmsClaimTypes.MapId);
+ claims.Add(new Claim(SimpleOmsClaimTypes.MapId, mapId.Value.ToString()));
+ }
+
+ claims.RemoveAll(x => x.Type == SimpleOmsClaimTypes.LoginType);
+ claims.Add(new Claim(SimpleOmsClaimTypes.LoginType, "0"));
return claims;
}
-}
\ No newline at end of file
+}
diff --git a/src/SimpleAdmin.Api/SimpleOmsClaimTypes.cs b/src/SimpleAdmin.Api/SimpleOmsClaimTypes.cs
new file mode 100644
index 0000000..7462564
--- /dev/null
+++ b/src/SimpleAdmin.Api/SimpleOmsClaimTypes.cs
@@ -0,0 +1,9 @@
+namespace SimpleAdmin.Api;
+
+internal static class SimpleOmsClaimTypes
+{
+ public const string MapId = "mapId";
+ public const string LoginType = "loginType";
+ public const string CustomerId = "customerId";
+ public const string SubAccountId = "subAccountId";
+}