feat: new
This commit is contained in:
parent
6458ab89ba
commit
96724376a6
@ -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
|
||||
|
||||
@ -11,9 +11,13 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIEntity_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa1494e50f25d476db4fff59a546c2b857800_003F59_003F8ca0c929_003FIEntity_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIRepository_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6015bad29db142679ead208ae4ecd55c7800_003F85_003Fa6db724e_003FIRepository_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIRepository_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa1494e50f25d476db4fff59a546c2b857800_003Fcc_003F73f30804_003FIRepository_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AObjectMethodExecutorAwaitable_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F61426bb2f5bd47ebb048d0e7051942f91de910_003Fe1_003F409ba3a2_003FObjectMethodExecutorAwaitable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryBuilder_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003Fc7_003F431a0b15_003FQueryBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReflectionInoCacheService_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003Fbb_003F446201ec_003FReflectionInoCacheService_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReflectionInoCore_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003Fc1_003F8f35c8ed_003FReflectionInoCore_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASqlBuilderProvider_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003F71_003F77c8fbe4_003FSqlBuilderProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASqlSugarClient_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003Fe8_003Ffcf6435d_003FSqlSugarClient_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASqlSugarProvider_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003F26_003Fce9a7a77_003FSqlSugarProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASqlSugarProvider_002Ecs_002Fl_003AC_0021_003FUsers_003FBSI_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F38bf349295324397b2c739e7e632b4bd2d5000_003F26_003Fce9a7a77_003FSqlSugarProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=9c7e285e_002D68dc_002D4e8e_002D88cb_002De8df37de5da5/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &lt;src&gt;\&lt;SimpleAdmin.Api.IntegrationTests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||
<Project Location="D:\my\itfx\admin\src\SimpleAdmin.Api.IntegrationTests" Presentation="&lt;src&gt;\&lt;SimpleAdmin.Api.IntegrationTests&gt;" />
|
||||
</SessionState></s:String></wpf:ResourceDictionary>
|
||||
154
db.sql
154
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 '更新人',
|
||||
|
||||
41
db.sync.sql
41
db.sync.sql
@ -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='订单导入任务失败明细表';
|
||||
5933
oms表结构与接口设计20260427.md
Normal file
5933
oms表结构与接口设计20260427.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -50,6 +50,11 @@ public class SalesProductDetailOutput
|
||||
/// </summary>
|
||||
public long? RemoteAreaId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 价格分区主键。
|
||||
/// </summary>
|
||||
public long? PriceAreaId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 计重规则主键。
|
||||
/// </summary>
|
||||
|
||||
@ -50,6 +50,11 @@ public class SalesProductListOutput
|
||||
/// </summary>
|
||||
public long? RemoteAreaId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 价格分区主键。
|
||||
/// </summary>
|
||||
public long? PriceAreaId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 计重规则主键。
|
||||
/// </summary>
|
||||
|
||||
@ -52,6 +52,11 @@ public class SalesProductSaveInput
|
||||
/// </summary>
|
||||
public long? RemoteAreaId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 价格分区主键。
|
||||
/// </summary>
|
||||
public long? PriceAreaId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 计重规则主键。
|
||||
/// </summary>
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// 服务渠道分组保存入参。
|
||||
/// </summary>
|
||||
public class ServiceChannelGroupSaveInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组主键,新增时可不传。
|
||||
/// </summary>
|
||||
public long? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
/// <summary>
|
||||
/// 分组名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 业务应用ID。
|
||||
/// </summary>
|
||||
public long AppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// API服务商ID。
|
||||
/// </summary>
|
||||
public long ApiServiceProviderId { get; set; }
|
||||
}
|
||||
@ -8,58 +8,102 @@ namespace Oms.Application.Dtos.Customers;
|
||||
/// </summary>
|
||||
public class CreateCustomerInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户编号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(16)]
|
||||
[StringLength(32)]
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(16)]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
[EmailAddress]
|
||||
[StringLength(64)]
|
||||
/// <summary>
|
||||
/// 邮箱。
|
||||
/// </summary>
|
||||
[EmailAddress]
|
||||
[StringLength(64)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Required]
|
||||
/// <summary>
|
||||
/// 微信头像地址。
|
||||
/// </summary>
|
||||
[StringLength(255)]
|
||||
public string? WechatAvatar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信昵称。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? WechatNickname { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 密码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账户余额。
|
||||
/// </summary>
|
||||
[Range(typeof(decimal), "0", "999999999999")]
|
||||
public decimal Balance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总额度。
|
||||
/// </summary>
|
||||
[Range(typeof(decimal), "0", "999999999999")]
|
||||
public decimal TotalQuota { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已用额度。
|
||||
/// </summary>
|
||||
[Range(typeof(decimal), "0", "999999999999")]
|
||||
public decimal UsedQuota { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public CustomerStatus Status { get; set; } = CustomerStatus.Normal;
|
||||
|
||||
[StringLength(512)]
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(512)]
|
||||
public string? Remark { get; set; }
|
||||
|
||||
[StringLength(32)]
|
||||
/// <summary>
|
||||
/// 销售人员。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Salesman { get; set; }
|
||||
|
||||
[StringLength(32)]
|
||||
/// <summary>
|
||||
/// 客服人员。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? CustomerService { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户分组。
|
||||
/// </summary>
|
||||
public CustomerGroup? CustomerGroup { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色编码,控制客户登录后的菜单权限。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
|
||||
@ -17,6 +17,11 @@ public class CustomerOutput
|
||||
/// </summary>
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// WechatAvatar。
|
||||
/// </summary>
|
||||
|
||||
@ -17,6 +17,11 @@ public class CustomerPaginatedOutput
|
||||
/// </summary>
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// WechatAvatar。
|
||||
/// </summary>
|
||||
@ -77,6 +82,11 @@ public class CustomerPaginatedOutput
|
||||
/// </summary>
|
||||
public CustomerGroup? CustomerGroup { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色编码。
|
||||
/// </summary>
|
||||
public string? Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后登录 IP。
|
||||
/// </summary>
|
||||
|
||||
@ -8,50 +8,101 @@ namespace Oms.Application.Dtos.Customers;
|
||||
/// </summary>
|
||||
public class UpdateCustomerInput
|
||||
{
|
||||
[Required]
|
||||
/// <summary>
|
||||
/// 主键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long Id { get; set; }
|
||||
|
||||
[EmailAddress]
|
||||
[StringLength(64)]
|
||||
/// <summary>
|
||||
/// 客户编号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(16)]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱。
|
||||
/// </summary>
|
||||
[EmailAddress]
|
||||
[StringLength(64)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信头像地址。
|
||||
/// </summary>
|
||||
[StringLength(255)]
|
||||
public string? WechatAvatar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信昵称。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? WechatNickname { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账户余额。
|
||||
/// </summary>
|
||||
[Range(typeof(decimal), "0", "999999999999")]
|
||||
public decimal Balance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总额度。
|
||||
/// </summary>
|
||||
[Range(typeof(decimal), "0", "999999999999")]
|
||||
public decimal TotalQuota { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已用额度。
|
||||
/// </summary>
|
||||
[Range(typeof(decimal), "0", "999999999999")]
|
||||
public decimal UsedQuota { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public CustomerStatus Status { get; set; } = CustomerStatus.Normal;
|
||||
|
||||
[StringLength(512)]
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(512)]
|
||||
public string? Remark { get; set; }
|
||||
|
||||
[StringLength(32)]
|
||||
/// <summary>
|
||||
/// 销售人员。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Salesman { get; set; }
|
||||
|
||||
[StringLength(32)]
|
||||
/// <summary>
|
||||
/// 客服人员。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? CustomerService { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户分组。
|
||||
/// </summary>
|
||||
public CustomerGroup? CustomerGroup { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色编码,控制客户登录后的菜单权限。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 批量创建小包入参,数组内每项与单票创建请求结构一致。
|
||||
/// </summary>
|
||||
public class OrderParcelBatchCreateInput : List<OrderParcelCreateInput>
|
||||
{
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 批量创建小包单条处理结果。
|
||||
/// </summary>
|
||||
public class OrderParcelBatchCreateItemOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 原始请求序号,从 1 开始。
|
||||
/// </summary>
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 幂等请求号。
|
||||
/// </summary>
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户单号。
|
||||
/// </summary>
|
||||
public string? ClientNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 运单号。
|
||||
/// </summary>
|
||||
public string? ServiceNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 跟踪号。
|
||||
/// </summary>
|
||||
public string? TrackNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 面单地址。
|
||||
/// </summary>
|
||||
public string? LabelUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否成功。
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 处理说明。
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 批量创建小包出参。
|
||||
/// </summary>
|
||||
public class OrderParcelBatchCreateOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 本次批量处理批次号。
|
||||
/// </summary>
|
||||
public string BatchNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 成功数量。
|
||||
/// </summary>
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败数量。
|
||||
/// </summary>
|
||||
public int FailCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 逐条处理结果。
|
||||
/// </summary>
|
||||
public List<OrderParcelBatchCreateItemOutput> Items { get; set; } = [];
|
||||
}
|
||||
27
src/Oms.Application/Dtos/System/CountryOutput.cs
Normal file
27
src/Oms.Application/Dtos/System/CountryOutput.cs
Normal file
@ -0,0 +1,27 @@
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 国家列表项出参。
|
||||
/// </summary>
|
||||
public class CountryOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 主键。
|
||||
/// </summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 国家中文名。
|
||||
/// </summary>
|
||||
public string ChineseName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 国家英文名。
|
||||
/// </summary>
|
||||
public string EnglishName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// ISO 3166-1 alpha-2 国家二字码。
|
||||
/// </summary>
|
||||
public string CountryCode { get; set; } = string.Empty;
|
||||
}
|
||||
35
src/Oms.Application/Dtos/System/DictCreateInput.cs
Normal file
35
src/Oms.Application/Dtos/System/DictCreateInput.cs
Normal file
@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 新增字典入参。
|
||||
/// </summary>
|
||||
public class DictCreateInput
|
||||
{
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
/// <summary>
|
||||
/// 字典类型。
|
||||
/// </summary>
|
||||
public string DictType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int DictSort { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
/// <summary>
|
||||
/// 字典编码。
|
||||
/// </summary>
|
||||
public string DictCode { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
/// <summary>
|
||||
/// 字典名称。
|
||||
/// </summary>
|
||||
public string DictName { get; set; } = string.Empty;
|
||||
}
|
||||
27
src/Oms.Application/Dtos/System/DictListInput.cs
Normal file
27
src/Oms.Application/Dtos/System/DictListInput.cs
Normal file
@ -0,0 +1,27 @@
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 字典分页查询入参。
|
||||
/// </summary>
|
||||
public class DictListInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码,从 1 开始。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 字典类型。
|
||||
/// </summary>
|
||||
public string? DictType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词,匹配编码或名称。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
42
src/Oms.Application/Dtos/System/DictOutput.cs
Normal file
42
src/Oms.Application/Dtos/System/DictOutput.cs
Normal file
@ -0,0 +1,42 @@
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 字典列表项出参。
|
||||
/// </summary>
|
||||
public class DictOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 主键。
|
||||
/// </summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典类型。
|
||||
/// </summary>
|
||||
public string DictType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int DictSort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典编码。
|
||||
/// </summary>
|
||||
public string DictCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 字典名称。
|
||||
/// </summary>
|
||||
public string DictName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdateTime { get; set; }
|
||||
}
|
||||
15
src/Oms.Application/Dtos/System/DictUpdateInput.cs
Normal file
15
src/Oms.Application/Dtos/System/DictUpdateInput.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典入参。
|
||||
/// </summary>
|
||||
public class DictUpdateInput : DictCreateInput
|
||||
{
|
||||
[Required]
|
||||
/// <summary>
|
||||
/// 字典主键。
|
||||
/// </summary>
|
||||
public long Id { get; set; }
|
||||
}
|
||||
46
src/Oms.Application/Dtos/System/NoticeCreateInput.cs
Normal file
46
src/Oms.Application/Dtos/System/NoticeCreateInput.cs
Normal file
@ -0,0 +1,46 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 新增通知入参。
|
||||
/// </summary>
|
||||
public class NoticeCreateInput
|
||||
{
|
||||
[Required]
|
||||
[StringLength(16)]
|
||||
/// <summary>
|
||||
/// 通知类型:notice/message。
|
||||
/// </summary>
|
||||
public string NoticeType { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
/// <summary>
|
||||
/// 正文内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否弹窗:0-否,1-是。
|
||||
/// </summary>
|
||||
public byte IsPopup { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(16)]
|
||||
/// <summary>
|
||||
/// 接收对象类型:user/customer。
|
||||
/// </summary>
|
||||
public string ReceiverType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收主体 ID 列表。
|
||||
/// </summary>
|
||||
public List<long> ReceiverIds { get; set; } = [];
|
||||
}
|
||||
32
src/Oms.Application/Dtos/System/NoticeListInput.cs
Normal file
32
src/Oms.Application/Dtos/System/NoticeListInput.cs
Normal file
@ -0,0 +1,32 @@
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 通知分页查询入参。
|
||||
/// </summary>
|
||||
public class NoticeListInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码,从 1 开始。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 通知类型。
|
||||
/// </summary>
|
||||
public string? NoticeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收对象类型。
|
||||
/// </summary>
|
||||
public string? ReceiverType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词,匹配标题或内容。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
72
src/Oms.Application/Dtos/System/NoticeOutput.cs
Normal file
72
src/Oms.Application/Dtos/System/NoticeOutput.cs
Normal file
@ -0,0 +1,72 @@
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 通知列表项出参。
|
||||
/// </summary>
|
||||
public class NoticeOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 主键。
|
||||
/// </summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知类型。
|
||||
/// </summary>
|
||||
public string NoticeType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 通知类型中文名。
|
||||
/// </summary>
|
||||
public string NoticeTypeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 正文内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否弹窗:0-否,1-是。
|
||||
/// </summary>
|
||||
public byte IsPopup { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收对象类型。
|
||||
/// </summary>
|
||||
public string ReceiverType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收对象类型中文名。
|
||||
/// </summary>
|
||||
public string ReceiverTypeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收主体 ID 列表。
|
||||
/// </summary>
|
||||
public List<long> ReceiverIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 已读人数。
|
||||
/// </summary>
|
||||
public long ReadCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收总人数。
|
||||
/// </summary>
|
||||
public int ReceiverCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdateTime { get; set; }
|
||||
}
|
||||
15
src/Oms.Application/Dtos/System/NoticeUpdateInput.cs
Normal file
15
src/Oms.Application/Dtos/System/NoticeUpdateInput.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.System;
|
||||
|
||||
/// <summary>
|
||||
/// 更新通知入参。
|
||||
/// </summary>
|
||||
public class NoticeUpdateInput : NoticeCreateInput
|
||||
{
|
||||
[Required]
|
||||
/// <summary>
|
||||
/// 通知主键。
|
||||
/// </summary>
|
||||
public long Id { get; set; }
|
||||
}
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Fake.ObjectMapping.AutoMapper" Version="8.0.5.11" />
|
||||
<PackageReference Include="Fake.Rbac.Infrastructure" Version="8.0.5.11" />
|
||||
<PackageReference Include="MiniExcel" Version="1.43.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -135,6 +135,7 @@ public partial class SalesProductService : ApplicationService
|
||||
/// 2. 按入参更新主体数据和关联配置。
|
||||
/// 3. 写入更新审计字段并保存结果。
|
||||
/// </remarks>
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[HttpPost("/channel/sales-product/config/save")]
|
||||
public async Task SaveConfigAsync(SalesProductSaveInput input)
|
||||
{
|
||||
|
||||
@ -13,7 +13,53 @@ public partial class ServiceChannelService
|
||||
/// </summary>
|
||||
private async Task<Dictionary<long, string>> GetGroupMapAsync()
|
||||
{
|
||||
return (await ServiceChannelRepository.GetGroupsAsync()).ToDictionary(x => x.Id, x => x.Name);
|
||||
return (await ServiceChannelGroupRepository.GetListAsync()).ToDictionary(x => x.Id, x => x.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取服务渠道分组,不存在时直接抛异常。
|
||||
/// </summary>
|
||||
private async Task<ServiceChannelGroup> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验服务渠道分组基础信息和名称唯一性。
|
||||
/// </summary>
|
||||
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("分组名称已存在");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -16,6 +16,7 @@ public partial class ServiceChannelService : ApplicationService
|
||||
private const string RulesOperatorType = "rules";
|
||||
|
||||
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
|
||||
private IServiceChannelGroupRepository ServiceChannelGroupRepository => LazyServiceProvider.GetRequiredService<IServiceChannelGroupRepository>();
|
||||
|
||||
/// <summary>
|
||||
/// 查询服务渠道列表。
|
||||
@ -124,6 +125,74 @@ public partial class ServiceChannelService : ApplicationService
|
||||
await ServiceChannelRepository.DeleteAsync(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增服务渠道分组。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:服务渠道分组保存参数。</param>
|
||||
/// <returns>无返回体,仅完成新增服务渠道分组。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 校验分组名称、应用ID、API服务商ID等基础字段。
|
||||
/// 2. 检查分组名称唯一性,避免重复配置。
|
||||
/// 3. 保存分组主数据供服务渠道绑定。
|
||||
/// </remarks>
|
||||
[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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新服务渠道分组。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:服务渠道分组保存参数。</param>
|
||||
/// <returns>无返回体,仅完成更新服务渠道分组。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 查询并校验目标分组是否存在。
|
||||
/// 2. 检查名称唯一性并更新分组主数据。
|
||||
/// 3. 保留既有渠道关联,只更新分组属性。
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除服务渠道分组。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:目标分组主键。</param>
|
||||
/// <returns>无返回体,仅完成删除服务渠道分组。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 查询并校验目标分组是否存在。
|
||||
/// 2. 检查分组下是否仍有关联渠道。
|
||||
/// 3. 无关联时执行删除,保留框架软删语义。
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存服务渠道配置。
|
||||
/// </summary>
|
||||
@ -135,6 +204,7 @@ public partial class ServiceChannelService : ApplicationService
|
||||
/// 2. 按入参更新主体数据和关联配置。
|
||||
/// 3. 写入更新审计字段并保存结果。
|
||||
/// </remarks>
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[HttpPost("/channel/service-channel/config/save")]
|
||||
public async Task SaveConfigAsync(ServiceChannelSaveInput input)
|
||||
{
|
||||
|
||||
@ -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<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -335,6 +335,72 @@ public partial class OrderService : ApplicationService
|
||||
return await CreateParcelCoreAsync(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量创建订单包裹。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:批量创建小包请求集合,每条记录沿用单票创建结构。</param>
|
||||
/// <returns>批量处理结果,包含批次号、成功失败数量与逐条执行明细。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 校验批量请求非空后逐条复用单票创建逻辑。
|
||||
/// 2. 单条失败不会中断整批处理,允许部分成功部分失败。
|
||||
/// 3. 返回逐条执行结果,方便调用方定位失败原因并重试。
|
||||
/// </remarks>
|
||||
[HttpPost("/order/parcel/batch-create")]
|
||||
public async Task<OrderParcelBatchCreateOutput> BatchCreateParcelAsync(OrderParcelBatchCreateInput input)
|
||||
{
|
||||
if (input == null || input.Count == 0)
|
||||
{
|
||||
throw new BusinessException("批量创建请求不能为空");
|
||||
}
|
||||
|
||||
var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}";
|
||||
var items = new List<OrderParcelBatchCreateItemOutput>(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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将订单列表请求对象转换为仓储可执行的查询表达式。
|
||||
/// </summary>
|
||||
|
||||
38
src/Oms.Application/Services/System/CountryService.cs
Normal file
38
src/Oms.Application/Services/System/CountryService.cs
Normal file
@ -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<ICountryRepository>();
|
||||
|
||||
/// <summary>
|
||||
/// 查询国家列表。
|
||||
/// </summary>
|
||||
/// <returns>国家列表,返回全部有效国家主数据。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 查询全部国家主数据。
|
||||
/// 2. 按国家代码和英文名稳定排序。
|
||||
/// 3. 返回页面选择器可直接消费的静态结果集。
|
||||
/// </remarks>
|
||||
[HttpGet("/system/country/list")]
|
||||
public async Task<List<CountryOutput>> 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();
|
||||
}
|
||||
}
|
||||
172
src/Oms.Application/Services/System/DictService.cs
Normal file
172
src/Oms.Application/Services/System/DictService.cs
Normal file
@ -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<IDictRepository>();
|
||||
|
||||
/// <summary>
|
||||
/// 查询字典列表。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:字典分页查询条件。</param>
|
||||
/// <returns>字典分页结果,包含字典列表与总记录数。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 根据字典类型和关键词拼装筛选条件。
|
||||
/// 2. 按排序值升序、主键降序返回分页结果。
|
||||
/// 3. 供系统配置页直接展示和编辑。
|
||||
/// </remarks>
|
||||
[HttpGet("/system/dict/list")]
|
||||
public async Task<OmsPageResult<DictOutput>> 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<string, bool> { [nameof(Dict.DictSort)] = true, [nameof(Dict.Id)] = false });
|
||||
var total = await DictRepository.CountAsync(predicate);
|
||||
|
||||
return new OmsPageResult<DictOutput>
|
||||
{
|
||||
Items = items.Select(MapDictOutput).ToList(),
|
||||
Total = total
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增字典。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:字典新增参数。</param>
|
||||
/// <returns>无返回体,仅完成新增字典。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 校验类型、编码、名称等基础字段。
|
||||
/// 2. 检查同类型下编码唯一性。
|
||||
/// 3. 写入字典主数据供系统配置复用。
|
||||
/// </remarks>
|
||||
[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()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:字典更新参数。</param>
|
||||
/// <returns>无返回体,仅完成更新字典。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 查询并校验目标字典是否存在。
|
||||
/// 2. 检查更新后的类型与编码组合唯一性。
|
||||
/// 3. 保存排序、编码和名称等最新配置。
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:目标主键。</param>
|
||||
/// <returns>无返回体,仅完成删除字典。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 查询并校验目标字典是否存在。
|
||||
/// 2. 走框架删除通道保留软删语义。
|
||||
/// 3. 让下游列表自动隐藏已删除记录。
|
||||
/// </remarks>
|
||||
[HttpPost("/system/dict/delete")]
|
||||
public async Task DeleteAsync(IdInput input)
|
||||
{
|
||||
var entity = await GetDictOrThrowAsync(input.Id);
|
||||
await DictRepository.DeleteAsync(entity);
|
||||
}
|
||||
|
||||
private static Expression<Func<Dict, bool>> 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<Dict> 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
335
src/Oms.Application/Services/System/NoticeService.cs
Normal file
335
src/Oms.Application/Services/System/NoticeService.cs
Normal file
@ -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<INoticeRepository>();
|
||||
private INoticeReadRepository NoticeReadRepository => LazyServiceProvider.GetRequiredService<INoticeReadRepository>();
|
||||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||
private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService<FakeRbacDbContext>();
|
||||
|
||||
/// <summary>
|
||||
/// 查询通知列表。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:通知分页查询条件。</param>
|
||||
/// <returns>通知分页结果,包含通知主体、接收范围与已读统计。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 根据通知类型、接收对象和关键词过滤通知。
|
||||
/// 2. 聚合已读明细,计算每条通知的已读数。
|
||||
/// 3. 返回接收范围、弹窗标记与统计字段供管理端展示。
|
||||
/// </remarks>
|
||||
[HttpGet("/system/notice/list")]
|
||||
public async Task<OmsPageResult<NoticeOutput>> 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<string, bool> { [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<NoticeOutput>
|
||||
{
|
||||
Items = notices.Select(x => MapNoticeOutput(
|
||||
x,
|
||||
readCountMap.TryGetValue(x.Id, out var readCount) ? readCount : 0)).ToList(),
|
||||
Total = total
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增通知。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:通知新增参数。</param>
|
||||
/// <returns>无返回体,仅完成新增通知。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 校验通知类型、接收对象和接收范围。
|
||||
/// 2. 验证用户或客户接收主体真实存在。
|
||||
/// 3. 保存公告或站内信主体,已读状态由确认接口单独落库。
|
||||
/// </remarks>
|
||||
[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) ?? "[]"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新通知。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:通知更新参数。</param>
|
||||
/// <returns>无返回体,仅完成更新通知。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 查询并校验目标通知是否存在。
|
||||
/// 2. 更新主体内容与接收范围。
|
||||
/// 3. 清理不在新接收范围内的历史已读明细。
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除通知。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:目标主键。</param>
|
||||
/// <returns>无返回体,仅完成删除通知。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 查询并校验目标通知是否存在。
|
||||
/// 2. 走框架删除通道保留软删语义。
|
||||
/// 3. 让通知与已读统计在常规查询中自动不可见。
|
||||
/// </remarks>
|
||||
[HttpPost("/system/notice/delete")]
|
||||
public async Task DeleteAsync(IdInput input)
|
||||
{
|
||||
var entity = await GetNoticeOrThrowAsync(input.Id);
|
||||
await NoticeRepository.DeleteAsync(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确认通知已读。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:目标通知主键。</param>
|
||||
/// <returns>无返回体,仅完成当前主体的已读确认。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 根据当前登录主体解析 user/customer 接收身份。
|
||||
/// 2. 校验当前主体在通知接收范围内。
|
||||
/// 3. 按通知、接收类型、接收ID 幂等写入已读明细。
|
||||
/// </remarks>
|
||||
[HttpPost("/system/notice/read-confirm")]
|
||||
public async Task ReadConfirmAsync(IdInput input)
|
||||
{
|
||||
var notice = await GetNoticeOrThrowAsync(input.Id);
|
||||
var receiverIds = OmsDocHelper.DeserializeJson<List<long>>(notice.ReceiverIds) ?? [];
|
||||
var isCustomerPrincipal = CurrentUser.FindClaimOrNull(CustomerIdClaim) != null;
|
||||
var currentReceiverType = isCustomerPrincipal ? ReceiverTypeCustomer : ReceiverTypeUser;
|
||||
var currentReceiverId = CurrentUser.GetId<long>();
|
||||
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<Func<Notice, bool>> 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<Notice> GetNoticeOrThrowAsync(long id)
|
||||
{
|
||||
var entity = await NoticeRepository.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (entity == null)
|
||||
{
|
||||
throw new BusinessException("通知不存在");
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private async Task<List<long>> NormalizeAndValidateReceiverIdsAsync(string receiverType, IEnumerable<long>? 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<List<long>> QueryValidUserIdsAsync(IReadOnlyCollection<long> 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<UserIdQueryResult>(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<List<long>>(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; }
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,14 @@ using System.Text;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
internal static class OmsPasswordHelper
|
||||
/// <summary>
|
||||
/// 统一的密码摘要工具。
|
||||
/// </summary>
|
||||
public static class OmsPasswordHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// 将明文密码转换为小写 MD5 字符串。
|
||||
/// </summary>
|
||||
public static string ToMd5(string password)
|
||||
{
|
||||
var bytes = MD5.HashData(Encoding.UTF8.GetBytes(password));
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
using Fake.Domain.Repositories;
|
||||
using Oms.Domain.ChannelsAggregate.Entities;
|
||||
|
||||
namespace Oms.Domain.ChannelsAggregate.Repositories;
|
||||
|
||||
public interface IServiceChannelGroupRepository : IRepository<ServiceChannelGroup>
|
||||
{
|
||||
Task<ServiceChannelGroup?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
@ -9,5 +9,7 @@ public interface ICustomerSubAccountRepository : IRepository<CustomerSubAccount>
|
||||
|
||||
Task<CustomerSubAccount?> GetBySubPhoneAsync(string subPhone, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CustomerSubAccount?> GetBySubEmailAsync(string subEmail, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ExistsSubPhoneAsync(string subPhone, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
14
src/Oms.Domain/SystemAggregate/Entities/Country.cs
Normal file
14
src/Oms.Domain/SystemAggregate/Entities/Country.cs
Normal file
@ -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;
|
||||
}
|
||||
16
src/Oms.Domain/SystemAggregate/Entities/Dict.cs
Normal file
16
src/Oms.Domain/SystemAggregate/Entities/Dict.cs
Normal file
@ -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;
|
||||
}
|
||||
20
src/Oms.Domain/SystemAggregate/Entities/Notice.cs
Normal file
20
src/Oms.Domain/SystemAggregate/Entities/Notice.cs
Normal file
@ -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; } = "[]";
|
||||
}
|
||||
16
src/Oms.Domain/SystemAggregate/Entities/NoticeRead.cs
Normal file
16
src/Oms.Domain/SystemAggregate/Entities/NoticeRead.cs
Normal file
@ -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; }
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
using Fake.Domain.Repositories;
|
||||
using Oms.Domain.SystemAggregate.Entities;
|
||||
|
||||
namespace Oms.Domain.SystemAggregate.Repositories;
|
||||
|
||||
public interface ICountryRepository : IRepository<Country>
|
||||
{
|
||||
Task<Country?> GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
using Fake.Domain.Repositories;
|
||||
using Oms.Domain.SystemAggregate.Entities;
|
||||
|
||||
namespace Oms.Domain.SystemAggregate.Repositories;
|
||||
|
||||
public interface IDictRepository : IRepository<Dict>
|
||||
{
|
||||
Task<Dict?> GetByTypeAndCodeAsync(string dictType, string dictCode, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
using Fake.Domain.Repositories;
|
||||
using Oms.Domain.SystemAggregate.Entities;
|
||||
|
||||
namespace Oms.Domain.SystemAggregate.Repositories;
|
||||
|
||||
public interface INoticeReadRepository : IRepository<NoticeRead>
|
||||
{
|
||||
Task<NoticeRead?> GetByNoticeReceiverAsync(long noticeId, string receiverType, long receiverId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
using Fake.Domain.Repositories;
|
||||
using Oms.Domain.SystemAggregate.Entities;
|
||||
|
||||
namespace Oms.Domain.SystemAggregate.Repositories;
|
||||
|
||||
public interface INoticeRepository : IRepository<Notice>
|
||||
{
|
||||
}
|
||||
@ -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<ServiceChannelGroup>, IServiceChannelGroupRepository, IScopedDependency
|
||||
{
|
||||
public Task<ServiceChannelGroup?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return FirstOrDefaultAsync(x => x.Name == name, cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,11 @@ public class CustomerSubAccountRepository : OmsSqlSugarRepository<CustomerSubAcc
|
||||
return FirstOrDefaultAsync(x => x.SubPhone == subPhone, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<CustomerSubAccount?> GetBySubEmailAsync(string subEmail, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return FirstOrDefaultAsync(x => x.SubEmail == subEmail, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsSubPhoneAsync(string subPhone, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return AnyAsync(x => x.SubPhone == subPhone, cancellationToken);
|
||||
|
||||
14
src/Oms.SqlSugarCore/System/CountryRepository.cs
Normal file
14
src/Oms.SqlSugarCore/System/CountryRepository.cs
Normal file
@ -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<Country>, ICountryRepository, IScopedDependency
|
||||
{
|
||||
public Task<Country?> GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return FirstOrDefaultAsync(x => x.CountryCode == countryCode, cancellationToken);
|
||||
}
|
||||
}
|
||||
14
src/Oms.SqlSugarCore/System/DictRepository.cs
Normal file
14
src/Oms.SqlSugarCore/System/DictRepository.cs
Normal file
@ -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<Dict>, IDictRepository, IScopedDependency
|
||||
{
|
||||
public Task<Dict?> GetByTypeAndCodeAsync(string dictType, string dictCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return FirstOrDefaultAsync(x => x.DictType == dictType && x.DictCode == dictCode, cancellationToken);
|
||||
}
|
||||
}
|
||||
16
src/Oms.SqlSugarCore/System/NoticeReadRepository.cs
Normal file
16
src/Oms.SqlSugarCore/System/NoticeReadRepository.cs
Normal file
@ -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<NoticeRead>, INoticeReadRepository, IScopedDependency
|
||||
{
|
||||
public Task<NoticeRead?> GetByNoticeReceiverAsync(long noticeId, string receiverType, long receiverId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return FirstOrDefaultAsync(
|
||||
x => x.NoticeId == noticeId && x.ReceiverType == receiverType && x.ReceiverId == receiverId,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
10
src/Oms.SqlSugarCore/System/NoticeRepository.cs
Normal file
10
src/Oms.SqlSugarCore/System/NoticeRepository.cs
Normal file
@ -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<Notice>, INoticeRepository, IScopedDependency
|
||||
{
|
||||
}
|
||||
1
src/Oms.Tests/GlobalUsings.cs
Normal file
1
src/Oms.Tests/GlobalUsings.cs
Normal file
@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
32
src/Oms.Tests/Oms.Tests.csproj
Normal file
32
src/Oms.Tests/Oms.Tests.csproj
Normal file
@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="8.7.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Oms.Application\Oms.Application.csproj" />
|
||||
<ProjectReference Include="..\Oms.Domain\Oms.Domain.csproj" />
|
||||
<ProjectReference Include="..\SimpleAdmin.Api\SimpleAdmin.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
90
src/Oms.Tests/OmsDocHelperTests.cs
Normal file
90
src/Oms.Tests/OmsDocHelperTests.cs
Normal file
@ -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<string, object?>
|
||||
{
|
||||
["customerCode"] = "C20260428",
|
||||
["amount"] = 128.5m
|
||||
};
|
||||
|
||||
var json = OmsDocHelper.SerializeJson(payload);
|
||||
var result = OmsDocHelper.DeserializeJson<Dictionary<string, JsonElement>>(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("中转仓");
|
||||
}
|
||||
}
|
||||
24
src/Oms.Tests/OmsPasswordHelperTests.cs
Normal file
24
src/Oms.Tests/OmsPasswordHelperTests.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
73
src/Oms.Tests/SimpleCurrentUserTests.cs
Normal file
73
src/Oms.Tests/SimpleCurrentUserTests.cs
Normal file
@ -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<long>().Should().Be(42);
|
||||
currentUser.GetId<int>().Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetId_ShouldFallBackToMapIdWhenUserIdIsGuid()
|
||||
{
|
||||
var currentUser = CreateCurrentUser(
|
||||
[
|
||||
new Claim(FakeClaimTypes.UserId, Guid.NewGuid().ToString()),
|
||||
new Claim("mapId", "88")
|
||||
]);
|
||||
|
||||
currentUser.GetId<long>().Should().Be(88);
|
||||
currentUser.GetId<int>().Should().Be(88);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetId_ShouldFallBackToCustomerIdWhenMapIdMissing()
|
||||
{
|
||||
var currentUser = CreateCurrentUser(
|
||||
[
|
||||
new Claim(FakeClaimTypes.UserId, Guid.NewGuid().ToString()),
|
||||
new Claim("customerId", "77")
|
||||
]);
|
||||
|
||||
currentUser.GetId<long>().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<long>().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);
|
||||
}
|
||||
}
|
||||
24
src/Oms.Tests/Support/TestCurrentPrincipalAccessor.cs
Normal file
24
src/Oms.Tests/Support/TestCurrentPrincipalAccessor.cs
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<ICountryRepository>();
|
||||
repository
|
||||
.Setup(x => x.GetListAsync(
|
||||
It.IsAny<System.Linq.Expressions.Expression<Func<Country, bool>>>(),
|
||||
It.IsAny<Dictionary<string, bool>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<List<CountryOutput>>();
|
||||
items.Should().NotBeNull();
|
||||
items!.Select(x => x.CountryCode).Should().ContainInOrder("AU", "US", "US");
|
||||
items.Select(x => x.EnglishName).Should().ContainInOrder("Australia", "America", "United States");
|
||||
}
|
||||
}
|
||||
@ -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<ICustomerRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(x => x.GetPagedListAsync(
|
||||
It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<Dictionary<string, bool>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<System.Linq.Expressions.Expression<Func<Customer, bool>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ICustomerRepository>();
|
||||
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<OmsPageResult<CustomerPaginatedOutput>>();
|
||||
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<ICustomerRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(x => x.FirstOrDefaultAsync(
|
||||
It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<ICustomerRepository>();
|
||||
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<CustomerOutput>();
|
||||
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<ICustomerRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(x => x.GetByCustomerCodeAsync("CUST-NEW", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Customer?)null);
|
||||
repository
|
||||
.Setup(x => x.GetByPhoneAsync("13700137000", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Customer?)null);
|
||||
repository
|
||||
.Setup(x => x.GetByEmailAsync("new@nanafa.com", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Customer?)null);
|
||||
repository
|
||||
.Setup(x => x.InsertAsync(It.IsAny<Customer>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<Customer, CancellationToken>((entity, _) => insertedEntity = entity)
|
||||
.ReturnsAsync((Customer entity, CancellationToken _) => entity);
|
||||
|
||||
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ICustomerRepository>();
|
||||
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<ICustomerRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(x => x.FirstOrDefaultAsync(
|
||||
It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(currentEntity);
|
||||
repository
|
||||
.Setup(x => x.GetByCustomerCodeAsync("CUST-UPD", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Customer?)null);
|
||||
repository
|
||||
.Setup(x => x.GetByPhoneAsync("13500135000", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Customer?)null);
|
||||
repository
|
||||
.Setup(x => x.GetByEmailAsync("updated@nanafa.com", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Customer?)null);
|
||||
repository
|
||||
.Setup(x => x.UpdateAsync(It.IsAny<Customer>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<Customer, CancellationToken>((entity, _) => updatedEntity = entity)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ICustomerRepository>();
|
||||
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<ICustomerRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(x => x.FirstOrDefaultAsync(
|
||||
It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(currentEntity);
|
||||
repository
|
||||
.Setup(x => x.UpdateAsync(It.IsAny<Customer>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<Customer, CancellationToken>((entity, _) => updatedEntity = entity)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ICustomerRepository>();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@ -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<IDictRepository>();
|
||||
repository
|
||||
.Setup(x => x.GetPagedListAsync(
|
||||
It.IsAny<System.Linq.Expressions.Expression<Func<Dict, bool>>>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<Dictionary<string, bool>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<System.Linq.Expressions.Expression<Func<Dict, bool>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<OmsPageResult<DictOutput>>();
|
||||
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("草稿");
|
||||
}
|
||||
}
|
||||
2
src/SimpleAdmin.Api.IntegrationTests/GlobalUsings.cs
Normal file
2
src/SimpleAdmin.Api.IntegrationTests/GlobalUsings.cs
Normal file
@ -0,0 +1,2 @@
|
||||
global using Xunit;
|
||||
global using System.Net.Http.Json;
|
||||
@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="8.7.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Oms.Application\Oms.Application.csproj" />
|
||||
<ProjectReference Include="..\Oms.Domain\Oms.Domain.csproj" />
|
||||
<ProjectReference Include="..\SimpleAdmin.Api\SimpleAdmin.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -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<string> RoutePatterns =>
|
||||
_app.Services.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.Select(x => x.RoutePattern.RawText ?? string.Empty)
|
||||
.OrderBy(x => x)
|
||||
.ToList();
|
||||
|
||||
public static async Task<SimpleAdminApiTestHost> CreateAsync(
|
||||
ICountryRepository? countryRepository = null,
|
||||
IDictRepository? dictRepository = null,
|
||||
Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
var builder = SimpleAdminApiBootstrap.CreateBuilder([], webAppBuilder =>
|
||||
{
|
||||
webAppBuilder.WebHost.UseTestServer();
|
||||
webAppBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["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<IDataSeedContributor>();
|
||||
builder.Services.RemoveAll<FakeRbacDbContext>();
|
||||
builder.Services.RemoveAll<DbContextOptions<FakeRbacDbContext>>();
|
||||
builder.Services.AddDbContext<FakeRbacDbContext>(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<TService>(IServiceCollection services, TService implementation)
|
||||
where TService : class
|
||||
{
|
||||
services.RemoveAll<TService>();
|
||||
services.AddSingleton(implementation);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Client.Dispose();
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@ -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<IWarehouseAddressRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(x => x.GetPagedListAsync(
|
||||
It.IsAny<System.Linq.Expressions.Expression<Func<WarehouseAddress, bool>>>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<Dictionary<string, bool>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<System.Linq.Expressions.Expression<Func<WarehouseAddress, bool>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IWarehouseAddressRepository>();
|
||||
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<OmsPageResult<WarehousePaginatedOutput>>();
|
||||
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<IWarehouseAddressRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(x => x.FirstOrDefaultAsync(
|
||||
It.IsAny<System.Linq.Expressions.Expression<Func<WarehouseAddress, bool>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<IWarehouseAddressRepository>();
|
||||
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<WarehouseOutput>();
|
||||
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<IWarehouseAddressRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(x => x.GetByWarehouseNameAsync("New Warehouse", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((WarehouseAddress?)null);
|
||||
repository
|
||||
.Setup(x => x.InsertAsync(It.IsAny<WarehouseAddress>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<WarehouseAddress, CancellationToken>((entity, _) => insertedEntity = entity)
|
||||
.ReturnsAsync((WarehouseAddress entity, CancellationToken _) => entity);
|
||||
|
||||
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IWarehouseAddressRepository>();
|
||||
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<IWarehouseAddressRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(x => x.FirstOrDefaultAsync(
|
||||
It.IsAny<System.Linq.Expressions.Expression<Func<WarehouseAddress, bool>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(currentEntity);
|
||||
repository
|
||||
.Setup(x => x.GetByWarehouseNameAsync("Updated Warehouse", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((WarehouseAddress?)null);
|
||||
repository
|
||||
.Setup(x => x.UpdateAsync(It.IsAny<WarehouseAddress>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<WarehouseAddress, CancellationToken>((entity, _) => updatedEntity = entity)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IWarehouseAddressRepository>();
|
||||
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\"]");
|
||||
}
|
||||
}
|
||||
723
src/SimpleAdmin.Api/Auth/AuthService.cs
Normal file
723
src/SimpleAdmin.Api/Auth/AuthService.cs
Normal file
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 接管 OMS 与 RBAC 共用的认证接口,补充客户登录和业务主体 claims 解析。
|
||||
/// </summary>
|
||||
[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<LoginResultDto> 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<LoginResultDto> 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<UserInfoDto> 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<UserInfoDto> 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<string> 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<LoginResultDto> 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<LoginResultDto> 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<LoginResultDto> RefreshCustomerTokenAsync(IEnumerable<Claim> 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<UserInfoDto> 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<UserInfoDto> 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<List<Claim>> BuildAdminClaimsAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var claims = await jwtService.GenerateClaimsByUserIdAsync(userId, cancellationToken);
|
||||
ReplaceOrAppendClaim(claims, SimpleOmsClaimTypes.LoginType, AdminLoginType.ToString());
|
||||
return claims;
|
||||
}
|
||||
|
||||
private List<Claim> BuildCustomerClaims(Guid principalId, long customerId, long? subAccountId, string roleCode, string userName)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
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<Claim> 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<Claim> CloneClaims(IEnumerable<Claim> 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<CustomerPrincipalContext> GetCurrentCustomerContextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var customerId = CurrentUser.GetId<long>();
|
||||
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<Customer?> 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<CustomerSubAccount?> 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<CustomerRoleBundle> 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<List<MenuTreeDto>> BuildMenusByPermissionsAsync(IReadOnlyCollection<string> 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<Guid>(allMenus
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.PermissionCode) && permissionCodes.Contains(x.PermissionCode, StringComparer.OrdinalIgnoreCase))
|
||||
.Select(x => x.Id));
|
||||
|
||||
var queue = new Queue<Guid>(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<MenuTreeDto>();
|
||||
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<Claim> claims)
|
||||
{
|
||||
return int.TryParse(claims.FirstOrDefault(x => x.Type == SimpleOmsClaimTypes.LoginType)?.Value, out var loginType)
|
||||
? loginType
|
||||
: AdminLoginType;
|
||||
}
|
||||
|
||||
private static long ParseLongClaim(IEnumerable<Claim> claims, string claimType)
|
||||
{
|
||||
return long.TryParse(claims.FirstOrDefault(x => x.Type == claimType)?.Value, out var value) ? value : 0;
|
||||
}
|
||||
|
||||
private static long? ParseNullableLongClaim(IEnumerable<Claim> claims, string claimType)
|
||||
{
|
||||
return long.TryParse(claims.FirstOrDefault(x => x.Type == claimType)?.Value, out var value) ? value : null;
|
||||
}
|
||||
|
||||
private static void ReplaceOrAppendClaim(List<Claim> 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<string> PermissionCodes { get; init; } = [];
|
||||
|
||||
public List<MenuTreeDto> Menus { get; init; } = [];
|
||||
}
|
||||
}
|
||||
@ -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<string[]>() ?? []);
|
||||
builder.Host.UseAutofac().UseSerilog();
|
||||
builder.Services.AddApplication<SimpleAdminApiModule>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
54
src/SimpleAdmin.Api/SimpleAdminApiBootstrap.cs
Normal file
54
src/SimpleAdmin.Api/SimpleAdminApiBootstrap.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using Serilog;
|
||||
|
||||
namespace SimpleAdmin.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 为正式启动与集成测试提供统一的应用构建入口。
|
||||
/// </summary>
|
||||
public static class SimpleAdminApiBootstrap
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建并配置 API 应用构建器。
|
||||
/// </summary>
|
||||
/// <param name="args">启动参数。</param>
|
||||
/// <param name="configureBuilder">附加构建配置,主要供测试环境注入本地配置和测试服务器使用。</param>
|
||||
public static WebApplicationBuilder CreateBuilder(
|
||||
string[] args,
|
||||
Action<WebApplicationBuilder>? 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<bool>("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<string[]>() ?? []);
|
||||
builder.Host.UseAutofac().UseSerilog();
|
||||
builder.Services.AddApplication<SimpleAdminApiModule>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据构建器生成并初始化 WebApplication。
|
||||
/// </summary>
|
||||
public static WebApplication BuildApplication(WebApplicationBuilder builder)
|
||||
{
|
||||
var app = builder.Build();
|
||||
app.InitializeApplication();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@ -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<FakeAspNetCoreMvcOptions>(options =>
|
||||
{
|
||||
options.ApplicationServices2Controller<OmsApplicationModule>();
|
||||
options.ApplicationServices2Controller<SimpleAdminApiModule>(settings =>
|
||||
{
|
||||
settings.RootPath = "rbac";
|
||||
settings.TypePredicate = type => type == typeof(AuthService);
|
||||
});
|
||||
options.ApplicationServices2Controller<FakeRbacApplicationModule>(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<IFeiShuNotificationService>().Send("Application started");
|
||||
var shouldSendStartupNotification =
|
||||
!app.Environment.IsEnvironment("Testing") &&
|
||||
app.Configuration.GetValue("App:SendStartupNotification", true);
|
||||
if (!shouldSendStartupNotification)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
app.Services.GetRequiredService<IFeiShuNotificationService>().Send("Application started");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Failed to send startup notification.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
src/SimpleAdmin.Api/SimpleCurrentUser.cs
Normal file
54
src/SimpleAdmin.Api/SimpleCurrentUser.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.Security.Claims;
|
||||
using Fake.Users;
|
||||
|
||||
namespace SimpleAdmin.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 扩展当前用户解析逻辑,支持从 OMS 业务 claims 中读取数值型主体标识。
|
||||
/// </summary>
|
||||
[Dependency(Replace = true)]
|
||||
public class SimpleCurrentUser(ICurrentPrincipalAccessor currentPrincipalAccessor) : CurrentUser(currentPrincipalAccessor)
|
||||
{
|
||||
public override T GetId<T>()
|
||||
{
|
||||
if (typeof(T) == typeof(long))
|
||||
{
|
||||
var userId = base.GetId<long>();
|
||||
if (userId != 0)
|
||||
{
|
||||
return (T)(object)userId;
|
||||
}
|
||||
|
||||
return (T)(object)GetFallbackNumericId();
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(int))
|
||||
{
|
||||
var userId = base.GetId<int>();
|
||||
if (userId != 0)
|
||||
{
|
||||
return (T)(object)userId;
|
||||
}
|
||||
|
||||
return (T)(object)(int)GetFallbackNumericId();
|
||||
}
|
||||
|
||||
return base.GetId<T>()!;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -20,9 +20,17 @@ public class SimpleJwtService(IOptions<JwtOptions> jwtOptions, IUserService user
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var claims = await base.GenerateClaimsByUserIdAsync(userId, cancellationToken);
|
||||
var mapId = await rbacDbContext.Database.SqlQuery<int>($"select userid as value from user where id = {userId}")
|
||||
.FirstAsync(cancellationToken);
|
||||
claims.Add(new Claim("mapId", mapId.ToString()));
|
||||
var mapId = await rbacDbContext.Database.SqlQuery<int?>(
|
||||
$"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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
src/SimpleAdmin.Api/SimpleOmsClaimTypes.cs
Normal file
9
src/SimpleAdmin.Api/SimpleOmsClaimTypes.cs
Normal file
@ -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";
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user