feat: new

This commit is contained in:
xiaolipro 2026-04-29 09:38:07 +08:00
parent 6458ab89ba
commit 96724376a6
76 changed files with 9560 additions and 105 deletions

View File

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

View File

@ -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">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &amp;lt;src&amp;gt;\&amp;lt;SimpleAdmin.Api.IntegrationTests&amp;gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;Project Location="D:\my\itfx\admin\src\SimpleAdmin.Api.IntegrationTests" Presentation="&amp;lt;src&amp;gt;\&amp;lt;SimpleAdmin.Api.IntegrationTests&amp;gt;" /&gt;&#xD;
&lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>

154
db.sql
View File

@ -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 '更新人',

View File

@ -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='订单导入任务失败明细表';

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,11 @@ public class SalesProductDetailOutput
/// </summary>
public long? RemoteAreaId { get; set; }
/// <summary>
/// 价格分区主键。
/// </summary>
public long? PriceAreaId { get; set; }
/// <summary>
/// 计重规则主键。
/// </summary>

View File

@ -50,6 +50,11 @@ public class SalesProductListOutput
/// </summary>
public long? RemoteAreaId { get; set; }
/// <summary>
/// 价格分区主键。
/// </summary>
public long? PriceAreaId { get; set; }
/// <summary>
/// 计重规则主键。
/// </summary>

View File

@ -52,6 +52,11 @@ public class SalesProductSaveInput
/// </summary>
public long? RemoteAreaId { get; set; }
/// <summary>
/// 价格分区主键。
/// </summary>
public long? PriceAreaId { get; set; }
/// <summary>
/// 计重规则主键。
/// </summary>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 批量创建小包入参,数组内每项与单票创建请求结构一致。
/// </summary>
public class OrderParcelBatchCreateInput : List<OrderParcelCreateInput>
{
}

View File

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

View File

@ -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; } = [];
}

View 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;
}

View 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;
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; } = [];
}

View 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; }
}

View 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; }
}

View 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; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();
}
}

View 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)
};
}
}

View 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; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}

View 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;
}

View 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; } = "[]";
}

View 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; }
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
using Fake.Domain.Repositories;
using Oms.Domain.SystemAggregate.Entities;
namespace Oms.Domain.SystemAggregate.Repositories;
public interface INoticeRepository : IRepository<Notice>
{
}

View File

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

View File

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

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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
{
}

View File

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

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

View 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("中转仓");
}
}

View 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);
}
}

View 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);
}
}

View 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();
}
}
}

View File

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

View File

@ -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");
}
}

View File

@ -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"));
}
}

View File

@ -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("草稿");
}
}

View File

@ -0,0 +1,2 @@
global using Xunit;
global using System.Net.Http.Json;

View File

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

View File

@ -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();
}
}

View File

@ -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\"]");
}
}

View 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; } = [];
}
}

View File

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

View 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;
}
}

View File

@ -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.");
}
}
}

View 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;
}
}

View File

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

View 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";
}