diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e2f0a9a..0a3a921 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -40,6 +40,10 @@ jobs: IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}" echo "TARGET=$TARGET REF=$REF REPO=$REPO TAG=$TAG host=$(hostname)" + # FakePackageVersion bump 后私服元数据可能仍被 NuGet http-cache 命中旧索引 + echo "==> clear NuGet http-cache (Fake packages / BaGet)" + dotnet nuget locals http-cache --clear + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml unset NAMESPACE cd /data/k3s diff --git a/Directory.Build.props b/Directory.Build.props index 5ebd994..b45eadd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,9 @@ + + - 8.0.5.20 + + 8.0.5.27 diff --git a/Fake.Local.EventBus.props b/Fake.Local.EventBus.props new file mode 100644 index 0000000..9e0099b --- /dev/null +++ b/Fake.Local.EventBus.props @@ -0,0 +1,13 @@ + + + + C:\Users\BSI\RiderProjects\fake2 + + false + + false + + diff --git a/SimpleAdmin.sln b/SimpleAdmin.sln index 8816a47..858277c 100644 --- a/SimpleAdmin.sln +++ b/SimpleAdmin.sln @@ -16,6 +16,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "items", "items", "{BE5132AD db.sql = db.sql Directory.Build.props = Directory.Build.props .gitea\workflows\deploy.yml = .gitea\workflows\deploy.yml + Fake.Local.EventBus.props = Fake.Local.EventBus.props EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleAdmin.Api", "src\SimpleAdmin.Api\SimpleAdmin.Api.csproj", "{229ED167-9ECC-4736-8DFB-522EEA8FF680}" diff --git a/sql/drop_order_side_effect.sql b/sql/drop_order_side_effect.sql new file mode 100644 index 0000000..04869f6 --- /dev/null +++ b/sql/drop_order_side_effect.sql @@ -0,0 +1,20 @@ +-- 删除创单后置步骤表(已不再使用)。可重复执行。 + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_side_effect' +); + +SET @sql := IF( + @exists > 0, + 'DROP TABLE `t_order_side_effect`', + 'SELECT ''t_order_side_effect already dropped'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/sql/migrate_sales_product_group_wms.sql b/sql/migrate_sales_product_group_wms.sql new file mode 100644 index 0000000..08470da --- /dev/null +++ b/sql/migrate_sales_product_group_wms.sql @@ -0,0 +1,24 @@ +-- 销售产品分组字典:仓储代发(wms)。可重复执行。 +-- 用于店铺订单「创建代发订单」选品过滤;销售产品 ProductGroup 填 wms。 + +SET @db := DATABASE(); + +-- 确保字典类型存在 +INSERT INTO `t_dict_type` (`DictType`, `Remark`, `TypeSort`, `IsDeleted`, `CreateUserId`) +SELECT 'sales_product_group', '销售产品分组', 0, 0, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_dict_type` + WHERE `DictType` = 'sales_product_group' AND `IsDeleted` = 0 +); + +-- 写入 wms 字典项 +INSERT INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `IsDeleted`, `CreateUserId`) +SELECT 'sales_product_group', 90, 'wms', '仓储代发', 0, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_dict` + WHERE `DictType` = 'sales_product_group' + AND `DictCode` = 'wms' + AND `IsDeleted` = 0 +); diff --git a/sql/migrate_user_is_enabled.sql b/sql/migrate_user_is_enabled.sql new file mode 100644 index 0000000..e3094cc --- /dev/null +++ b/sql/migrate_user_is_enabled.sql @@ -0,0 +1,24 @@ +-- 后台用户启用/禁用(可重复执行)。 +-- 表名:fk_user(Fake RBAC)。默认启用;禁用后拒绝账号密码登录与仓配 Token 换取。 + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 'fk_user' + AND COLUMN_NAME = 'IsEnabled' +); + +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `fk_user` ADD COLUMN `IsEnabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:1启用 0禁用'' AFTER `IsDeleted`', + 'SELECT ''fk_user.IsEnabled already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `fk_user` SET `IsEnabled` = 1 WHERE `IsEnabled` IS NULL; diff --git a/src/Oms.Application/DataPerm/DataPermRepository.cs b/src/Oms.Application/DataPerm/DataPermRepository.cs index b0975bb..3c4632a 100644 --- a/src/Oms.Application/DataPerm/DataPermRepository.cs +++ b/src/Oms.Application/DataPerm/DataPermRepository.cs @@ -1,5 +1,6 @@ using Fake.DependencyInjection; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.DataPerm; @@ -11,7 +12,7 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task GetUserSnapshotAsync(Guid userId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $@" SELECT u.`Id` AS `Id`, u.`userid` AS `MapId`, @@ -19,26 +20,25 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency u.`Name` AS `Name`, u.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName` - FROM `user` u - LEFT JOIN `organization` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 - WHERE u.`Id` = {0} AND u.`IsDeleted` = 0 + FROM `{FakeTables.User}` u + LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 + WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 LIMIT 1 - """; - + "; return await Db.Database.SqlQueryRaw(sql, userId.ToString()) .FirstOrDefaultAsync(cancellationToken); } public async Task IsPureCustomerUserAsync(Guid userId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $@" SELECT COUNT(*) AS `Value` - FROM `user_role` ur - INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 - WHERE ur.`UserId` = {0} + FROM `{FakeTables.UserRole}` ur + INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + WHERE ur.`UserId` = {{0}} AND r.`Code` <> 'Customer' AND r.`Code` NOT LIKE 'CustomerSub_%' - """; + "; var nonCustomerRoleCount = await Db.Database.SqlQueryRaw(sql, userId.ToString()) .FirstAsync(cancellationToken); @@ -47,12 +47,11 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency return false; } - const string hasRoleSql = """ + var hasRoleSql = $@" SELECT COUNT(*) AS `Value` - FROM `user_role` ur - WHERE ur.`UserId` = {0} - """; - + FROM `{FakeTables.UserRole}` ur + WHERE ur.`UserId` = {{0}} + "; var roleCount = await Db.Database.SqlQueryRaw(hasRoleSql, userId.ToString()) .FirstAsync(cancellationToken); return roleCount > 0; @@ -60,13 +59,13 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task IsAdminUserAsync(Guid userId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $@" SELECT COUNT(*) AS `Value` - FROM `user_role` ur - INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 - WHERE ur.`UserId` = {0} + FROM `{FakeTables.UserRole}` ur + INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + WHERE ur.`UserId` = {{0}} AND r.`Code` = 'Admin' - """; + "; var count = await Db.Database.SqlQueryRaw(sql, userId.ToString()).FirstAsync(cancellationToken); return count > 0; @@ -74,23 +73,22 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task> GetUserRoleIdsAsync(Guid userId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $@" SELECT ur.`RoleId` AS `Value` - FROM `user_role` ur - WHERE ur.`UserId` = {0} - """; - + FROM `{FakeTables.UserRole}` ur + WHERE ur.`UserId` = {{0}} + "; return await Db.Database.SqlQueryRaw(sql, userId.ToString()) .ToListAsync(cancellationToken); } public async Task> GetUserIdsByRoleAsync(Guid roleId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $@" SELECT ur.`UserId` AS `Value` - FROM `user_role` ur - WHERE ur.`RoleId` = {0} - """; + FROM `{FakeTables.UserRole}` ur + WHERE ur.`RoleId` = {{0}} + "; return await Db.Database.SqlQueryRaw(sql, roleId.ToString()) .ToListAsync(cancellationToken); @@ -115,7 +113,6 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency WHERE p.`IsDeleted` = 0 AND p.`RoleId` IN ({inClause}) """; - return await Db.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); } @@ -385,19 +382,19 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task> GetOrganizationSubtreeIdsAsync(Guid rootOrganizationId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $@" WITH RECURSIVE org_tree AS ( SELECT o.`Id` - FROM `organization` o - WHERE o.`Id` = {0} AND o.`IsDeleted` = 0 + FROM `{FakeTables.Organization}` o + WHERE o.`Id` = {{0}} AND o.`IsDeleted` = 0 UNION ALL SELECT c.`Id` - FROM `organization` c + FROM `{FakeTables.Organization}` c INNER JOIN org_tree t ON c.`ParentId` = t.`Id` WHERE c.`IsDeleted` = 0 ) SELECT `Id` AS `Value` FROM org_tree - """; + "; return await Db.Database.SqlQueryRaw(sql, rootOrganizationId.ToString()) .ToListAsync(cancellationToken); @@ -414,25 +411,24 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency } var inClause = string.Join(",", ids.Select(x => $"'{x}'")); - var sql = $""" + var sql = $@" SELECT o.`Id` AS `Id`, o.`Name` AS `Name` - FROM `organization` o + FROM `{FakeTables.Organization}` o WHERE o.`IsDeleted` = 0 AND o.`Id` IN ({inClause}) - """; - + "; var rows = await Db.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); return rows.ToDictionary(x => x.Id, x => x.Name ?? x.Id.ToString()); } public async Task OrganizationExistsAndEnabledAsync(Guid organizationId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $@" SELECT COUNT(*) AS `Value` - FROM `organization` o - WHERE o.`Id` = {0} + FROM `{FakeTables.Organization}` o + WHERE o.`Id` = {{0}} AND o.`IsDeleted` = 0 - """; + "; var count = await Db.Database.SqlQueryRaw(sql, organizationId.ToString()).FirstAsync(cancellationToken); return count > 0; @@ -450,13 +446,12 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency if (dataScope == DataPermConstants.DataScopeTeam) { - const string sql = """ + var sql = $@" SELECT u.`userid` AS `Value` - FROM `user` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND u.`OrganizationId` = {0} - """; - + AND u.`OrganizationId` = {{0}} + "; return await Db.Database.SqlQueryRaw(sql, organizationId.ToString()) .ToListAsync(cancellationToken); } @@ -470,12 +465,12 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency } var inClause = string.Join(",", orgIds.Select(x => $"'{x}'")); - var sql = $""" + var sql = $@" SELECT u.`userid` AS `Value` - FROM `user` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 AND u.`OrganizationId` IN ({inClause}) - """; + "; return await Db.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); } @@ -485,7 +480,7 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task> GetDataResourcesAsync(CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $""" SELECT r.`Code` AS `Code`, r.`Name` AS `Name`, diff --git a/src/Oms.Application/DataPerm/DataPermStore.cs b/src/Oms.Application/DataPerm/DataPermStore.cs index c2afb49..fce007d 100644 --- a/src/Oms.Application/DataPerm/DataPermStore.cs +++ b/src/Oms.Application/DataPerm/DataPermStore.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Fake.Application; using Fake.DependencyInjection; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Oms.Application.Dtos.DataPerm; @@ -15,26 +16,28 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac PropertyNameCaseInsensitive = true }; - // EF Core 原始 SQL 参数不接受 DBNull,空值直接传 null - private static object? SqlParam(object? value) => value; + // EF Core 原始 SQL 参数不接受 DBNull;object[] 元素类型为 object,运行时仍可传 null + private static object SqlParam(object? value) => value ?? null!; public async Task GetUserDataPermAsync(Guid userId, CancellationToken cancellationToken = default) { - var user = await dbContext.Database.SqlQueryRaw(""" + var userSql = $@" SELECT u.`Id` AS `UserId`, u.`Name` AS `UserName`, u.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName` - FROM `user` u - LEFT JOIN `organization` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 - WHERE u.`Id` = {0} AND u.`IsDeleted` = 0 + FROM `{FakeTables.User}` u + LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 + WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 LIMIT 1 - """, userId.ToString()).FirstOrDefaultAsync(cancellationToken) + "; + var user = await dbContext.Database.SqlQueryRaw(userSql, userId.ToString()) + .FirstOrDefaultAsync(cancellationToken) ?? throw new BusinessException("用户不存在"); var now = DateTime.UtcNow; - var orgRows = await dbContext.Database.SqlQueryRaw(""" + var orgSql = $@" SELECT p.`Id` AS `Id`, p.`OrganizationId` AS `OrganizationId`, @@ -44,9 +47,11 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac p.`ExpireTime` AS `ExpireTime`, p.`IsTemporary` AS `IsTemporary` FROM `user_org_perm` p - LEFT JOIN `organization` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 - WHERE p.`IsDeleted` = 0 AND p.`UserId` = {0} - """, userId.ToString()).ToListAsync(cancellationToken); + LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 + WHERE p.`IsDeleted` = 0 AND p.`UserId` = {{0}} + "; + var orgRows = await dbContext.Database.SqlQueryRaw(orgSql, userId.ToString()) + .ToListAsync(cancellationToken); var scopeRows = await dbContext.Database.SqlQueryRaw(""" SELECT p.`Id` AS `Id`, p.`DataScope` AS `DataScope`, p.`DataResourceCode` AS `DataResourceCode`, p.`ExpireTime` AS `ExpireTime` @@ -127,20 +132,24 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac public async Task GetRoleDataPermAsync(Guid roleId, CancellationToken cancellationToken = default) { - var role = await dbContext.Database.SqlQueryRaw(""" + var roleSql = $@" SELECT r.`Id` AS `RoleId`, r.`Name` AS `RoleName` - FROM `role` r - WHERE r.`Id` = {0} AND r.`IsDeleted` = 0 + FROM `{FakeTables.Role}` r + WHERE r.`Id` = {{0}} AND r.`IsDeleted` = 0 LIMIT 1 - """, roleId.ToString()).FirstOrDefaultAsync(cancellationToken) + "; + var role = await dbContext.Database.SqlQueryRaw(roleSql, roleId.ToString()) + .FirstOrDefaultAsync(cancellationToken) ?? throw new BusinessException("角色不存在"); - var orgRows = await dbContext.Database.SqlQueryRaw(""" + var orgSql = $@" SELECT p.`Id` AS `Id`, p.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName`, p.`OrgScope` AS `OrgScope` FROM `role_org_perm` p - LEFT JOIN `organization` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 - WHERE p.`IsDeleted` = 0 AND p.`RoleId` = {0} - """, roleId.ToString()).ToListAsync(cancellationToken); + LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 + WHERE p.`IsDeleted` = 0 AND p.`RoleId` = {{0}} + "; + var orgRows = await dbContext.Database.SqlQueryRaw(orgSql, roleId.ToString()) + .ToListAsync(cancellationToken); var scopeRows = await dbContext.Database.SqlQueryRaw(""" SELECT p.`Id` AS `Id`, p.`DataScope` AS `DataScope`, p.`DataResourceCode` AS `DataResourceCode` @@ -520,7 +529,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac public async Task> GetFieldMasksAsync(string dataResourceCode, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = """ SELECT m.`FieldName` AS `FieldName`, m.`SecretLevel` AS `SecretLevel`, m.`MaskType` AS `MaskType`, m.`MaskPattern` AS `MaskPattern` FROM `data_perm_field_mask` m WHERE m.`IsDeleted` = 0 AND m.`IsEnabled` = 1 AND m.`DataResourceCode` = {0} @@ -640,7 +649,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac public async Task GetTemplateDetailAsync(Guid templateId, CancellationToken cancellationToken = default) { - const string headerSql = """ + var headerSql = """ SELECT t.`Id` AS `Id`, t.`Name` AS `Name`, t.`Code` AS `Code`, t.`Description` AS `Description`, t.`RoleCode` AS `RoleCode`, t.`IsBuiltIn` AS `IsBuiltIn` FROM `data_perm_template` t @@ -652,7 +661,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac .FirstOrDefaultAsync(cancellationToken) ?? throw new BusinessException("模板不存在"); - const string itemSql = """ + var itemSql = """ SELECT i.`Id` AS `Id`, i.`DimensionType` AS `DimensionType`, CAST(i.`ItemContent` AS CHAR) AS `ItemContentJson`, i.`SortOrder` AS `SortOrder` FROM `data_perm_template_item` i WHERE i.`TemplateId` = {0} AND i.`IsDeleted` = 0 diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs new file mode 100644 index 0000000..9a63e80 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace Oms.Application.Dtos.Channels; + +/// +/// 自动触发规则 Excel 导入入参。 +/// +public class AutoTriggerImportInput +{ + /// + /// 导入模式:incremental=增量;full=全量(先清空再导入)。 + /// + public string ImportMode { get; set; } = ImportModes.Incremental; + + /// + /// Excel 文件。 + /// + public IFormFile File { get; set; } = null!; + + public static class ImportModes + { + public const string Incremental = "incremental"; + public const string Full = "full"; + } +} diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs new file mode 100644 index 0000000..e73fe1e --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs @@ -0,0 +1,25 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// 自动触发规则 Excel 导入结果。 +/// +public class AutoTriggerImportOutput +{ + public int SuccessCount { get; set; } + + public int SkipCount { get; set; } + + public int FailCount { get; set; } + + public List Failures { get; set; } = []; +} + +/// +/// 导入失败行。 +/// +public class AutoTriggerImportFailRow +{ + public int RowNumber { get; set; } + + public string Reason { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs b/src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs new file mode 100644 index 0000000..203e1ce --- /dev/null +++ b/src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs @@ -0,0 +1,108 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.OpenApi; + +/// +/// 开放接口获取 Token 入参。 +/// +public class OpenApiGetTokenInput +{ + /// + /// 用户名(OMS 后台账号)。 + /// + [Required] + [StringLength(64)] + public string Username { get; set; } = string.Empty; + + /// + /// 密码。 + /// + [Required] + [StringLength(128)] + public string Password { get; set; } = string.Empty; +} + +/// +/// 开放接口简易结果(件重尺上传等)。 +/// +public class OpenApiSimpleResult +{ + public bool Success { get; set; } + + public string Msg { get; set; } = string.Empty; + + public static OpenApiSimpleResult Ok(string msg = "success") => + new() { Success = true, Msg = msg }; + + public static OpenApiSimpleResult Fail(string msg) => + new() { Success = false, Msg = msg }; +} + +/// +/// 仓配上传件重尺入参(入库接口可忽略 mode,固定走入库扫描「入库」逻辑)。 +/// +public class OpenApiUploadWeightInput +{ + /// + /// 订单号(服务单号 / 跟踪号 / 下游单号,同入库扫描扫码框)。 + /// + [Required] + [StringLength(64)] + public string OrderNo { get; set; } = string.Empty; + + /// + /// 收货实重(同入库扫描「实重」)。 + /// + public decimal Weight { get; set; } + + /// + /// 长(同入库扫描尺寸)。 + /// + public decimal Length { get; set; } + + /// + /// 宽。 + /// + public decimal Width { get; set; } + + /// + /// 高。 + /// + public decimal Height { get; set; } + + /// + /// 模式:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。调用 /inbound 时可省略。 + /// + public int Mode { get; set; } + + /// + /// 重量单位:KG(默认)/ G / LB。 + /// + [StringLength(8)] + public string? WeightUnit { get; set; } + + /// + /// 长度单位:CM(默认)/ MM / IN / M。 + /// + [StringLength(8)] + public string? LengthUnit { get; set; } +} + +/// +/// 仓配上传红外图片入参(落订单附件 infraredImage,转存 COS)。 +/// +public class OpenApiUploadImageInput +{ + /// + /// 订单号(服务单号 / 跟踪号 / 下游单号)。 + /// + [Required] + [StringLength(64)] + public string OrderNo { get; set; } = string.Empty; + + /// + /// 图片 Base64(可带 data:image/...;base64, 前缀;jpg/png/gif/webp/bmp/pdf)。 + /// + [Required] + public string ImageBase64 { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs index cdab609..92ab5b9 100644 --- a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs +++ b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs @@ -11,6 +11,9 @@ public class PlatformStorePageInput public int? CustomerId { get; set; } + /// + /// 平台编码:tiktok / temu / shopee / lazada;空或 all 表示全部。 + /// public string? PlatformCode { get; set; } public string? StoreNickname { get; set; } @@ -147,11 +150,6 @@ public class PlatformStoreOrderListInput : PlatformStorePageInput { public int? StoreId { get; set; } - /// - /// 平台编码:tiktok / temu / shopee / lazada;空或 all 表示全部。 - /// - public string? PlatformCode { get; set; } - /// /// 履约状态:pending / toShip / shipped / canceled / suspended / completed;空或 all 表示全部。 /// diff --git a/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs b/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs index 55fd380..851a240 100644 --- a/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs +++ b/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs @@ -26,6 +26,11 @@ public class InternalUserListItemDto public DateTime? CreatedAt { get; set; } + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + public List Roles { get; set; } = []; } diff --git a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs index 415831c..febeaee 100644 --- a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs +++ b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs @@ -16,6 +16,11 @@ public class UserProfileData /// 企业微信客服接入链接(微信外接入)。 /// public string? WecomKfUrl { get; set; } + + /// + /// 是否启用;缺省按启用处理。 + /// + public bool IsEnabled { get; set; } = true; } public class OmsUserCreateInput : UserCreateDto @@ -29,6 +34,11 @@ public class OmsUserCreateInput : UserCreateDto public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + /// + /// 是否启用,默认 true。 + /// + public bool? IsEnabled { get; set; } } public class OmsUserUpdateInput : UserUpdateDto @@ -40,6 +50,11 @@ public class OmsUserUpdateInput : UserUpdateDto public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + /// + /// 是否启用;不传则不改。 + /// + public bool? IsEnabled { get; set; } } public class OmsUserDetailDto : UserDto @@ -53,6 +68,11 @@ public class OmsUserDetailDto : UserDto public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; } /// @@ -65,3 +85,14 @@ public class ResetUserPasswordInput /// public string NewPassword { get; set; } = string.Empty; } + +/// +/// 设置用户启用状态入参。 +/// +public class SetUserEnabledInput +{ + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } +} diff --git a/src/Oms.Application/FakeTables.cs b/src/Oms.Application/FakeTables.cs new file mode 100644 index 0000000..43f7efe --- /dev/null +++ b/src/Oms.Application/FakeTables.cs @@ -0,0 +1,12 @@ +namespace Oms.Application; + + +public static class FakeTables +{ + public static string User => "fk_user"; + public static string Role => "fk_role"; + public static string Menu => "fk_menu"; + public static string Organization => "fk_organization"; + public static string UserRole => "fk_user_role"; + public static string RolePermission => "fk_role_permission"; +} diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs index 19eef09..53a5d17 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs @@ -68,10 +68,18 @@ internal sealed class SmallBagTrackUpsertRequestBuilder( } /// - /// 仅取运单跟踪号;无值则空串,不回落运单号/客户单号等。 + /// 轨迹侧 TrackNo 必填。有真实跟踪号用跟踪号;尚未下发时先用客户单号占位,后续有 TrackNo 再 Upsert 更新。 /// - internal static string ResolveTrackNo(Order order) => - order.TrackNo?.Trim() ?? string.Empty; + internal static string ResolveTrackNo(Order order) + { + var trackNo = order.TrackNo?.Trim() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(trackNo)) + { + return trackNo; + } + + return order.ClientNo?.Trim() ?? string.Empty; + } internal static string? PickSupplierCodeFromProviders( IEnumerable providers, diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs index a6da67b..7e3287b 100644 --- a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs +++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs @@ -53,8 +53,7 @@ public sealed class LabelPdfGenerationService( var relativeUrl = await SavePdfAsync(order.ServiceNo, pdfBytes, cancellationToken); order.LabelTemplateId = templateId; - order.UpdateTime = DateTime.Now; - await orderRepository.UpdateAsync(order); + await orderRepository.UpdateLabelTemplateIdAsync(order.Id, templateId.Value, cancellationToken); await orderFileManager.ReplaceSystemLabelAsync( order.Id, diff --git a/src/Oms.Application/Messaging/OmsAfterCommit.cs b/src/Oms.Application/Messaging/OmsAfterCommit.cs new file mode 100644 index 0000000..9b31ee7 --- /dev/null +++ b/src/Oms.Application/Messaging/OmsAfterCommit.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Fake.UnitOfWork; + +namespace Oms.Application.Messaging; + +/// +/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。 +/// +internal static class OmsAfterCommit +{ + public static Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func action) + { + var current = unitOfWorkManager.Current; + if (current != null && !current.IsCompleted) + { + // OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。 + // 等工作单元释放后再开独立事务。 + EventHandler handler = null!; + handler = (_, _) => + { + current.Disposed -= handler; + _ = Task.Run(action); + }; + current.Disposed += handler; + return Task.CompletedTask; + } + + return action(); + } + + /// + /// 新开独立事务执行一步。失败只记日志,不抛回调用方。 + /// + public static async Task RunStepAsync( + IServiceScopeFactory scopeFactory, + ILogger logger, + string step, + Func work) + { + try + { + using var scope = scopeFactory.CreateScope(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager); + await work(scope.ServiceProvider); + await unitOfWork.CompleteAsync(); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "订单后置步骤失败 Step={Step}", step); + return false; + } + } +} diff --git a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs index 956bc1e..b572be4 100644 --- a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs +++ b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs @@ -33,9 +33,9 @@ internal static class OmsIntegrationEventPublisher @event.Id); return; } - catch (Exception ex) when (IsTxConnectionMismatch(ex)) + catch (Exception ex) when (ShouldUseIndependentOutbox(ex)) { - // SqlSugar 已在另一条连接上开事务时,EF 无法征用该 DbTransaction。 + // 环境事务已提交、连接对不上,或还没有可征用的数据库事务时,改走独立短事务。 logger.LogWarning( ex, "Ambient UoW TX incompatible with EF Outbox; using short outbox TX. Event={EventName} Id={EventId}", @@ -97,12 +97,18 @@ internal static class OmsIntegrationEventPublisher return dbProvider.GetDbContextAsync(); } - private static bool IsTxConnectionMismatch(Exception ex) + private static bool ShouldUseIndependentOutbox(Exception ex) { for (var e = ex; e != null; e = e.InnerException!) { - if (e.Message.Contains("not associated with the current connection", StringComparison.OrdinalIgnoreCase) - || e.Message.Contains("associated with the current connection", StringComparison.OrdinalIgnoreCase)) + var message = e.Message; + if (message.Contains("not associated with the current connection", StringComparison.OrdinalIgnoreCase) + || message.Contains("associated with the current connection", StringComparison.OrdinalIgnoreCase) + || message.Contains("可征用的数据库事务", StringComparison.Ordinal) + || message.Contains("returned null", StringComparison.OrdinalIgnoreCase) + || message.Contains("No ambient transaction", StringComparison.OrdinalIgnoreCase) + || message.Contains("已经回滚", StringComparison.Ordinal) + || message.Contains("已被调用", StringComparison.Ordinal)) { return true; } diff --git a/src/Oms.Application/Messaging/OmsOutboxUnitOfWork.cs b/src/Oms.Application/Messaging/OmsOutboxUnitOfWork.cs index dd1c83b..8d868d5 100644 --- a/src/Oms.Application/Messaging/OmsOutboxUnitOfWork.cs +++ b/src/Oms.Application/Messaging/OmsOutboxUnitOfWork.cs @@ -17,6 +17,16 @@ public static class OmsOutboxUnitOfWork IsolationLevel = IsolationLevel.ReadCommitted }; + /// + /// 与当前业务事务脱开的短事务,用于创单/预报成功之后的独立事件。 + /// + public static UnitOfWorkAttribute Independent() => new() + { + IsTransactional = true, + RequiresNew = true, + IsolationLevel = IsolationLevel.ReadCommitted + }; + public static bool IsAmbientTransactional(IUnitOfWorkManager unitOfWorkManager) => unitOfWorkManager.Current?.Context.IsTransactional == true; } diff --git a/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs b/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs index 95b3ad4..32451db 100644 --- a/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs +++ b/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs @@ -29,7 +29,7 @@ public sealed class OrderBatchJobEventHandler( return; } - // ForecastBilling 成功后继续写轨迹/面单/自动触发 Outbox,须事务性 UoW + // 业务结果在本事务提交;轨迹/面单/自动触发由处理过程登记到提交后单独执行。 using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); await processor.ProcessAsync(@event.ToMessage(), cancellationToken); await unitOfWork.CompleteAsync(cancellationToken); diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs index f963a48..ddc7e64 100644 --- a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs +++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs @@ -1,7 +1,5 @@ using Fake.DependencyInjection; using Fake.EventBus; -using Fake.UnitOfWork; -using Oms.Application.Messaging; using Microsoft.Extensions.Logging; using Oms.Application.Integrations.SmallBagTrack; @@ -9,7 +7,6 @@ namespace Oms.Application.Messaging.OrderSideEffects; public sealed class OrderLifecycleTrackAppendEventHandler( ISmallBagTrackOrderClient trackClient, - IUnitOfWorkManager unitOfWorkManager, ILogger logger) : IEventHandler, ITransientDependency { @@ -25,7 +22,7 @@ public sealed class OrderLifecycleTrackAppendEventHandler( return; } - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); + // 纯外部 HTTP,无需事务;占 TX 只会拖长连接占用。 try { await trackClient.AppendLifecycleTrackAsync( @@ -44,7 +41,5 @@ public sealed class OrderLifecycleTrackAppendEventHandler( @event.OrderNo, @event.TrackCode); } - - await unitOfWork.CompleteAsync(cancellationToken); } } diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs index 5ce44e7..b188506 100644 --- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs @@ -4,6 +4,7 @@ using Fake.UnitOfWork; using Oms.Application.Messaging; using Microsoft.Extensions.Logging; using Oms.Application.Services.Orders; +using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Repositories; namespace Oms.Application.Messaging.ParcelCreate; @@ -24,9 +25,11 @@ public sealed class OrderParcelCustoms9610UpsertEventHandler( { using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken); - if (order == null) + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) { - logger.LogWarning("9610 upsert skipped, order missing OrderId={OrderId}", @event.OrderId); + logger.LogWarning("9610 upsert skipped, order missing/deleted OrderId={OrderId}", @event.OrderId); + await unitOfWork.CompleteAsync(cancellationToken); return; } diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs index 0fb3f89..c2572b2 100644 --- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs @@ -2,7 +2,6 @@ using Fake.Application; using Fake.DependencyInjection; using Fake.EventBus; using Fake.UnitOfWork; -using Oms.Application.Messaging; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Oms.Application.Integrations.SmallBagTrack; @@ -25,7 +24,8 @@ public sealed class OrderParcelTrackUpsertEventHandler( OrderParcelTrackUpsertIntegrationEvent @event, CancellationToken cancellationToken) { - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); + // 仅读库 + 外部 HTTP,不要占事务连接;失败抛出让总线重试。 + using var unitOfWork = unitOfWorkManager.Begin(new UnitOfWorkAttribute { IsTransactional = false }); var request = await ResolveRequestAsync(@event, cancellationToken); request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess; diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj index 72efa2e..515006d 100644 --- a/src/Oms.Application/Oms.Application.csproj +++ b/src/Oms.Application/Oms.Application.csproj @@ -18,13 +18,8 @@ - - - - - @@ -35,6 +30,29 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs b/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs index ea7f768..ed34881 100644 --- a/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs +++ b/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs @@ -1,5 +1,6 @@ using Fake.DependencyInjection; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Customers; @@ -41,18 +42,17 @@ public class CustomerServiceAdvisorResolver(FakeRbacDbContext dbContext) : IScop return null; } - const string sql = """ + var sql = $@" SELECT u.`Name` AS `Name`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, u.`WecomKfUrl` AS `WecomKfUrl` - FROM `user` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND u.`userid` = {0} + AND u.`userid` = {{0}} LIMIT 1 - """; - + "; return await dbContext.Database .SqlQueryRaw(sql, userId) .FirstOrDefaultAsync(cancellationToken); @@ -60,18 +60,18 @@ public class CustomerServiceAdvisorResolver(FakeRbacDbContext dbContext) : IScop private async Task FindByNameOrAccountAsync(string key, CancellationToken cancellationToken) { - const string sql = """ + var sql = $@" SELECT u.`Name` AS `Name`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, u.`WecomKfUrl` AS `WecomKfUrl` - FROM `user` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND (u.`Name` = {0} OR u.`Account` = {0}) + AND (u.`Name` = {{0}} OR u.`Account` = {{0}}) ORDER BY u.`CreateTime` ASC LIMIT 1 - """; + "; return await dbContext.Database .SqlQueryRaw(sql, key) diff --git a/src/Oms.Application/Rbac/InternalUserRepository.cs b/src/Oms.Application/Rbac/InternalUserRepository.cs index 2c48839..3d6cc05 100644 --- a/src/Oms.Application/Rbac/InternalUserRepository.cs +++ b/src/Oms.Application/Rbac/InternalUserRepository.cs @@ -1,4 +1,5 @@ using Fake.DependencyInjection; +using Fake.EntityFrameworkCore; using Fake.Rbac.Infrastructure; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Rbac; @@ -7,19 +8,19 @@ namespace Oms.Application.Rbac; public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDependency { - private const string InternalUserFilter = """ + private static readonly string InternalUserFilter = $@" ( - NOT EXISTS (SELECT 1 FROM `user_role` ur0 WHERE ur0.`UserId` = u.`Id`) + NOT EXISTS (SELECT 1 FROM `{FakeTables.UserRole}` ur0 WHERE ur0.`UserId` = u.`Id`) OR EXISTS ( SELECT 1 - FROM `user_role` ur - INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + FROM `{FakeTables.UserRole}` ur + INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 WHERE ur.`UserId` = u.`Id` AND r.`Code` <> 'Customer' AND r.`Code` NOT LIKE 'CustomerSub\_%' ) ) - """; + "; public async Task GetInternalUsersAsync( string? keyword, @@ -45,16 +46,16 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend args.Add(organizationId.Value.ToString()); } - var countSql = $""" + var countSql = $@" SELECT COUNT(*) AS `Value` - FROM `user` u + FROM `{FakeTables.User}` u {where} - """; + "; var total = await dbContext.Database.SqlQueryRaw(countSql, args.ToArray()) .FirstAsync(cancellationToken); var offset = Math.Max(page - 1, 0) * pageSize; - var listSql = $""" + var listSql = $@" SELECT u.`Id` AS `Id`, u.`Name` AS `Name`, @@ -66,14 +67,14 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend u.`WechatId` AS `WechatId`, u.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName`, - u.`CreateTime` AS `CreatedAt` - FROM `user` u - LEFT JOIN `organization` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 + u.`CreateTime` AS `CreatedAt`, + COALESCE(u.`IsEnabled`, 1) AS `IsEnabled` + FROM `{FakeTables.User}` u + LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 {where} ORDER BY u.`CreateTime` DESC LIMIT {pageSize} OFFSET {offset} - """; - + "; var rows = await dbContext.Database.SqlQueryRaw(listSql, args.ToArray()) .ToListAsync(cancellationToken); if (rows.Count == 0) @@ -96,6 +97,7 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend OrganizationId = row.OrganizationId?.ToString(), OrganizationName = row.OrganizationName, CreatedAt = row.CreatedAt, + IsEnabled = row.IsEnabled != 0, Roles = roleMap.TryGetValue(row.Id, out var roles) ? roles : [] }).ToList(); @@ -112,17 +114,17 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend } var inClause = string.Join(",", userIds.Select(x => $"'{x}'")); - var sql = $""" + var sql = $@" SELECT ur.`UserId` AS `UserId`, r.`Id` AS `RoleId`, r.`Name` AS `RoleName`, r.`Code` AS `RoleCode` - FROM `user_role` ur - INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + FROM `{FakeTables.UserRole}` ur + INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 WHERE ur.`UserId` IN ({inClause}) ORDER BY r.`Name` - """; + "; var roleRows = await dbContext.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); return roleRows @@ -160,6 +162,8 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend public string? OrganizationName { get; set; } public DateTime? CreatedAt { get; set; } + + public int IsEnabled { get; set; } = 1; } private sealed class UserRoleRow diff --git a/src/Oms.Application/Rbac/OmsUserProfileMapper.cs b/src/Oms.Application/Rbac/OmsUserProfileMapper.cs index 463a194..56a3013 100644 --- a/src/Oms.Application/Rbac/OmsUserProfileMapper.cs +++ b/src/Oms.Application/Rbac/OmsUserProfileMapper.cs @@ -33,6 +33,7 @@ internal static class OmsUserProfileMapper detail.WechatId = profile.WechatId; detail.WechatQrCode = profile.WechatQrCode; detail.WecomKfUrl = profile.WecomKfUrl; + detail.IsEnabled = profile.IsEnabled; return detail; } } diff --git a/src/Oms.Application/Rbac/UserProfileRepository.cs b/src/Oms.Application/Rbac/UserProfileRepository.cs index 7115b11..a02fa3f 100644 --- a/src/Oms.Application/Rbac/UserProfileRepository.cs +++ b/src/Oms.Application/Rbac/UserProfileRepository.cs @@ -1,5 +1,7 @@ using System.Text.RegularExpressions; +using Fake.Application; using Fake.DependencyInjection; +using Fake.EntityFrameworkCore; using Fake.Rbac.Infrastructure; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Rbac; @@ -15,18 +17,18 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public async Task GetAsync(Guid userId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $@" SELECT u.`Phone` AS `Phone`, u.`EmployeeNo` AS `EmployeeNo`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, - u.`WecomKfUrl` AS `WecomKfUrl` - FROM `user` u - WHERE u.`Id` = {0} AND u.`IsDeleted` = 0 + u.`WecomKfUrl` AS `WecomKfUrl`, + COALESCE(u.`IsEnabled`, 1) AS `IsEnabled` + FROM `{FakeTables.User}` u + WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 LIMIT 1 - """; - + "; var row = await dbContext.Database .SqlQueryRaw(sql, userId.ToString()) .FirstOrDefaultAsync(cancellationToken); @@ -43,6 +45,7 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende WechatId = row.WechatId, WechatQrCode = row.WechatQrCode, WecomKfUrl = row.WecomKfUrl, + IsEnabled = row.IsEnabled != 0, }; } @@ -56,17 +59,18 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende } var inClause = string.Join(",", userIds.Select(x => $"'{x}'")); - var sql = $""" + var sql = $@" SELECT u.`Id` AS `UserId`, u.`Phone` AS `Phone`, u.`EmployeeNo` AS `EmployeeNo`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, - u.`WecomKfUrl` AS `WecomKfUrl` - FROM `user` u + u.`WecomKfUrl` AS `WecomKfUrl`, + COALESCE(u.`IsEnabled`, 1) AS `IsEnabled` + FROM `{FakeTables.User}` u WHERE u.`Id` IN ({inClause}) AND u.`IsDeleted` = 0 - """; + "; var rows = await dbContext.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); return rows.ToDictionary( @@ -78,59 +82,103 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende WechatId = x.WechatId, WechatQrCode = x.WechatQrCode, WecomKfUrl = x.WecomKfUrl, + IsEnabled = x.IsEnabled != 0, }); } public async Task SaveAsync(Guid userId, UserProfileData profile, CancellationToken cancellationToken = default) { - const string sql = """ - UPDATE `user` + var sql = $@" + UPDATE `{FakeTables.User}` SET - `Phone` = {1}, - `EmployeeNo` = {2}, - `WechatId` = {3}, - `WechatQrCode` = {4}, - `WecomKfUrl` = {5} - WHERE `Id` = {0} AND `IsDeleted` = 0 - """; + `Phone` = {{1}}, + `EmployeeNo` = {{2}}, + `WechatId` = {{3}}, + `WechatQrCode` = {{4}}, + `WecomKfUrl` = {{5}}, + `IsEnabled` = {{6}} + WHERE `Id` = {{0}} AND `IsDeleted` = 0 + "; await dbContext.Database.ExecuteSqlRawAsync( sql, [ userId.ToString(), - // EF Core 参数不接受 DBNull,空值直接传 null 即可映射为 SQL NULL - (object?)NormalizeOptional(profile.Phone), - (object?)NormalizeOptional(profile.EmployeeNo), - (object?)NormalizeOptional(profile.WechatId), - (object?)NormalizeOptional(profile.WechatQrCode), - (object?)NormalizeOptional(profile.WecomKfUrl), + // EF Core 参数不接受 DBNull;object[] 元素类型为 object,运行时仍可传 null + SqlParam(NormalizeOptional(profile.Phone)), + SqlParam(NormalizeOptional(profile.EmployeeNo)), + SqlParam(NormalizeOptional(profile.WechatId)), + SqlParam(NormalizeOptional(profile.WechatQrCode)), + SqlParam(NormalizeOptional(profile.WecomKfUrl)), + profile.IsEnabled ? 1 : 0, ], cancellationToken); } + public async Task SetEnabledAsync(Guid userId, bool isEnabled, CancellationToken cancellationToken = default) + { + var sql = $@" + UPDATE `{FakeTables.User}` + SET `IsEnabled` = {{1}} + WHERE `Id` = {{0}} AND `IsDeleted` = 0 + "; + + var affected = await dbContext.Database.ExecuteSqlRawAsync( + sql, + [userId.ToString(), isEnabled ? 1 : 0], + cancellationToken); + if (affected <= 0) + { + throw new BusinessException("用户不存在"); + } + } + + public async Task EnsureEnabledOrThrowAsync(Guid userId, CancellationToken cancellationToken = default) + { + var sql = $@" + SELECT COALESCE(u.`IsEnabled`, 1) AS `Value` + FROM `{FakeTables.User}` u + WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 + LIMIT 1 + "; + + var rows = await dbContext.Database + .SqlQueryRaw(sql, userId.ToString()) + .ToListAsync(cancellationToken); + if (rows.Count == 0) + { + throw new BusinessException("用户不存在"); + } + + if (rows[0] == 0) + { + throw new BusinessException("账号已禁用"); + } + } + public async Task UpdateContactAsync( Guid userId, UserProfileData profile, CancellationToken cancellationToken = default) { - const string sql = """ - UPDATE `user` + var sql = $@" + UPDATE `{FakeTables.User}` SET - `Phone` = {1}, - `WechatId` = {2}, - `WechatQrCode` = {3}, - `WecomKfUrl` = {4} - WHERE `Id` = {0} AND `IsDeleted` = 0 - """; + `Phone` = {{1}}, + `WechatId` = {{2}}, + `WechatQrCode` = {{3}}, + `WecomKfUrl` = {{4}} + WHERE `Id` = {{0}} AND `IsDeleted` = 0 + "; await dbContext.Database.ExecuteSqlRawAsync( sql, [ userId.ToString(), - (object?)NormalizeOptional(profile.Phone), - (object?)NormalizeOptional(profile.WechatId), - (object?)NormalizeOptional(profile.WechatQrCode), - (object?)NormalizeOptional(profile.WecomKfUrl), + SqlParam(NormalizeOptional(profile.Phone)), + SqlParam(NormalizeOptional(profile.WechatId)), + SqlParam(NormalizeOptional(profile.WechatQrCode)), + SqlParam(NormalizeOptional(profile.WecomKfUrl)), ], cancellationToken); } @@ -142,19 +190,19 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende { var normalized = employeeNo.Trim().ToUpperInvariant(); var sql = excludeUserId.HasValue - ? """ + ? $@" SELECT COUNT(*) AS `Value` - FROM `user` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND u.`EmployeeNo` = {0} - AND u.`Id` <> {1} - """ - : """ + AND u.`EmployeeNo` = {{0}} + AND u.`Id` <> {{1}} + " + : $@" SELECT COUNT(*) AS `Value` - FROM `user` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND u.`EmployeeNo` = {0} - """; + AND u.`EmployeeNo` = {{0}} + "; var count = excludeUserId.HasValue ? await dbContext.Database.SqlQueryRaw(sql, normalized, excludeUserId.Value.ToString()) @@ -166,13 +214,13 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public async Task GenerateNextEmployeeNoAsync(CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $@" SELECT u.`EmployeeNo` AS `EmployeeNo` - FROM `user` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 AND u.`EmployeeNo` IS NOT NULL AND u.`EmployeeNo` <> '' - """; + "; var rows = await dbContext.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); var max = 0; @@ -187,8 +235,6 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende if (int.TryParse(match.Groups[1].Value, out var number)) { - - max = Math.Max(max, number); } } @@ -209,6 +255,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende return value.Trim(); } + private static object SqlParam(object? value) => value ?? null!; + private sealed class UserProfileRow { public string? Phone { get; set; } @@ -220,6 +268,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + public int IsEnabled { get; set; } = 1; } private sealed class UserProfileListRow @@ -235,6 +285,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + public int IsEnabled { get; set; } = 1; } private sealed class EmployeeNoRow diff --git a/src/Oms.Application/Services/Channels/AutoTriggerService.cs b/src/Oms.Application/Services/Channels/AutoTriggerService.cs index 97477f5..a5cad73 100644 --- a/src/Oms.Application/Services/Channels/AutoTriggerService.cs +++ b/src/Oms.Application/Services/Channels/AutoTriggerService.cs @@ -38,6 +38,118 @@ public class AutoTriggerService : ApplicationService return new OmsPageResult { Items = items, Total = total }; } + /// + /// 下载自动触发规则导入模板。 + /// + [HttpGet("/channel/auto-trigger/import-template")] + public async Task DownloadImportTemplateAsync() + { + var bytes = await AutoTriggerImportHelper.BuildTemplateBytesAsync(); + return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + { + FileDownloadName = AutoTriggerImportHelper.TemplateFileName + }; + } + + /// + /// 导入自动触发规则(Excel)。 + /// + [HttpPost("/channel/auto-trigger/import")] + public async Task ImportAsync([FromForm] AutoTriggerImportInput input) + { + if (input.File == null || input.File.Length == 0) + { + throw new BusinessException("请上传 Excel 文件"); + } + + var importMode = NormalizeImportMode(input.ImportMode); + await using var stream = input.File.OpenReadStream(); + var parsedRows = await AutoTriggerImportHelper.ParseImportRowsAsync(stream); + if (parsedRows.Count == 0) + { + throw new BusinessException("Excel 中无有效数据行"); + } + + if (importMode == AutoTriggerImportInput.ImportModes.Full) + { + var existing = await AutoTriggerRepository.GetListAsync(_ => true); + foreach (var entity in existing) + { + await AutoTriggerRepository.DeleteAsync(entity); + } + } + + var output = new AutoTriggerImportOutput(); + var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (rowNumber, entity) in parsedRows) + { + try + { + AutoTriggerImportHelper.ValidateEntity(entity); + entity.TriggerTiming = AutoTriggerTimingCodes.ToCanonicalLabel(entity.TriggerTiming); + var key = BuildDuplicateKey(entity); + if (!seenKeys.Add(key)) + { + output.SkipCount++; + output.Failures.Add(new AutoTriggerImportFailRow + { + RowNumber = rowNumber, + Reason = "文件内规则重复,已跳过" + }); + continue; + } + + if (importMode == AutoTriggerImportInput.ImportModes.Incremental) + { + var duplicated = await AutoTriggerRepository.FirstOrDefaultAsync(x => + x.RuleName == entity.RuleName + && x.Operation == entity.Operation + && x.TriggerTiming == entity.TriggerTiming + && x.TriggerCondition == entity.TriggerCondition); + if (duplicated != null) + { + output.SkipCount++; + output.Failures.Add(new AutoTriggerImportFailRow + { + RowNumber = rowNumber, + Reason = "与已有规则重复,已跳过" + }); + continue; + } + } + + await AutoTriggerRepository.InsertAsync(entity); + output.SuccessCount++; + } + catch (Exception ex) + { + output.FailCount++; + output.Failures.Add(new AutoTriggerImportFailRow + { + RowNumber = rowNumber, + Reason = ex.Message + }); + } + } + + return output; + } + + private static string NormalizeImportMode(string? mode) + { + if (string.Equals(mode, AutoTriggerImportInput.ImportModes.Full, StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "全量", StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "replace", StringComparison.OrdinalIgnoreCase)) + { + return AutoTriggerImportInput.ImportModes.Full; + } + + return AutoTriggerImportInput.ImportModes.Incremental; + } + + private static string BuildDuplicateKey(AutoTrigger entity) => + $"{entity.RuleName}|{entity.Operation}|{entity.TriggerTiming}|{entity.TriggerCondition}"; + /// /// 获取公式字段元数据。 /// diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index dd14bec..32cd9e8 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -5,6 +5,7 @@ using Fake.Rbac.Application.Dtos.User; using Fake.Rbac.Application.Services; using Fake.Rbac.Infrastructure; using Fake.Security.Claims; +using Fake.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Customers; @@ -203,8 +204,9 @@ public class CustomerService : ApplicationService // 同步 RBAC 登录邮箱,覆盖注册时的 @noemail.local 占位 try { - await RbacDbContext.Database.ExecuteSqlInterpolatedAsync( - $"UPDATE `user` SET `Email` = {email} WHERE `userid` = {customer.UserId} AND `IsDeleted` = 0"); + var syncEmailSql = + $"UPDATE `{FakeTables.User}` SET `Email` = {{0}} WHERE `userid` = {{1}} AND `IsDeleted` = 0"; + await RbacDbContext.Database.ExecuteSqlRawAsync(syncEmailSql, email, customer.UserId); } catch { @@ -390,8 +392,8 @@ public class CustomerService : ApplicationService private async Task GetRbacUserGuidByMapIdAsync(int mapId) { - return await RbacDbContext.Database.SqlQuery( - $"select `Id` as `Value` from `user` where `userid` = {mapId} and `IsDeleted` = 0") + var sql = $"select `Id` as `Value` from `{FakeTables.User}` where `userid` = {{0}} and `IsDeleted` = 0"; + return await RbacDbContext.Database.SqlQueryRaw(sql, mapId) .FirstOrDefaultAsync(); } @@ -440,7 +442,7 @@ public class CustomerService : ApplicationService (string.IsNullOrWhiteSpace(keyword) || customer.CustomerCode.Contains(keyword) || customer.CustomerName.Contains(keyword) || - customer.Phone.Contains(keyword) || + (customer.Phone ?? string.Empty).Contains(keyword) || (customer.Email ?? string.Empty).Contains(keyword)) && (!hasStatusFilter || customer.Status == statusValue) && (!hasCustomerGroupFilter || customer.CustomerGroup == customerGroupValue) && @@ -495,8 +497,8 @@ public class CustomerService : ApplicationService private async Task GetRbacUserMapIdOrThrowAsync(Guid id) { - var userId = await RbacDbContext.Database.SqlQuery( - $"select `userid` as `Value` from `user` where `Id` = {id}") + var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}"; + var userId = await RbacDbContext.Database.SqlQueryRaw(sql, id) .FirstOrDefaultAsync(); if (!userId.HasValue || userId.Value <= 0) { diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs index bba414f..cfe2f49 100644 --- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs +++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs @@ -14,9 +14,10 @@ using Oms.Domain.OrdersAggregate.Consts; namespace Oms.Application.Services.OpenApi; /// -/// 对外开放 API(Swagger 文档分组:openApi)。 +/// 客户对外开放 API(Swagger 文档分组:openApi)。 /// 统一响应:{ code, msg, data }。 -/// 客户下单鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。 +/// 鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。 +/// 仓配 Token 接口见分组 warehouseOpenApi。 /// [ApiExplorerSettings(GroupName = "openApi")] public class OpenApiService : ApplicationService diff --git a/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs b/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs new file mode 100644 index 0000000..3788f08 --- /dev/null +++ b/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs @@ -0,0 +1,201 @@ +using Fake.Application; +using Fake.Rbac.Application.Jwt; +using Fake.Rbac.Domain.Managers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.OpenApi; +using Oms.Application.Dtos.Operations; +using Oms.Application.Rbac; +using Oms.Application.Services.Operations; +using Oms.Application.Services.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Services.OpenApi; + +/// +/// 仓配对外开放 API(Swagger 文档分组:warehouseOpenApi)。 +/// 鉴权:Authorization: {token}(账号密码换取,有效期 48 小时;无需 Bearer 前缀)。 +/// 与客户 HMAC 分组 openApi 相互独立。 +/// +[ApiExplorerSettings(GroupName = "warehouseOpenApi")] +public class WarehouseOpenApiService : ApplicationService +{ + private AccountManager AccountManager => + LazyServiceProvider.GetRequiredService(); + + private IJwtService JwtService => + LazyServiceProvider.GetRequiredService(); + + private OpenApiTokenIssuer TokenIssuer => + LazyServiceProvider.GetRequiredService(); + + private IOrderRepository OrderRepository => + LazyServiceProvider.GetRequiredService(); + + private OrderFileManager OrderFileManager => + LazyServiceProvider.GetRequiredService(); + + private InboundRecordService InboundRecordService => + LazyServiceProvider.GetRequiredService(); + + private OutboundRecordService OutboundRecordService => + LazyServiceProvider.GetRequiredService(); + + /// + /// 获取 Token(有效期 48 小时)。后续请求 Header:Authorization: {token}(无需 Bearer)。 + /// + [AllowAnonymous] + [HttpPost("/openapi/warehouse/token")] + [Consumes("application/json")] + [Produces("application/json")] + public async Task GetTokenAsync( + [FromBody] OpenApiGetTokenInput input, + CancellationToken cancellationToken = default) + { + if (input == null + || string.IsNullOrWhiteSpace(input.Username) + || string.IsNullOrWhiteSpace(input.Password)) + { + throw new BusinessException("用户名或密码不能为空"); + } + + var user = await AccountManager.ValidateCredentialsAsync( + input.Username.Trim(), + input.Password, + cancellationToken); + var profileRepository = LazyServiceProvider.GetRequiredService(); + await profileRepository.EnsureEnabledOrThrowAsync(user.Id, cancellationToken); + var claims = await JwtService.GenerateClaimsByUserIdAsync(user.Id, cancellationToken); + return TokenIssuer.Issue(claims); + } + + /// + /// 入库上传件重尺。与操作台「入库扫描」页「入库」按钮同源(POST /operation/inbound/create)。 + /// 写入收货件重尺、入库记录,并触发入库计费/自动触发。重量默认 kg,长度默认 cm。 + /// Header:Authorization: {token}(无需 Bearer)。 + /// + [Authorize] + [HttpPost("/openapi/warehouse/inbound")] + [Consumes("application/json")] + [Produces("application/json")] + public Task InboundAsync([FromBody] OpenApiUploadWeightInput input) => + UploadWeightCoreAsync(input, forceMode: 0); + + /// + /// 上传件重尺。mode:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。 + /// Header:Authorization: {token}(无需 Bearer)。 + /// + [Authorize] + [HttpPost("/openapi/warehouse/weight/upload")] + [Consumes("application/json")] + [Produces("application/json")] + public Task UploadWeightAsync([FromBody] OpenApiUploadWeightInput input) => + UploadWeightCoreAsync(input, forceMode: null); + + private async Task UploadWeightCoreAsync( + OpenApiUploadWeightInput? input, + int? forceMode) + { + try + { + if (input == null || string.IsNullOrWhiteSpace(input.OrderNo)) + { + return OpenApiSimpleResult.Fail("订单号不能为空"); + } + + var mode = forceMode ?? input.Mode; + var weightKg = OpenApiMeasureUnitConverter.ToKilograms(input.Weight, input.WeightUnit); + var lengthCm = OpenApiMeasureUnitConverter.ToCentimeters(input.Length, input.LengthUnit); + var widthCm = OpenApiMeasureUnitConverter.ToCentimeters(input.Width, input.LengthUnit); + var heightCm = OpenApiMeasureUnitConverter.ToCentimeters(input.Height, input.LengthUnit); + var orderNo = input.OrderNo.Trim(); + + if (weightKg <= 0 || lengthCm <= 0 || widthCm <= 0 || heightCm <= 0) + { + return OpenApiSimpleResult.Fail("实重与长宽高须大于 0"); + } + + if (mode == 1) + { + await OutboundRecordService.CreateAsync(new CreateOutboundRecordInput + { + WaybillNo = orderNo, + Weight = weightKg, + Length = lengthCm, + Width = widthCm, + Height = heightCm + }); + return OpenApiSimpleResult.Ok("出库成功"); + } + + if (mode == 0) + { + // 与入库扫描页「入库」按钮同一实现:InboundRecordService.CreateAsync + var result = await InboundRecordService.CreateAsync(new CreateInboundRecordInput + { + WaybillNo = orderNo, + Weight = weightKg, + Length = lengthCm, + Width = widthCm, + Height = heightCm + }); + var statusName = string.IsNullOrWhiteSpace(result.OrderStatusName) + ? "入库成功" + : $"入库成功({result.OrderStatusName})"; + return OpenApiSimpleResult.Ok(statusName); + } + + return OpenApiSimpleResult.Fail("mode 仅支持 0(入库)或 1(出库)"); + } + catch (BusinessException ex) + { + return OpenApiSimpleResult.Fail(ex.Message); + } + catch (Exception ex) + { + return OpenApiSimpleResult.Fail( + string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message); + } + } + + /// + /// 上传红外图片。Base64 解码后转存 COS,写入订单附件 fileType=infraredImage(Source=upload), + /// 与订单详情「附件」Tab →「红外图片」→「点击上传」(POST /order/files/upload)同源展示。 + /// 成功无响应体。Header:Authorization: {token}(无需 Bearer)。 + /// + [Authorize] + [HttpPost("/openapi/warehouse/image/upload")] + [Consumes("application/json")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task UploadImageAsync( + [FromBody] OpenApiUploadImageInput input, + CancellationToken cancellationToken = default) + { + if (input == null || string.IsNullOrWhiteSpace(input.OrderNo)) + { + throw new BusinessException("订单号不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.ImageBase64)) + { + throw new BusinessException("图片内容不能为空"); + } + + var order = await OrderRepository.GetByWaybillNoAsync(input.OrderNo.Trim(), cancellationToken) + ?? throw new BusinessException("未找到对应订单,请检查单号"); + + var operatorUserId = CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0; + // 固定红外图片类型,路径 uploads/order-files/{orderId}/infraredImage/... + await OrderFileManager.AddFromBase64Async( + order.Id, + OrderFileTypeCodes.InfraredImage, + input.ImageBase64, + operatorUserId, + cancellationToken); + + return new NoContentResult(); + } +} diff --git a/src/Oms.Application/Services/Operations/InboundRecordService.cs b/src/Oms.Application/Services/Operations/InboundRecordService.cs index 0770e99..30ead03 100644 --- a/src/Oms.Application/Services/Operations/InboundRecordService.cs +++ b/src/Oms.Application/Services/Operations/InboundRecordService.cs @@ -82,7 +82,7 @@ public class InboundRecordService : ApplicationService } /// - /// 新增入库记录。 + /// 新增入库记录(入库扫描页「入库」按钮;仓配开放接口 POST /openapi/warehouse/inbound 同源)。 /// /// 入参:入库记录创建参数。 /// 入库记录结果。 diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs index 9b04635..071ad64 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Oms.Application.Dtos.Orders; using Oms.Application.Messaging; +using Oms.Application.Services.Orders; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; @@ -94,6 +95,7 @@ public sealed class OrderBatchJobProcessor( await OrderBatchJobItemFeedback.EnrichIdentifiersAsync(item, action, orderRepository, cancellationToken); OrderBatchJobItemFeedback.ApplyBusinessFailure(item, result, action); await batchJobRepository.UpdateItemAsync(item, cancellationToken); + await TryStampForecastBillingFailureAsync(message, item.FailReason, cancellationToken); await RefreshJobSummaryAsync(item.JobId, cancellationToken); return; @@ -111,6 +113,7 @@ public sealed class OrderBatchJobProcessor( action, item.RetryCount, item.FailReason); + await TryStampForecastBillingFailureAsync(message, item.FailReason, cancellationToken); await RefreshJobSummaryAsync(item.JobId, cancellationToken); return; } @@ -148,6 +151,43 @@ public sealed class OrderBatchJobProcessor( return null; } + /// + /// 预报计费重试耗尽或业务失败时,把原因写回订单,避免一直显示「处理中」。 + /// 更具体的失败原因已经写上时不再覆盖。 + /// + private async Task TryStampForecastBillingFailureAsync( + OrderBatchJobMessage message, + string? reason, + CancellationToken cancellationToken) + { + if (!string.Equals(message.Action, OrderBatchActionCodes.ForecastBilling, StringComparison.OrdinalIgnoreCase) + || message.OrderId <= 0) + { + return; + } + + var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == message.OrderId, cancellationToken); + if (order == null + || !string.Equals( + order.PredeclareFailReason, + OrderService.ForecastBillingProcessingReason, + StringComparison.Ordinal)) + { + return; + } + + var text = string.IsNullOrWhiteSpace(reason) ? "预报计费失败" : reason.Trim(); + if (text.Length > 512) + { + text = text[..512]; + } + + order.OrderStatus = OrderStatusCodes.Draft; + order.PredeclareFailReason = text; + order.UpdateTime = DateTime.Now; + await orderRepository.UpdateAsync(order, cancellationToken); + } + private async Task RefreshJobSummaryAsync(int jobId, CancellationToken cancellationToken) { var job = await batchJobRepository.GetByIdAsync(jobId, cancellationToken); diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs index f363cd8..664bb91 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs @@ -72,7 +72,8 @@ public sealed class OrderBatchOrderActionHandler(IServiceScopeFactory scopeFacto message.OrderId, message.PayloadJson, message.OperatorUserId, - cancellationToken); + cancellationToken, + message.Authorization); return OrderBatchActionExecuteResult.Ok(); case OrderBatchActionCodes.Reallocate: diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs index 356230a..89cdeb9 100644 --- a/src/Oms.Application/Services/Orders/OrderFileManager.cs +++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs @@ -363,6 +363,221 @@ public sealed class OrderFileManager( return ".pdf"; } + /// + /// 从 Base64 写入订单附件:先转存对象存储(COS),再落 t_order_file。 + /// 与订单详情附件 Tab「点击上传」(POST /order/files/upload)同源校验与路径约定。 + /// + public async Task AddFromBase64Async( + int orderId, + string fileType, + string imageBase64, + int operatorUserId, + CancellationToken cancellationToken = default) + { + if (!OrderFileTypeCodes.IsValid(fileType)) + { + throw new BusinessException("不支持的附件类型"); + } + + var type = OrderFileTypeCodes.Normalize(fileType); + if (string.IsNullOrWhiteSpace(imageBase64)) + { + throw new BusinessException("图片内容不能为空"); + } + + var (bytes, contentType, ext) = DecodeBase64Image(imageBase64); + if (bytes.Length == 0) + { + throw new BusinessException("图片内容不能为空"); + } + + var existing = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken); + var max = OrderFileTypeCodes.GetMaxCount(type); + if (existing.Count >= max) + { + throw new BusinessException($"类型 {type} 最多 {max} 个文件"); + } + + var fileName = $"{type}_{DateTime.Now:yyyyMMddHHmmss}{ext}"; + var savedPath = await SaveBytesAsync(bytes, fileName, contentType, orderId, type, cancellationToken); + + var sort = existing.Count == 0 ? 0 : existing.Max(x => x.Sort) + 1; + var entity = new OrderFile + { + OrderId = orderId, + FileType = type, + FileName = savedPath.FileName, + FileUrl = savedPath.Url, + ContentType = savedPath.ContentType, + FileSize = savedPath.Size, + Sort = sort, + Source = OrderFileSourceCodes.Upload, + CreateUserId = operatorUserId, + CreateTime = DateTime.Now + }; + var saved = await orderFileRepository.InsertAsync(entity, cancellationToken); + return MapItem(saved); + } + + private static (byte[] Bytes, string ContentType, string Ext) DecodeBase64Image(string imageBase64) + { + var raw = imageBase64.Trim(); + string? headerContentType = null; + + if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + var comma = raw.IndexOf(','); + if (comma <= 0) + { + throw new BusinessException("图片 Base64 格式错误"); + } + + var header = raw[..comma]; + raw = raw[(comma + 1)..]; + var mimeStart = header.IndexOf(':'); + var mimeEnd = header.IndexOf(';'); + if (mimeStart >= 0 && mimeEnd > mimeStart) + { + headerContentType = header[(mimeStart + 1)..mimeEnd].Trim(); + } + } + + byte[] bytes; + try + { + bytes = Convert.FromBase64String(raw); + } + catch (FormatException) + { + throw new BusinessException("图片 Base64 解码失败"); + } + + var sniffed = SniffImageFormat(bytes); + var contentType = headerContentType ?? sniffed.ContentType; + var ext = sniffed.Ext; + if (!string.IsNullOrWhiteSpace(headerContentType)) + { + ext = headerContentType.ToLowerInvariant() switch + { + "image/png" => ".png", + "image/webp" => ".webp", + "image/gif" => ".gif", + "image/bmp" => ".bmp", + "application/pdf" => ".pdf", + "image/jpeg" or "image/jpg" => ".jpg", + _ => sniffed.Ext + }; + } + + return (bytes, contentType, ext); + } + + private static (string ContentType, string Ext) SniffImageFormat(byte[] bytes) + { + if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) + { + return ("image/jpeg", ".jpg"); + } + + if (bytes.Length >= 8 + && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) + { + return ("image/png", ".png"); + } + + if (bytes.Length >= 6 + && bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) + { + return ("image/gif", ".gif"); + } + + if (bytes.Length >= 12 + && bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 + && bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) + { + return ("image/webp", ".webp"); + } + + if (bytes.Length >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D) + { + return ("image/bmp", ".bmp"); + } + + if (bytes.Length >= 5 + && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46) + { + return ("application/pdf", ".pdf"); + } + + return ("image/jpeg", ".jpg"); + } + + /// + /// 与 multipart 上传共用:校验扩展名/大小后写入 COS,返回逻辑 object key。 + /// + private async Task<(string Url, string FileName, string? ContentType, long Size)> SaveBytesAsync( + byte[] bytes, + string fileName, + string? contentType, + int orderId, + string fileType, + CancellationToken cancellationToken = default) + { + if (bytes == null || bytes.Length <= 0) + { + throw new BusinessException("上传文件不能为空"); + } + + var ext = Path.GetExtension(fileName); + if (string.IsNullOrWhiteSpace(ext)) + { + ext = ".bin"; + } + + var isLabelType = fileType is OrderFileTypeCodes.SystemLabel + or OrderFileTypeCodes.DownstreamLabel + or OrderFileTypeCodes.LastMileLabel; + var isInfraredOrProof = fileType is OrderFileTypeCodes.InfraredImage + or OrderFileTypeCodes.TradeProof + or OrderFileTypeCodes.Pod; + + if (isLabelType) + { + if (!LabelExt.Contains(ext)) + { + throw new BusinessException("面单仅支持 pdf / jpg / png"); + } + + if (string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && bytes.Length > MaxPdfBytes) + { + throw new BusinessException("PDF 不能超过 10MB"); + } + + if (!string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && bytes.Length > MaxImageBytes) + { + throw new BusinessException("图片不能超过 5MB"); + } + } + else if (isInfraredOrProof) + { + if (!ImageExt.Contains(ext) && !string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("交易证明/红外图片/POD 仅支持图片或 pdf"); + } + + if (bytes.Length > MaxPdfBytes) + { + throw new BusinessException("文件不能超过 10MB"); + } + } + + var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; + var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}"; + await using var stream = new MemoryStream(bytes); + var key = await fileStorage.SaveObjectAsync(stream, relativeKey, contentType, cancellationToken); + return (key, Path.GetFileName(fileName), contentType, bytes.Length); + } + public async Task> UploadAsync( int orderId, string fileType, @@ -469,54 +684,15 @@ public sealed class OrderFileManager( throw new BusinessException("上传文件不能为空"); } - var ext = Path.GetExtension(file.FileName); - if (string.IsNullOrWhiteSpace(ext)) - { - ext = ".bin"; - } - - var isLabelType = fileType is OrderFileTypeCodes.SystemLabel - or OrderFileTypeCodes.DownstreamLabel - or OrderFileTypeCodes.LastMileLabel; - var isInfraredOrProof = fileType is OrderFileTypeCodes.InfraredImage - or OrderFileTypeCodes.TradeProof - or OrderFileTypeCodes.Pod; - - if (isLabelType) - { - if (!LabelExt.Contains(ext)) - { - throw new BusinessException("面单仅支持 pdf / jpg / png"); - } - - if (string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && file.Length > MaxPdfBytes) - { - throw new BusinessException("PDF 不能超过 10MB"); - } - - if (!string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && file.Length > MaxImageBytes) - { - throw new BusinessException("图片不能超过 5MB"); - } - } - else if (isInfraredOrProof) - { - if (!ImageExt.Contains(ext) && !string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase)) - { - throw new BusinessException("交易证明/红外图片/POD 仅支持图片或 pdf"); - } - - if (file.Length > MaxPdfBytes) - { - throw new BusinessException("文件不能超过 10MB"); - } - } - - var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; - var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}"; await using var stream = file.OpenReadStream(); - var key = await fileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType); - return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length); + await using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + return await SaveBytesAsync( + ms.ToArray(), + file.FileName, + file.ContentType, + orderId, + fileType); } private OrderFileItemOutput MapItem(OrderFile x) => diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 9d3b15a..56ef739 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -1,7 +1,9 @@ using System.Collections.Concurrent; using Fake.Application; +using Fake.UnitOfWork; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Oms.Application.Dtos.Channels; @@ -38,7 +40,7 @@ public partial class OrderService private const int ParcelBatchMaxCount = 2000; private const string InboundBillingProcessingReason = "入库后计费/排货处理中"; private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费"; - private const string ForecastBillingProcessingReason = "预报计费/排货处理中"; + internal const string ForecastBillingProcessingReason = "预报计费/排货处理中"; private const string ForecastBillingEnqueueFailReason = "预报计费任务投递失败,请重新预报"; private static readonly ConcurrentDictionary InboundBillingInFlight = new(); @@ -76,6 +78,28 @@ public partial class OrderService public string? InboundMethod { get; init; } } + private static ParcelCreateContext? BuildParcelCreateContext(OrderParcelCreateInput input) + { + var inbound = string.IsNullOrWhiteSpace(input.InboundMethod) + ? null + : input.InboundMethod.Trim(); + if (inbound == null) + { + return null; + } + + var isDoorPickup = string.Equals( + inbound, + OrderInboundMethodCodes.DoorPickup, + StringComparison.OrdinalIgnoreCase); + return new ParcelCreateContext + { + InboundMethod = inbound, + PickupTime = isDoorPickup ? input.ExpectPickupTime : null, + CustomerSenderAddressId = isDoorPickup ? input.SenderAddressId : null + }; + } + private async Task CreateParcelCoreAsync( OrderParcelCreateInput input, int? operatorUserId = null, @@ -92,6 +116,7 @@ public partial class OrderService var bindTime = DateTime.Now; NormalizeParcelCreateInput(input); + createContext ??= BuildParcelCreateContext(input); var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId); input.CustomerId = sessionCustomer.Id; @@ -127,6 +152,7 @@ public partial class OrderService country.ChineseName); var serviceNo = await ResolveServiceNoAsync(); + var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); Order? created = null; try { @@ -144,11 +170,12 @@ public partial class OrderService var weights = BuildInitialWeightRows(order.Id, input); await SaveParcelChildrenAsync(order, input, weights, country); - // 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。 + // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收在事务提交后各自执行。 if (input.SaveAsDraft) { - await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode); - return await BuildParcelCreateOutputAsync(order); + var draftOutput = await BuildParcelCreateOutputAsync(order); + await ScheduleCreateSideEffectsAsync(order.Id, input, draftOutput, includeForecast: false, operatorId); + return draftOutput; } // 偏远标记轻量同步;计费重/应收/排货走 ForecastBilling MQ(失败不回滚建单)。 @@ -165,33 +192,19 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode); - await EnqueueForecastBillingAsync( - order, - input.CouponInstanceId, - operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); - var output = await BuildParcelCreateOutputAsync(order); - output.ForecastBillingQueued = string.Equals( - order.PredeclareFailReason, - ForecastBillingProcessingReason, - StringComparison.Ordinal); + await ScheduleCreateSideEffectsAsync(order.Id, input, output, includeForecast: true, operatorId); return output; } + catch (BusinessException) + { + await TryRollbackFailedParcelCreateAsync(created); + throw; + } catch (Exception ex) { // 创建失败不落库草稿:回滚已插入订单,由前端让客户选择继续修改或主动保存草稿。 - if (created != null) - { - try - { - await RollbackFailedParcelCreateAsync(created); - } - catch - { - // 回滚失败不覆盖原始业务错误;半成品订单需运维清理 - } - } + await TryRollbackFailedParcelCreateAsync(created); var cleanReason = StripDraftSavedMarker(ex.Message); if (string.IsNullOrWhiteSpace(cleanReason)) @@ -203,6 +216,23 @@ public partial class OrderService } } + private async Task TryRollbackFailedParcelCreateAsync(Order? created) + { + if (created == null) + { + return; + } + + try + { + await RollbackFailedParcelCreateAsync(created); + } + catch + { + // 回滚失败不覆盖原始业务错误;半成品订单需运维清理 + } + } + /// /// 创建流程中途失败时删除已落库的半成品订单,避免自动变成草稿。 /// @@ -2298,10 +2328,30 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - await EnqueueForecastBillingAsync( - order, - order.CouponInstanceId, - operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); + var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); + var authorization = CaptureRequestAuthorization(); + var publisher = LazyServiceProvider.GetRequiredService(); + if (!publisher.IsEnabled) + { + var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload + { + CouponInstanceId = order.CouponInstanceId, + Authorization = authorization + }); + await ProcessForecastBillingForBatchAsync( + order.Id, + payloadJson, + operatorId, + authorization: authorization); + } + else + { + var orderId = order.Id; + var couponInstanceId = order.CouponInstanceId; + await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId, authorization); + } + + await ScheduleForecastSideEffectsAsync(order.Id, operatorId); // 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。 var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); @@ -2619,10 +2669,17 @@ public partial class OrderService int orderId, string? payloadJson = null, int operatorUserId = 0, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? authorization = null) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); if (order == null) + { + // 创单事务尚未提交时消息可能先到。抛出让总线重试,避免当成成功 Ack 后订单永远停在处理中。 + throw new InvalidOperationException($"预报计费时订单尚未可见,稍后重试 OrderId={orderId}"); + } + + if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) { return; } @@ -2637,6 +2694,7 @@ public partial class OrderService var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); int? couponInstanceId = order.CouponInstanceId; + string? trackAuthorization = authorization; if (!string.IsNullOrWhiteSpace(payloadJson)) { try @@ -2646,6 +2704,12 @@ public partial class OrderService { couponInstanceId = payload.CouponInstanceId; } + + if (string.IsNullOrWhiteSpace(trackAuthorization) + && !string.IsNullOrWhiteSpace(payload?.Authorization)) + { + trackAuthorization = payload.Authorization.Trim(); + } } catch { @@ -2747,7 +2811,7 @@ public partial class OrderService } // 排货失败不阻断预报成功(与原创单同步路径一致) - await TryConfirmServiceChannelAndPayableAsync( + var allocated = await TryConfirmServiceChannelAndPayableAsync( order, product, billing, @@ -2778,6 +2842,13 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); + // 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。 + // 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。 + if (allocated && !string.IsNullOrWhiteSpace(order.ChannelCode)) + { + await ScheduleTrackAfterAllocationAsync(order.Id, trackAuthorization); + } + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); await OperationLogWriter.TryWriteAsync( order, @@ -2789,38 +2860,222 @@ public partial class OrderService operatorName: null, operatorType: "staff", source: "job"); - - var customer = order.CustomerId.HasValue - ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) - : null; - await PublishParcelCreateTrackAndLabelAsync( - order, - customer, - receiver: null, - appendInitialTrack: true, - operatorUserId); - - await AutoTriggerRuntimeService.EnqueueFireAsync( - order.Id, - AutoTriggerTiming.OrderCreate, - operatorUserId); } private sealed class ForecastBillingJobPayload { public int? CouponInstanceId { get; set; } + + /// + /// 创单/预报请求的 Authorization,供排货后异步创建轨迹订单转发到轨迹网关。 + /// + public string? Authorization { get; set; } + } + + /// + /// 创单事务提交后,9610、预报计费、揽收、面单、自动触发各自提交。 + /// 轨迹订单等排货成功后再创建。 + /// + private async Task ScheduleCreateSideEffectsAsync( + int orderId, + OrderParcelCreateInput input, + OrderParcelCreateOutput output, + bool includeForecast, + int operatorUserId) + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderParcelCreate"); + var platformCode = input.PlatformCode; + var couponInstanceId = input.CouponInstanceId; + // 事务提交后 HttpContext 已不可用,这里先捕获登录态供轨迹网关使用。 + var authorization = CaptureRequestAuthorization(); + + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => + { + try + { + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp => + sp.GetRequiredService().PublishCustomsForOrderAsync(orderId, platformCode)); + + var forecastQueued = true; + if (includeForecast) + { + forecastQueued = await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp => + { + var service = sp.GetRequiredService(); + await service.EnqueueForecastBillingByIdAsync( + orderId, + couponInstanceId, + operatorUserId, + authorization); + }); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => + sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => + sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)); + } + + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp => + sp.GetRequiredService().TryAttachPickupAfterParcelCreateAsync(input, output)); + + if (includeForecast && !forecastQueued) + { + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp => + sp.GetRequiredService().MarkForecastEnqueueFailedIfStillProcessingAsync(orderId)); + } + } + catch (Exception ex) + { + logger.LogError(ex, "创单后置步骤调度失败 OrderId={OrderId}", orderId); + } + }); + } + + private async Task ScheduleForecastEnqueueAsync( + int orderId, + int? couponInstanceId, + int operatorUserId, + string? authorization) + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => + OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", sp => + sp.GetRequiredService().EnqueueForecastBillingByIdAsync( + orderId, + couponInstanceId, + operatorUserId, + authorization))); + } + + /// + /// 草稿重新预报提交后,面单和下单自动触发各自入队。轨迹等排货成功后再创建。 + /// + private async Task ScheduleForecastSideEffectsAsync(int orderId, int operatorUserId) + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => + { + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => + sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => + sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)); + }); + } + + /// + /// 预报计费事务提交后,再创建轨迹订单(此时已有渠道和 ApiCode)。 + /// + private async Task ScheduleTrackAfterAllocationAsync(int orderId, string? authorization) + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => + OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => + sp.GetRequiredService().PublishForecastTrackAsync(orderId, authorization))); + } + + private async Task PublishCustomsForOrderAsync(int orderId, string? platformCode) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode); + } + + private async Task EnqueueForecastBillingByIdAsync( + int orderId, + int? couponInstanceId, + int operatorUserId, + string? authorization) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId, authorization); + } + + private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || !string.Equals(order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal)) + { + return; + } + + await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); + } + + private async Task PublishForecastTrackAsync(int orderId, string? authorization = null) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase) + || string.IsNullOrWhiteSpace(order.ChannelCode)) + { + return; + } + + var customer = order.CustomerId.HasValue + ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) + : null; + var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); + request.AppendInitialTrackOnSuccess = true; + ApplyTrackAuthorization(request, authorization); + await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); + } + + private async Task PublishForecastLabelAsync(int orderId, int operatorUserId) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await ParcelCreateEventPublisher.PublishLabelGenerateAsync(order, operatorUserId); + } + + private async Task PublishForecastAutoTriggerAsync(int orderId, int operatorUserId) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await AutoTriggerRuntimeService.EnqueueFireAsync(orderId, AutoTriggerTiming.OrderCreate, operatorUserId); } private async Task EnqueueForecastBillingAsync( Order order, int? couponInstanceId, - int operatorUserId) + int operatorUserId, + string? authorization = null) { var logger = LazyServiceProvider.GetRequiredService() .CreateLogger("Oms.ForecastBilling"); + authorization ??= CaptureRequestAuthorization(); var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload { - CouponInstanceId = couponInstanceId + CouponInstanceId = couponInstanceId, + Authorization = authorization }); try @@ -2830,7 +3085,11 @@ public partial class OrderService { try { - await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorUserId); + await ProcessForecastBillingForBatchAsync( + order.Id, + payloadJson, + operatorUserId, + authorization: authorization); } catch (Exception ex) { @@ -2856,7 +3115,8 @@ public partial class OrderService RequestId = requestId, PayloadJson = payloadJson }, - operatorUserId); + operatorUserId, + authorization); } catch (Exception ex) { @@ -3143,40 +3403,8 @@ public partial class OrderService private IOrderSideEffectEventPublisher SideEffectEventPublisher => LazyServiceProvider.GetRequiredService(); - /// - /// 创单/草稿预报成功后:轨迹订单+A0101 合并事件,系统面单独立事件(Fake.EventBus.RabbitMQ)。 - /// - private async Task PublishParcelCreateTrackAndLabelAsync( - Order order, - Customer? customer, - OrderParcelReceiverInput? receiver, - bool appendInitialTrack, - int? operatorUserId = null) - { - var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); - request.AppendInitialTrackOnSuccess = appendInitialTrack; - var authorization = LazyServiceProvider - .GetRequiredService() - .HttpContext? - .Request - .Headers - .Authorization - .ToString(); - if (!string.IsNullOrWhiteSpace(authorization)) - { - request.Authorization = authorization; - } - - await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); - await ParcelCreateEventPublisher.PublishLabelGenerateAsync( - order, - operatorUserId - ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); - } - /// /// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。 - /// 入库等非创单路径仍可调用;创单路径请走 。 /// private async Task EnqueueSmallBagTrackUpsertAsync( Order order, @@ -3186,19 +3414,42 @@ public partial class OrderService { var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); request.AppendInitialTrackOnSuccess = appendInitialTrack; - var authorization = LazyServiceProvider - .GetRequiredService() - .HttpContext? - .Request - .Headers - .Authorization - .ToString(); + ApplyTrackAuthorization(request); + await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); + } + + private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request, string? authorization = null) + { if (!string.IsNullOrWhiteSpace(authorization)) { - request.Authorization = authorization; + request.Authorization = authorization.Trim(); + return; } - await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); + var fromRequest = CaptureRequestAuthorization(); + if (!string.IsNullOrWhiteSpace(fromRequest)) + { + request.Authorization = fromRequest; + } + } + + private string? CaptureRequestAuthorization() + { + try + { + var authorization = LazyServiceProvider + .GetRequiredService() + .HttpContext? + .Request + .Headers + .Authorization + .ToString(); + return string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim(); + } + catch (ObjectDisposedException) + { + return null; + } } /// diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 6bcbac0..1932297 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -603,38 +603,40 @@ public partial class OrderService : ApplicationService } /// - /// 创建订单包裹。 + /// 创建订单包裹(PC/H5 确认下单或保存草稿)。 + /// + /// 【同步,本接口事务内】RequestId 幂等 → 校验入参/销售产品 → 黑名单 → + /// 自动触发「拦截」规则(命中直接失败)→ 生成运单号 → 落库订单与子表。 + /// 确认下单:同步写偏远标记,状态草稿 +「预报计费/排货处理中」后返回; + /// 保存草稿:仅落库,不做预报计费。失败会回滚订单,不落草稿。 + /// + /// + /// 【创单事务提交后各自投递 MQ(互不等待;失败不回滚订单)】 + /// 1) 9610 订单 Upsert(customs-9610)— 草稿与确认下单都会投; + /// 2) 预报计费/排货任务(forecast-billing 批量任务)— 仅确认下单; + /// 队列关闭时在后台同步跑同一套计费逻辑; + /// 3) 系统面单 PDF(label-generate)— 仅确认下单; + /// 4) 下单自动触发(OrderCreate:警告/问题件等,不含拦截)— 仅确认下单; + /// 5) 上门揽收挂载(pickup-attach)— 仅上门揽收且地址/时间/仓库齐全时; + /// 投递失败则同步回落建揽收单。 + /// + /// + /// 【预报计费消费后】计费重、应收、排货应付、营销快照 → 订单改已预报。 + /// 排货成功且已有 ChannelCode 时,再投递轨迹订单 + A0101(track-upsert); + /// 轨迹网关需要登录 Authorization,创单时写入预报任务 Payload,消费端原样转发; + /// 排货失败不阻断预报,也不建轨迹(可页面补做)。 + /// /// - /// 入参:订单包裹创建参数。 - /// 订单包裹创建结果。 + /// 入参:订单包裹创建参数。SaveAsDraft=true 为仅保存草稿。 + /// + /// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已排队; + /// 上门揽收时 PickupQueued / PickupNo 视投递或同步回落结果而定。 + /// [HttpPost("/order/parcel/create")] [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public async Task CreateParcelAsync(OrderParcelCreateInput input) { - var inbound = string.IsNullOrWhiteSpace(input.InboundMethod) - ? null - : input.InboundMethod.Trim(); - var createContext = inbound == null - ? null - : new ParcelCreateContext - { - InboundMethod = inbound, - PickupTime = string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase) - ? input.ExpectPickupTime - : null, - CustomerSenderAddressId = string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase) - ? input.SenderAddressId - : null - }; - - var output = await CreateParcelCoreAsync(input, createContext: createContext); - - if (string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase)) - { - await TryAttachPickupAfterParcelCreateAsync(input, output); - } - - return output; + return await CreateParcelCoreAsync(input, createContext: BuildParcelCreateContext(input)); } /// @@ -687,6 +689,14 @@ public partial class OrderService : ApplicationService return; } + var inbound = string.IsNullOrWhiteSpace(input.InboundMethod) + ? null + : input.InboundMethod.Trim(); + if (!string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase)) + { + return; + } + if (input.SenderAddressId is not > 0) { output.PickupCreateError = "上门揽收需选择揽收地址"; diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index e73d78d..1f836a7 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -525,12 +525,12 @@ internal static class ParcelBillingCalculator priceSnapshot.Prices.Select(p => (p.Status, p.Details))); private static IReadOnlyList CollectDistinctDetailZoneKeys( - IEnumerable<(byte Status, List? Details)> prices) + IEnumerable<(byte Status, List Details)> prices) { var zones = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var price in prices.Where(x => EnableStatusRules.IsEnabled(x.Status))) { - foreach (var detail in price.Details ?? []) + foreach (var detail in price.Details) { var factors = ParseFactorDictionary(detail.FactorValuesJson); if (TryGetZoneFactorText(factors, out var zone)) diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs index d812708..e949a82 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs @@ -5,6 +5,7 @@ using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.PlatformStore; using Oms.Application.Dtos.Shared; using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; @@ -409,7 +410,8 @@ public class PlatformStoreOrderService : ApplicationService input.ProductCode, input.WarehouseId, input.Items, - requireBind: false); + requireBind: false, + requireWmsProductGroup: false); } /// @@ -429,7 +431,30 @@ public class PlatformStoreOrderService : ApplicationService input.ProductCode, input.WarehouseId, input.Items, - requireBind: true); + requireBind: true, + requireWmsProductGroup: false); + } + + /// + /// 批量创建仓储代发订单:与代发运单同链路(建单+绑定+产品计费), + /// 但产品必须属于分组 wms(仓储代发)。 + /// + [HttpPost("/order/platform-store-order/batch-create-fulfill-wms")] + public async Task BatchCreateFulfillWmsAsync( + PlatformStoreOrderBatchCreateFulfillInput input) + { + if (input.WarehouseId <= 0) + { + throw new BusinessException("请指定发货仓库"); + } + + return await BatchCreateLogisticsInternalAsync( + input.CustomerId, + input.ProductCode, + input.WarehouseId, + input.Items, + requireBind: true, + requireWmsProductGroup: true); } private async Task BatchCreateLogisticsInternalAsync( @@ -437,7 +462,8 @@ public class PlatformStoreOrderService : ApplicationService string? productCode, int? warehouseId, List items, - bool requireBind) + bool requireBind, + bool requireWmsProductGroup = false) { if (items == null || items.Count == 0) { @@ -451,14 +477,14 @@ public class PlatformStoreOrderService : ApplicationService if (string.IsNullOrWhiteSpace(productCode)) { - throw new BusinessException("请选择运输方式"); + throw new BusinessException(requireWmsProductGroup ? "请选择仓储代发产品" : "请选择运输方式"); } var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( CustomerRepository, CurrentUser, customerIdInput); - var resolvedProductCode = await ResolveProductCodeRequiredAsync(productCode); + var resolvedProductCode = await ResolveProductCodeRequiredAsync(productCode, requireWmsProductGroup); var batchNo = $"PSL{DateTime.Now:yyyyMMddHHmmss}"; var output = new PlatformStoreOrderBatchCreateLogisticsOutput { BatchNo = batchNo }; @@ -488,9 +514,11 @@ public class PlatformStoreOrderService : ApplicationService itemInput.Receiver, requireBind ? itemInput.BindItems : null); item.Success = true; - item.Message = requireBind - ? $"已创建物流单 {result.ServiceNo} 并绑定" - : $"已创建物流单 {result.ServiceNo}"; + item.Message = requireWmsProductGroup + ? $"已创建代发订单 {result.ServiceNo} 并绑定" + : requireBind + ? $"已创建物流单 {result.ServiceNo} 并绑定" + : $"已创建物流单 {result.ServiceNo}"; item.LinkedOrderId = result.OrderId; item.ServiceNo = result.ServiceNo; output.SuccessCount++; @@ -523,7 +551,7 @@ public class PlatformStoreOrderService : ApplicationService List? bindItems) { var order = await GetOrderOrThrowAsync(customerId, storeOrderId); - await EnsureMatchableAsync(order); + await EnsureCanCreateLogisticsAsync(order); var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id && !x.IsShipExcluded); if (lines.Count == 0) @@ -639,7 +667,9 @@ public class PlatformStoreOrderService : ApplicationService await OrderRepository.UpdateAsync(logisticsOrder); } - private async Task ResolveProductCodeRequiredAsync(string productCode) + private async Task ResolveProductCodeRequiredAsync( + string productCode, + bool requireWmsProductGroup = false) { var normalized = productCode.Trim(); var product = await SalesProductRepository.GetByProductCodeAsync(normalized); @@ -648,6 +678,15 @@ public class PlatformStoreOrderService : ApplicationService throw new BusinessException("销售产品不存在或未启用"); } + if (requireWmsProductGroup + && !string.Equals( + product.ProductGroup?.Trim(), + SalesProductGroupCodes.Wms, + StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("仓储代发订单仅可选择产品分组为「仓储代发(wms)」的销售产品"); + } + return normalized; } @@ -734,6 +773,26 @@ public class PlatformStoreOrderService : ApplicationService } } + /// + /// 创建运单/代发:不限制履约状态;拦截搁置与未删除的已关联物流单。 + /// + private async Task EnsureCanCreateLogisticsAsync(SalesStoreOrder order) + { + if (order.IsSuspended) + { + throw new BusinessException("订单已搁置,不能创建"); + } + + if (order.LinkedOrderId is > 0) + { + var linked = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.LinkedOrderId.Value); + if (linked != null && linked.OrderStatus != OrderStatusCodes.Deleted) + { + throw new BusinessException($"已关联小包订单 {linked.ServiceNo},不能重复创建"); + } + } + } + private async Task EnsureMatchableAsync(SalesStoreOrder order) { if (order.IsSuspended) diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs index 46a3439..15d945c 100644 --- a/src/Oms.Application/Services/Rbac/OmsUserService.cs +++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs @@ -1,5 +1,7 @@ +using System.Security.Claims; using Fake.Application; using Fake.Domain.Exceptions; +using Fake.ObjectStorage; using Fake.Rbac.Application.Dtos.Common; using Fake.Rbac.Application.Dtos.Role; using Fake.Rbac.Application.Dtos.User; @@ -8,7 +10,6 @@ using Fake.Rbac.Domain.Managers; using Fake.Rbac.Infrastructure; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Fake.ObjectStorage; using Oms.Application.Dtos.Rbac; using Oms.Application.Rbac; using Oms.Application.Shared; @@ -157,6 +158,7 @@ public class OmsUserService( WechatId = input.WechatId, WechatQrCode = input.WechatQrCode, WecomKfUrl = input.WecomKfUrl, + IsEnabled = input.IsEnabled ?? true, }, cancellationToken); @@ -242,6 +244,12 @@ public class OmsUserService( }, cancellationToken); + if (input.IsEnabled.HasValue) + { + EnsureCanChangeEnabled(id, input.IsEnabled.Value); + await profileRepository.SetEnabledAsync(id, input.IsEnabled.Value, cancellationToken); + } + var profile = await profileRepository.GetAsync(id, cancellationToken); return MapUserDetail(user, profile); } @@ -251,6 +259,29 @@ public class OmsUserService( } } + /// + /// 启用或禁用用户。禁用后无法登录后台。 + /// + [HttpPost("/rbac/user/set-enabled")] + public async Task SetEnabledAsync( + [FromQuery] Guid id, + [FromBody] SetUserEnabledInput input, + CancellationToken cancellationToken = default) + { + if (id == Guid.Empty) + { + throw new BusinessException("用户 Id 无效"); + } + + if (input == null) + { + throw new BusinessException("请求体不能为空"); + } + + EnsureCanChangeEnabled(id, input.IsEnabled); + await profileRepository.SetEnabledAsync(id, input.IsEnabled, cancellationToken); + } + /// /// 修改用户密码。 /// @@ -391,4 +422,18 @@ public class OmsUserService( detail.Avatar = fileStorage.GetPublicUrl(detail.Avatar); return detail; } + + private void EnsureCanChangeEnabled(Guid targetUserId, bool isEnabled) + { + if (isEnabled) + { + return; + } + + var currentIdText = CurrentUser.FindClaimOrNull(ClaimTypes.NameIdentifier)?.Value; + if (Guid.TryParse(currentIdText, out var currentId) && currentId == targetUserId) + { + throw new BusinessException("不能禁用当前登录账号"); + } + } } diff --git a/src/Oms.Application/Services/System/NoticeService.cs b/src/Oms.Application/Services/System/NoticeService.cs index d804de4..ca82fe3 100644 --- a/src/Oms.Application/Services/System/NoticeService.cs +++ b/src/Oms.Application/Services/System/NoticeService.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using Fake.Application; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Shared; @@ -24,20 +25,19 @@ public class NoticeService : ApplicationService private const string ReceiverTypeUser = "user"; private const string ReceiverTypeCustomer = "customer"; - private const string InternalUserFilter = """ + private static readonly string InternalUserFilter = $@" ( - NOT EXISTS (SELECT 1 FROM `user_role` ur0 WHERE ur0.`UserId` = u.`Id`) + NOT EXISTS (SELECT 1 FROM `{FakeTables.UserRole}` ur0 WHERE ur0.`UserId` = u.`Id`) OR EXISTS ( SELECT 1 - FROM `user_role` ur - INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + FROM `{FakeTables.UserRole}` ur + INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 WHERE ur.`UserId` = u.`Id` AND r.`Code` <> 'Customer' AND r.`Code` NOT LIKE 'CustomerSub\_%' ) ) - """; - + "; private INoticeRepository NoticeRepository => LazyServiceProvider.GetRequiredService(); private INoticeReadRepository NoticeReadRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); @@ -239,19 +239,19 @@ public class NoticeService : ApplicationService args.Add(pattern); } - var countSql = $"SELECT COUNT(*) AS `Value` FROM `user` u {where}"; + var countSql = $"SELECT COUNT(*) AS `Value` FROM `{FakeTables.User}` u {where}"; var total = await RbacDbContext.Database.SqlQueryRaw(countSql, args.ToArray()).FirstAsync(); var offset = Math.Max(pageIndex - 1, 0) * pageSize; - var listSql = $""" + var listSql = $@" SELECT u.`userid` AS `Value`, u.`Name` AS `Label`, u.`Account` AS `Extra` - FROM `user` u + FROM `{FakeTables.User}` u {where} ORDER BY u.`Name`, u.`userid` LIMIT {pageSize} OFFSET {offset} - """; + "; var items = await RbacDbContext.Database.SqlQueryRaw(listSql, args.ToArray()) .Select(x => new NoticeReceiverOptionOutput { @@ -277,7 +277,7 @@ public class NoticeService : ApplicationService x.ParentCustomerId == null && (x.CustomerName.Contains(pattern) || x.CustomerCode.Contains(pattern) || - x.Phone.Contains(pattern)); + (x.Phone != null && x.Phone.Contains(pattern))); } var total = await CustomerRepository.CountAsync(predicate); @@ -465,7 +465,7 @@ public class NoticeService : ApplicationService return []; } - var sql = $"SELECT `userid` AS `UserId` FROM `user` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", userIds)})"; + var sql = $"SELECT `userid` AS `UserId` FROM `{FakeTables.User}` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", userIds)})"; return await RbacDbContext.Database.SqlQueryRaw(sql) .Select(x => x.UserId) .ToListAsync(); diff --git a/src/Oms.Application/Shared/AutoTriggerImportHelper.cs b/src/Oms.Application/Shared/AutoTriggerImportHelper.cs new file mode 100644 index 0000000..7c7c66b --- /dev/null +++ b/src/Oms.Application/Shared/AutoTriggerImportHelper.cs @@ -0,0 +1,290 @@ +using MiniExcelLibs; +using Oms.Application.Services.Orders.Issues; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.Shared.Enums; + +namespace Oms.Application.Shared; + +/// +/// 自动触发规则 Excel 模板与导入解析。 +/// +public static class AutoTriggerImportHelper +{ + public const string TemplateSheetName = "导入模板"; + public const string EnumSheetName = "枚举值"; + + private static readonly Dictionary HeaderAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["规则名称"] = nameof(AutoTrigger.RuleName), + ["RuleName"] = nameof(AutoTrigger.RuleName), + ["ruleName"] = nameof(AutoTrigger.RuleName), + ["优先级"] = nameof(AutoTrigger.Priority), + ["Priority"] = nameof(AutoTrigger.Priority), + ["priority"] = nameof(AutoTrigger.Priority), + ["状态"] = nameof(AutoTrigger.Status), + ["Status"] = nameof(AutoTrigger.Status), + ["status"] = nameof(AutoTrigger.Status), + ["操作"] = nameof(AutoTrigger.Operation), + ["Operation"] = nameof(AutoTrigger.Operation), + ["operation"] = nameof(AutoTrigger.Operation), + ["触发时机"] = nameof(AutoTrigger.TriggerTiming), + ["TriggerTiming"] = nameof(AutoTrigger.TriggerTiming), + ["triggerTiming"] = nameof(AutoTrigger.TriggerTiming), + ["触发条件"] = nameof(AutoTrigger.TriggerCondition), + ["TriggerCondition"] = nameof(AutoTrigger.TriggerCondition), + ["triggerCondition"] = nameof(AutoTrigger.TriggerCondition), + ["提示语"] = nameof(AutoTrigger.PromptMessage), + ["提示信息"] = nameof(AutoTrigger.PromptMessage), + ["PromptMessage"] = nameof(AutoTrigger.PromptMessage), + ["promptMessage"] = nameof(AutoTrigger.PromptMessage), + ["通知人"] = nameof(AutoTrigger.Notifier), + ["通知对象"] = nameof(AutoTrigger.Notifier), + ["Notifier"] = nameof(AutoTrigger.Notifier), + ["notifier"] = nameof(AutoTrigger.Notifier), + }; + + public static async Task BuildTemplateBytesAsync() + { + var templateRows = new List> + { + new() + { + ["规则名称"] = "英国超尺寸拦截示例", + ["优先级"] = 100, + ["状态"] = "启用", + ["操作"] = "拦截", + ["触发时机"] = "下单", + ["触发条件"] = "{\n \"expression\": \"收件国家 == \\\"GB\\\"\"\n}", + ["提示语"] = "超尺寸,禁止下单", + ["通知人"] = "" + } + }; + + var enumRows = new List> + { + new() { ["枚举类型"] = "状态", ["编码"] = "2", ["名称"] = "启用" }, + new() { ["枚举类型"] = "状态", ["编码"] = "1", ["名称"] = "禁用" }, + new() { ["枚举类型"] = "操作", ["编码"] = "intercept", ["名称"] = "拦截" }, + new() { ["枚举类型"] = "操作", ["编码"] = "warn", ["名称"] = "警告" }, + new() { ["枚举类型"] = "操作", ["编码"] = "problem", ["名称"] = "问题件" }, + new() { ["枚举类型"] = "触发时机", ["编码"] = "order_create", ["名称"] = "下单" }, + new() { ["枚举类型"] = "触发时机", ["编码"] = "inbound", ["名称"] = "入库" }, + new() { ["枚举类型"] = "触发时机", ["编码"] = "outbound", ["名称"] = "出库" }, + }; + + var sheets = new Dictionary + { + [TemplateSheetName] = templateRows, + [EnumSheetName] = enumRows + }; + + await using var stream = new MemoryStream(); + await MiniExcel.SaveAsAsync(stream, sheets); + return stream.ToArray(); + } + + public static string TemplateFileName => "自动触发规则导入模板.xlsx"; + + internal static string? ResolveImportSheetName(IEnumerable sheetNames) + { + var names = sheetNames.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (names.Count == 0) + { + return null; + } + + return names.FirstOrDefault(x => + string.Equals(x, TemplateSheetName, StringComparison.OrdinalIgnoreCase)) + ?? names.FirstOrDefault(x => + !string.Equals(x, EnumSheetName, StringComparison.OrdinalIgnoreCase)) + ?? names[0]; + } + + public static async Task> ParseImportRowsAsync(Stream fileStream) + { + if (fileStream.CanSeek) + { + fileStream.Position = 0; + } + + var sheetNames = MiniExcel.GetSheetNames(fileStream); + var sheetName = ResolveImportSheetName(sheetNames); + + if (fileStream.CanSeek) + { + fileStream.Position = 0; + } + + var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true, sheetName: sheetName); + var result = new List<(int RowNumber, AutoTrigger Entity)>(); + var rowNumber = 1; + + foreach (var row in rows) + { + rowNumber++; + if (row is not IDictionary rowDict) + { + continue; + } + + if (IsEmptyRow(rowDict)) + { + continue; + } + + result.Add((rowNumber, ParseRow(rowDict))); + } + + return result; + } + + public static void ValidateEntity(AutoTrigger entity) + { + if (string.IsNullOrWhiteSpace(entity.RuleName)) + { + throw new InvalidOperationException("规则名称不能为空"); + } + + if (string.IsNullOrWhiteSpace(entity.TriggerCondition)) + { + throw new InvalidOperationException("触发条件不能为空"); + } + + if (string.IsNullOrWhiteSpace(entity.Operation)) + { + throw new InvalidOperationException("操作不能为空"); + } + + if (string.IsNullOrWhiteSpace(entity.TriggerTiming)) + { + throw new InvalidOperationException("触发时机不能为空"); + } + + if (string.IsNullOrWhiteSpace(entity.PromptMessage)) + { + entity.PromptMessage = entity.RuleName; + } + + if (entity.Priority <= 0) + { + entity.Priority = 100; + } + } + + private static AutoTrigger ParseRow(IDictionary rowDict) + { + var mapped = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (header, value) in rowDict) + { + if (string.IsNullOrWhiteSpace(header)) + { + continue; + } + + if (!HeaderAliases.TryGetValue(header.Trim(), out var field)) + { + continue; + } + + mapped[field] = NormalizeCell(value); + } + + var operation = NormalizeOperation(mapped.GetValueOrDefault(nameof(AutoTrigger.Operation))); + var timing = AutoTriggerTimingCodes.ToCanonicalLabel( + mapped.GetValueOrDefault(nameof(AutoTrigger.TriggerTiming))); + var status = NormalizeStatus(mapped.GetValueOrDefault(nameof(AutoTrigger.Status))); + var priorityText = mapped.GetValueOrDefault(nameof(AutoTrigger.Priority)); + var priority = int.TryParse(priorityText, out var p) && p > 0 ? p : 100; + + return new AutoTrigger + { + RuleName = mapped.GetValueOrDefault(nameof(AutoTrigger.RuleName))?.Trim(), + Priority = priority, + Status = status, + Operation = operation, + TriggerTiming = timing, + TriggerCondition = mapped.GetValueOrDefault(nameof(AutoTrigger.TriggerCondition)), + PromptMessage = mapped.GetValueOrDefault(nameof(AutoTrigger.PromptMessage))?.Trim(), + Notifier = mapped.GetValueOrDefault(nameof(AutoTrigger.Notifier))?.Trim() + }; + } + + private static string NormalizeOperation(string? raw) + { + var text = (raw ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + if (ContainsAny(text, "拦截", "intercept", "block")) + { + return "拦截"; + } + + if (ContainsAny(text, "警告", "warn", "warning")) + { + return "警告"; + } + + if (ContainsAny(text, "问题件", "problem", "issue")) + { + return "问题件"; + } + + return text; + } + + private static EnableStatus NormalizeStatus(string? raw) + { + var text = (raw ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(text)) + { + return EnableStatus.Enabled; + } + + if (text is "1" or "禁用" or "停用" or "disabled" or "Disabled" or "false" or "False" or "0") + { + return EnableStatus.Disabled; + } + + return EnableStatus.Enabled; + } + + private static bool ContainsAny(string text, params string[] keywords) + { + foreach (var keyword in keywords) + { + if (text.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string? NormalizeCell(object? value) + { + if (value == null || value is DBNull) + { + return null; + } + + var text = Convert.ToString(value)?.Trim(); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + + private static bool IsEmptyRow(IDictionary rowDict) + { + foreach (var value in rowDict.Values) + { + if (!string.IsNullOrWhiteSpace(NormalizeCell(value))) + { + return false; + } + } + + return true; + } +} diff --git a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs index 6241a72..0971ad4 100644 --- a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs +++ b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs @@ -2,6 +2,7 @@ using System.Net.Mail; using System.Text.RegularExpressions; using Fake.Application; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Oms.Application.Shared; @@ -166,14 +167,14 @@ public static class OmsRbacRegistrationGuard return; } - var hit = await rbacDbContext.Database - .SqlQuery( - $""" + var sql = $@" SELECT 1 AS Value - FROM `organization` - WHERE `Id` = {organizationId.Value.ToString()} AND `IsDeleted` = 0 + FROM `{FakeTables.Organization}` + WHERE `Id` = {{0}} AND `IsDeleted` = 0 LIMIT 1 - """) + "; + var hit = await rbacDbContext.Database + .SqlQueryRaw(sql, organizationId.Value.ToString()) .FirstOrDefaultAsync(cancellationToken); if (!hit.HasValue) @@ -200,14 +201,14 @@ public static class OmsRbacRegistrationGuard foreach (var roleId in ids) { - var hit = await rbacDbContext.Database - .SqlQuery( - $""" + var sql = $@" SELECT 1 AS Value - FROM `role` - WHERE `Id` = {roleId.ToString()} AND `IsDeleted` = 0 + FROM `{FakeTables.Role}` + WHERE `Id` = {{0}} AND `IsDeleted` = 0 LIMIT 1 - """) + "; + var hit = await rbacDbContext.Database + .SqlQueryRaw(sql, roleId.ToString()) .FirstOrDefaultAsync(cancellationToken); if (!hit.HasValue) @@ -491,18 +492,20 @@ public static class OmsRbacRegistrationGuard // 唯一索引不按 IsDeleted 区分,软删除账号仍会触发 Duplicate entry。 if (excludeUserId is Guid id && id != Guid.Empty) { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Account` = {{0}} AND `Id` <> {{1}} LIMIT 1"; var hit = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Account` = {account} AND `Id` <> {id.ToString()} LIMIT 1") + .SqlQueryRaw(sql, account, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } - var any = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Account` = {account} LIMIT 1") - .FirstOrDefaultAsync(cancellationToken); - return any.HasValue; + { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Account` = {{0}} LIMIT 1"; + var any = await rbacDbContext.Database + .SqlQueryRaw(sql, account) + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; + } } private static async Task ExistsUserByEmailAsync( @@ -513,18 +516,20 @@ public static class OmsRbacRegistrationGuard { if (excludeUserId is Guid id && id != Guid.Empty) { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Email` = {{0}} AND `Id` <> {{1}} LIMIT 1"; var hit = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Email` = {email} AND `Id` <> {id.ToString()} LIMIT 1") + .SqlQueryRaw(sql, email, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } - var any = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Email` = {email} LIMIT 1") - .FirstOrDefaultAsync(cancellationToken); - return any.HasValue; + { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Email` = {{0}} LIMIT 1"; + var any = await rbacDbContext.Database + .SqlQueryRaw(sql, email) + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; + } } private static async Task ExistsUserByPhoneAsync( @@ -535,18 +540,20 @@ public static class OmsRbacRegistrationGuard { if (excludeUserId is Guid id && id != Guid.Empty) { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Phone` = {{0}} AND `Id` <> {{1}} LIMIT 1"; var hit = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Phone` = {phone} AND `Id` <> {id.ToString()} LIMIT 1") + .SqlQueryRaw(sql, phone, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } - var any = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Phone` = {phone} LIMIT 1") - .FirstOrDefaultAsync(cancellationToken); - return any.HasValue; + { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Phone` = {{0}} LIMIT 1"; + var any = await rbacDbContext.Database + .SqlQueryRaw(sql, phone) + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; + } } private static async Task ExistsUserByWechatIdAsync( @@ -557,18 +564,20 @@ public static class OmsRbacRegistrationGuard { if (excludeUserId is Guid id && id != Guid.Empty) { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `WechatId` = {{0}} AND `Id` <> {{1}} LIMIT 1"; var hit = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `WechatId` = {wechatId} AND `Id` <> {id.ToString()} LIMIT 1") + .SqlQueryRaw(sql, wechatId, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } - var any = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `WechatId` = {wechatId} LIMIT 1") - .FirstOrDefaultAsync(cancellationToken); - return any.HasValue; + { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `WechatId` = {{0}} LIMIT 1"; + var any = await rbacDbContext.Database + .SqlQueryRaw(sql, wechatId) + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; + } } private static string ExtractDeepMessage(Exception ex) diff --git a/src/Oms.Application/Shared/OmsRbacUserDisplayNameResolver.cs b/src/Oms.Application/Shared/OmsRbacUserDisplayNameResolver.cs index 1b5b92d..38c277b 100644 --- a/src/Oms.Application/Shared/OmsRbacUserDisplayNameResolver.cs +++ b/src/Oms.Application/Shared/OmsRbacUserDisplayNameResolver.cs @@ -1,5 +1,6 @@ using Fake.DependencyInjection; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Oms.Application.Shared; @@ -40,7 +41,7 @@ public class OmsRbacUserDisplayNameResolver : IScopedDependency try { var sql = - $"SELECT `userid` AS `{nameof(RbacUserNameRow.UserId)}`, `Name` AS `{nameof(RbacUserNameRow.DisplayName)}` FROM `user` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", ids)})"; + $"SELECT `userid` AS `{nameof(RbacUserNameRow.UserId)}`, `Name` AS `{nameof(RbacUserNameRow.DisplayName)}` FROM `{FakeTables.User}` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", ids)})"; var rows = await _rbacDbContext.Database .SqlQueryRaw(sql) .ToListAsync(cancellationToken); diff --git a/src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs b/src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs new file mode 100644 index 0000000..b372aa8 --- /dev/null +++ b/src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs @@ -0,0 +1,34 @@ +using Fake.Application; + +namespace Oms.Application.Shared; + +/// +/// 开放接口件重尺单位换算为系统内部单位(kg / cm)。 +/// +public static class OpenApiMeasureUnitConverter +{ + public static decimal ToKilograms(decimal weight, string? weightUnit) + { + var unit = string.IsNullOrWhiteSpace(weightUnit) ? "KG" : weightUnit.Trim().ToUpperInvariant(); + return unit switch + { + "KG" or "KGS" or "KILOGRAM" or "KILOGRAMS" => weight, + "G" or "GRAM" or "GRAMS" => weight / 1000m, + "LB" or "LBS" or "POUND" or "POUNDS" => weight * 0.45359237m, + _ => throw new BusinessException($"不支持的重量单位:{weightUnit}") + }; + } + + public static decimal ToCentimeters(decimal length, string? lengthUnit) + { + var unit = string.IsNullOrWhiteSpace(lengthUnit) ? "CM" : lengthUnit.Trim().ToUpperInvariant(); + return unit switch + { + "CM" or "CMS" or "CENTIMETER" or "CENTIMETERS" => length, + "MM" or "MILLIMETER" or "MILLIMETERS" => length / 10m, + "M" or "METER" or "METERS" => length * 100m, + "IN" or "INCH" or "INCHES" => length * 2.54m, + _ => throw new BusinessException($"不支持的长度单位:{lengthUnit}") + }; + } +} diff --git a/src/Oms.Application/Shared/OpenApiTokenIssuer.cs b/src/Oms.Application/Shared/OpenApiTokenIssuer.cs new file mode 100644 index 0000000..b1e2cba --- /dev/null +++ b/src/Oms.Application/Shared/OpenApiTokenIssuer.cs @@ -0,0 +1,47 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Fake.AspNetCore.Authentication; +using Fake.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Oms.Application.Shared; + +/// +/// 仓配开放接口 JWT 签发(固定 48 小时有效期,签名密钥与门户共用)。 +/// +public class OpenApiTokenIssuer(IOptions jwtOptions) : ITransientDependency +{ + public static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(48); + + public const string TokenKindClaim = "token_kind"; + public const string TokenKindOpenApi = "openapi"; + + public string Issue(IEnumerable claims) + { + var options = jwtOptions.Value; + if (string.IsNullOrWhiteSpace(options.SecretKey)) + { + throw new InvalidOperationException("Jwt SecretKey 未配置"); + } + + var list = claims + .Where(x => !string.Equals(x.Type, TokenKindClaim, StringComparison.OrdinalIgnoreCase)) + .Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer, x.OriginalIssuer)) + .ToList(); + list.Add(new Claim(TokenKindClaim, TokenKindOpenApi)); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SecretKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var now = DateTime.UtcNow; + var token = new JwtSecurityToken( + issuer: options.Issuer, + audience: options.Audience, + claims: list, + notBefore: now, + expires: now.Add(TokenLifetime), + signingCredentials: credentials); + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/Oms.Application/Shared/StaffAdvisorBinder.cs b/src/Oms.Application/Shared/StaffAdvisorBinder.cs index 48c9719..ac4f465 100644 --- a/src/Oms.Application/Shared/StaffAdvisorBinder.cs +++ b/src/Oms.Application/Shared/StaffAdvisorBinder.cs @@ -1,4 +1,5 @@ using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Oms.Domain.CustomersAggregate.Entities; @@ -44,14 +45,14 @@ public static class StaffAdvisorBinder string key, CancellationToken cancellationToken) { - const string sql = """ + var sql = $@" SELECT u.`Account` AS `Account` - FROM `user` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND u.`Account` = {0} + AND u.`Account` = {{0}} ORDER BY u.`CreateTime` ASC LIMIT 1 - """; + "; var row = await rbacDbContext.Database .SqlQueryRaw(sql, key) diff --git a/src/Oms.Domain/ChannelsAggregate/Consts/SalesProductGroupCodes.cs b/src/Oms.Domain/ChannelsAggregate/Consts/SalesProductGroupCodes.cs new file mode 100644 index 0000000..ff419d1 --- /dev/null +++ b/src/Oms.Domain/ChannelsAggregate/Consts/SalesProductGroupCodes.cs @@ -0,0 +1,10 @@ +namespace Oms.Domain.ChannelsAggregate.Consts; + +/// +/// 销售产品分组编码(字典 sales_product_group.DictCode)。 +/// +public static class SalesProductGroupCodes +{ + /// 仓储代发(贴单出库 / 仓内履约计费)。 + public const string Wms = "wms"; +} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs index 986fd4c..9507109 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs @@ -43,7 +43,7 @@ public static class OrderFileTypeCodes return false; } - return !string.Equals(fileType.Trim(), DownstreamLabel, StringComparison.OrdinalIgnoreCase); + return !string.Equals(fileType!.Trim(), DownstreamLabel, StringComparison.OrdinalIgnoreCase); } public static string Normalize(string fileType) => fileType.Trim(); diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index 1cd7722..c5d8ab0 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -132,4 +132,9 @@ public interface IOrderRepository : IRepository DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default); + + /// + /// 只更新标签模板 Id,避免面单生成覆盖预报计费正在写入的订单字段。 + /// + Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs index 6215bd9..34baceb 100644 --- a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs +++ b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs @@ -140,7 +140,7 @@ public class CustomerRepository : OmsSqlSugarRepository, ICustomerRepo query = query.Where((g, c) => c.CustomerCode.Contains(kw) || c.CustomerName.Contains(kw) || - c.Phone.Contains(kw)); + (c.Phone != null && c.Phone.Contains(kw))); } var total = await query.CountAsync(cancellationToken); diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index b7d0884..c85d43f 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -1021,6 +1021,15 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I } } + public async Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Updateable() + .SetColumns(x => new Order { LabelTemplateId = labelTemplateId, UpdateTime = DateTime.Now }) + .Where(x => x.Id == orderId) + .ExecuteCommandAsync(cancellationToken); + } + public async Task DeleteReceivableFeesAsync(int orderId, CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); diff --git a/src/Oms.Tests/Oms.Tests.csproj b/src/Oms.Tests/Oms.Tests.csproj index 040ca5b..2638958 100644 --- a/src/Oms.Tests/Oms.Tests.csproj +++ b/src/Oms.Tests/Oms.Tests.csproj @@ -14,8 +14,18 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + + + + + + diff --git a/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs b/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs index df70e79..47147c0 100644 --- a/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs +++ b/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs @@ -20,14 +20,28 @@ public class SmallBagTrackUpsertRequestBuilderTests } [Fact] - public void ResolveTrackNo_EmptyWhenTrackNoMissing() + public void ResolveTrackNo_FallsBackToClientNoWhenTrackNoMissing() { var order = new Order { ServiceNo = "IDE2607221859123456", + ClientNo = "CNO-001", TrackNo = null }; + Assert.Equal("CNO-001", SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order)); + } + + [Fact] + public void ResolveTrackNo_EmptyWhenTrackNoAndClientNoMissing() + { + var order = new Order + { + ServiceNo = "IDE2607221859123456", + TrackNo = null, + ClientNo = null + }; + Assert.Equal(string.Empty, SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order)); } diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 632f352..5bef285 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -12,11 +12,13 @@ using Fake.Rbac.Domain.UserAggregate; using Fake.Rbac.Infrastructure; using Fake.Security.Claims; using Fake.Users; +using Fake.EntityFrameworkCore; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Fake.ObjectStorage; +using Oms.Application; using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Domain.CustomersAggregate.Entities; @@ -431,6 +433,8 @@ public partial class AuthService( private async Task LoginAdminAsync(string account, string password, CancellationToken cancellationToken) { var user = await accountManager.ValidateCredentialsAsync(account, password, cancellationToken); + var profileRepository = LazyServiceProvider.GetRequiredService(); + await profileRepository.EnsureEnabledOrThrowAsync(user.Id, cancellationToken); return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken); } @@ -904,15 +908,15 @@ public partial class AuthService( private async Task GetRbacUserGuidByMapIdAsync(int userId, CancellationToken cancellationToken) { - return await rbacDbContext.Database.SqlQuery( - $"select `Id` as `Value` from `user` where `IsDeleted` = 0 and `userid` = {userId}") + var sql = $"select `Id` as `Value` from `{FakeTables.User}` where `IsDeleted` = 0 and `userid` = {{0}}"; + return await rbacDbContext.Database.SqlQueryRaw(sql, userId) .FirstOrDefaultAsync(cancellationToken); } private async Task GetMapIdByUserGuidAsync(Guid userId, CancellationToken cancellationToken) { - return await rbacDbContext.Database.SqlQuery( - $"select `userid` as `Value` from `user` where `Id` = {userId}") + var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}"; + return await rbacDbContext.Database.SqlQueryRaw(sql, userId) .FirstOrDefaultAsync(cancellationToken); } diff --git a/src/SimpleAdmin.Api/Auth/WarehouseOpenApiAuthHeaderMiddleware.cs b/src/SimpleAdmin.Api/Auth/WarehouseOpenApiAuthHeaderMiddleware.cs new file mode 100644 index 0000000..f418a24 --- /dev/null +++ b/src/SimpleAdmin.Api/Auth/WarehouseOpenApiAuthHeaderMiddleware.cs @@ -0,0 +1,36 @@ +namespace SimpleAdmin.Api.Auth; + +/// +/// 仓配开放 API:Authorization 直接传 JWT(不要求 Bearer 前缀)。 +/// 在进入 JwtBearer 前补上 Bearer,以便复用现有鉴权。 +/// +public sealed class WarehouseOpenApiAuthHeaderMiddleware(RequestDelegate next) +{ + public Task InvokeAsync(HttpContext context) + { + if (context.Request.Path.StartsWithSegments("/openapi/warehouse")) + { + NormalizeAuthorizationHeader(context.Request.Headers); + } + + return next(context); + } + + private static void NormalizeAuthorizationHeader(IHeaderDictionary headers) + { + var auth = headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(auth)) + { + return; + } + + auth = auth.Trim(); + if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + // 无 scheme:Authorization: {token} + headers.Authorization = "Bearer " + auth; + } +} diff --git a/src/SimpleAdmin.Api/Program.cs b/src/SimpleAdmin.Api/Program.cs index 39bf160..4c8fb29 100644 --- a/src/SimpleAdmin.Api/Program.cs +++ b/src/SimpleAdmin.Api/Program.cs @@ -1,10 +1,16 @@ +using Fake.EntityFrameworkCore; using Serilog; using SimpleAdmin.Api; var builder = WebApplication.CreateSlimBuilder(args); var configuration = builder.Configuration; -builder.Configuration.AddConsul("idwms/common", source => source.ReloadOnChange = true); -builder.Configuration.AddConsul("idwms/admin", source => source.ReloadOnChange = true); +var prefix = builder.Environment.EnvironmentName == "Development" ? "dev" : "idwms"; +builder.Configuration.AddConsul($"{prefix}/common", source => source.ReloadOnChange = true); +builder.Configuration.AddConsul($"{prefix}/admin", source => source.ReloadOnChange = true); + +// Fake 框架表前缀(默认 fk_):须在 AddApplication / Migrate 之前生效 +FakeDbProperties.Configure(configuration[FakeDbProperties.TablePrefixConfigurationKey]); + Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(configuration) .CreateLogger(); @@ -26,4 +32,4 @@ catch (Exception ex) finally { Log.CloseAndFlush(); -} \ No newline at end of file +} diff --git a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj index 568c1a4..6b854bb 100644 --- a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj +++ b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj @@ -15,15 +15,35 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index 19f0d1f..ab4606f 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -7,6 +7,7 @@ using Fake.AspNetCore.Auditing; using Fake.AspNetCore.ExceptionHandling; using Fake.AspNetCore.Newtonsoft; using Fake.AspNetCore.Swagger; +using Fake.EventBus.Dashboard; using Fake.EventBus.IntegrationEventLog; using Fake.FeiShu; using Fake.FileManagement.Application; @@ -40,6 +41,8 @@ namespace SimpleAdmin.Api; typeof(FakeObjectStorageTencentCosModule), // 在 RabbitMQ 物理总线之后 Replace IDistributedEventBus → Outbox typeof(FakeEventBusIntegrationEventLogModule), + // 独立 EventBus Dashboard(本地工程引用调试;默认 /eventbus) + typeof(FakeEventBusDashboardModule), typeof(FakeAspNetCoreModule), typeof(FakeSerilogModule) )] @@ -181,7 +184,7 @@ public class SimpleAdminApiModule : FakeModule // 静态文件服务(用于头像等上传文件) app.UseStaticFiles(); - // 路由 - 必须在认证和授权之前 + // 路由 - 必须在认证和授权之前;且必须在 UsePathBase 之后 app.UseRouting(); // OpenAPI 验签需读取原始 Body,允许二次读流 @@ -195,6 +198,9 @@ public class SimpleAdminApiModule : FakeModule await next(); }); + // 仓配开放 API:Authorization 直接传 token(可不带 Bearer) + app.UseMiddleware(); + // 认证和授权 app.UseAuthentication(); app.UseAuthorization(); @@ -202,19 +208,11 @@ public class SimpleAdminApiModule : FakeModule // 角色/菜单写操作后失效登录用户信息缓存 app.UseMiddleware(); - // 端点映射 + // 端点映射(Dashboard 用 MapGroup,依赖 PathBase 已剥离前缀) app.MapControllers(); + // 必须在 UsePathBase 之后:完整对外路径为 /admin/eventbus + app.UseFakeEventBusDashboard(); - // FileManagement / Outbox 与业务同库并行(EF) - using (var scope = app.Services.CreateScope()) - { - var fileDb = scope.ServiceProvider.GetRequiredService(); - fileDb.Database.Migrate(); - - var outboxDb = scope.ServiceProvider.GetRequiredService(); - IntegrationEventLogSchema.EnsureTablesAsync(outboxDb).GetAwaiter().GetResult(); - } - app.Services.GetRequiredService().Send("Application started"); } } diff --git a/src/SimpleAdmin.Api/SimpleJwtService.cs b/src/SimpleAdmin.Api/SimpleJwtService.cs index adfe75f..09a7ad9 100644 --- a/src/SimpleAdmin.Api/SimpleJwtService.cs +++ b/src/SimpleAdmin.Api/SimpleJwtService.cs @@ -7,6 +7,7 @@ using Fake.Rbac.Infrastructure; using Fake.Security.Claims; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Oms.Application; namespace SimpleAdmin.Api; @@ -21,8 +22,8 @@ public class SimpleJwtService(IOptions jwtOptions, UserService u CancellationToken cancellationToken) { var claims = await base.GenerateClaimsByUserIdAsync(userId, cancellationToken); - var mapId = await rbacDbContext.Database.SqlQuery( - $"select `userid` as `Value` from `user` where `Id` = {userId}") + var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}"; + var mapId = await rbacDbContext.Database.SqlQueryRaw(sql, userId) .FirstOrDefaultAsync(cancellationToken); if (mapId.HasValue) { diff --git a/src/SimpleAdmin.Api/Storage/IntegrationEventLogSchema.cs b/src/SimpleAdmin.Api/Storage/IntegrationEventLogSchema.cs deleted file mode 100644 index f44e97c..0000000 --- a/src/SimpleAdmin.Api/Storage/IntegrationEventLogSchema.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Fake.EventBus.IntegrationEventLog; -using Microsoft.EntityFrameworkCore; - -namespace SimpleAdmin.Api.Storage; - -/// -/// Outbox/Inbox 与业务同库时 EnsureCreated 无效(库已存在),用 IF NOT EXISTS 建表。 -/// -internal static class IntegrationEventLogSchema -{ - public static async Task EnsureTablesAsync( - IntegrationEventLogContext db, - CancellationToken cancellationToken = default) - { - await db.Database.ExecuteSqlRawAsync( - """ - CREATE TABLE IF NOT EXISTS `OutboxEventLog` ( - `EventId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, - `EventTypeName` varchar(512) NOT NULL, - `State` int NOT NULL, - `TimesSent` int NOT NULL, - `CreationTime` datetime(6) NOT NULL, - `Content` longtext NOT NULL, - `TransactionId` varchar(64) NOT NULL, - `LockExpiresAt` datetime(6) NULL, - PRIMARY KEY (`EventId`), - KEY `IX_OutboxEventLog_State_CreationTime` (`State`, `CreationTime`), - KEY `IX_OutboxEventLog_TransactionId` (`TransactionId`) - ) CHARACTER SET utf8mb4; - """, - cancellationToken); - - await db.Database.ExecuteSqlRawAsync( - """ - CREATE TABLE IF NOT EXISTS `InboxEventLog` ( - `EventId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, - `EventTypeName` varchar(512) NOT NULL, - `State` int NOT NULL, - `ProcessedTime` datetime(6) NOT NULL, - `Content` longtext NOT NULL, - `ErrorMessage` longtext NULL, - PRIMARY KEY (`EventId`), - KEY `IX_InboxEventLog_ProcessedTime` (`ProcessedTime`) - ) CHARACTER SET utf8mb4; - """, - cancellationToken); - } -} diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs index f76a3ed..e5c6ec2 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs @@ -4,10 +4,12 @@ using Fake.Rbac.Application.Dtos.Role; using Fake.Rbac.Application.Dtos.User; using Fake.Rbac.Application.Services; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Oms.Application; using Oms.Application.Shared; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Enums; @@ -323,8 +325,8 @@ public class WeChatCustomerService( private async Task GetRbacUserMapIdOrThrowAsync(Guid id, CancellationToken cancellationToken) { - var userId = await rbacDbContext.Database.SqlQuery( - $"select `userid` as `Value` from `user` where `Id` = {id}") + var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}"; + var userId = await rbacDbContext.Database.SqlQueryRaw(sql, id) .FirstOrDefaultAsync(cancellationToken); if (!userId.HasValue || userId.Value <= 0) { diff --git a/src/SimpleAdmin.Api/appsettings.Development.json b/src/SimpleAdmin.Api/appsettings.Development.json deleted file mode 100644 index 03d2137..0000000 --- a/src/SimpleAdmin.Api/appsettings.Development.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "UseLocalConfigs": false, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Information" - } - }, - "ConnectionStrings": { - "Default": "Server=127.0.0.1;Port=3306;Database=oms;Uid=root;Pwd=你的密码;CharSet=utf8mb4;" - }, - "App": { - "Urls": ["http://localhost:5281"], - "CorsOrigins": [ - "http://localhost:5173", - "http://localhost:5281", - "https://oms.nanafa.com", - "http://oms.nanafa.com" - ], - "SendStartupNotification": false - }, - "Jwt": { - "SecretKey": "local-dev-secret-key-change-me", - "Issuer": "SimpleAdmin.Api", - "Audience": "SimpleAdmin.Api", - "ExpiryInMinutes": "480", - "RefreshExpiryInDays": "7", - "ClockSkewMinutes": "5" - }, - "JwtSettings": { - "SecretKey": "local-dev-secret-key-change-me", - "Issuer": "SimpleAdmin.Api", - "Audience": "SimpleAdmin.Api", - "ExpiryInMinutes": "480", - "RefreshExpiryInDays": "7", - "ClockSkewMinutes": "5" - }, - "FeiShuNotice": { - "Webhook": "https://example.com/webhook", - "EnableFeiShuExceptionSubscribe": false - }, - "Redis": { - "Host": "127.0.0.1:6379", - "Password": "", - "Database": 0, - "MaxPoolSize": 50, - "MinPoolSize": 1, - "IdleTimeout": 20000, - "ConnectTimeout": 5000 - }, - "WeChat": { - "Mp": { - "AppId": "wxfffea62e089ee15f", - "Secret": "379f51a89419dc36fbb9167feef6f89a", - "Token": "c6ca74013df823c0e1da0feaaaf8bf66", - "EncodingAesKey": "", - "OAuthRedirectBase": "https://oms.nanafa.com", - "FollowUrl": "" - } - }, - "ObjectStorage": { - "Provider": "Local", - "KeyPrefix": "", - "SignUrlsByDefault": true, - "DefaultSignedUrlExpiresSeconds": 3600, - "DefaultUploadExpiresSeconds": 600, - "Local": { - "BasePath": "wwwroot", - "BaseUrl": "" - }, - "TencentCos": { - "SecretId": "", - "SecretKey": "", - "Region": "ap-guangzhou", - "Bucket": "your-bucket-1250000000", - "BaseUrl": "", - "SignUrls": true, - "UseHttps": true, - "DurationSecond": 600 - } - }, - "FileManagement": { - "SignUrlsByDefault": true, - "DefaultSignedUrlExpiresSeconds": 3600, - "DefaultMaxSizeBytes": 20971520, - "DefaultStorageSource": "default" - }, - "Oms": { - "TrackService": { - "Enabled": true, - "BaseUrl": "http://api.nanafa.com/track", - "AppKey": "W2606001", - "AppSecret": "ziK8UzlQ39m3kdL346XEBg8nljY8os52", - "TimeoutSeconds": 30 - }, - "Itfx": { - "BaseUrl": "http://api.nanafa.com/itfx", - "TimeoutSeconds": 30, - "Customs9610StandardCode": "customs9610Declare", - "Customs9610ManifestStandardCode": "customs9610Manifest", - "Customs9610DeclarationFormStandardCode": "customs9610DeclarationForm" - }, - "PlatformShop": { - "FrontendBaseUrl": "http://localhost:5173", - "Lazada": { - "AppKey": "123631", - "AppSecret": "7vBz2EaD6rIupeGEfwrgkabsTNsXt2ix", - "AuthorizeUrl": "https://auth.lazada.com/oauth/authorize", - "AccessTokenUrl": "https://auth.lazada.com/rest" - }, - "Shopee": { - "PartnerId": 2004112, - "PartnerKey": "2f385d7ea38b087d5d71f7292e06a09c9f19022ca6c3f60ec0110a8214ef7d65", - "HostUrl": "https://openplatform.shopee.cn", - "HostAuthUrl": "https://partner.shopeemobile.com" - }, - "Tiktok": { - "AppKey": "6hc64lc6vl312", - "AppSecret": "5a691a96ee6d844534769bab5c21de1f848fb678", - "HostAuth": "https://auth.tiktok-shops.com", - "HostApi": "https://open-api.tiktokglobalshop.com" - }, - "Temu": { - "AppKey": "4ebbc9190ae410443d65b4c2faca981f", - "AppSecret": "4782d2d827276688bf4758bed55dbdd4bbe79a79", - "HostAuthUrl": "https://openapi-b-us.temu.com/openapi/router" - } - } - }, - "RabbitMQ": { - "Connections": { - "Default": { - "HostName": "127.0.0.1", - "Port": 5672, - "UserName": "oms", - "Password": "oms123" - } - }, - "EventBus": { - "QueueName": "nanafa.oms", - "RetryCount": 5 - } - }, - "OrderBatch": { - "RabbitMq": { - "Enabled": false - } - }, - "IntegrationEventLog": { - "OutboxPublisher": { - "StartupDelay": "00:00:05", - "ScanInterval": "00:00:10", - "BatchSize": 100, - "MaxDegreeOfParallelism": 8 - } - } -} diff --git a/src/SimpleAdmin.Api/appsettings.json b/src/SimpleAdmin.Api/appsettings.json index 860fa46..06ba18d 100644 --- a/src/SimpleAdmin.Api/appsettings.json +++ b/src/SimpleAdmin.Api/appsettings.json @@ -1,7 +1,7 @@ { "UseLocalConfigs": false, "Consul": { - "Address": "https://consul.nanafa.com", - "Token": "97217125-df0b-5c0a-dbfc-8885def5e471" + "Address": "http://test.ideship.com:30850", + "Token": "c0aea902-dca0-ac29-299b-a0d47e5402aa" } }