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