1.05 #7
@ -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
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
<Project>
|
||||
<Import Project="Fake.Local.EventBus.props" Condition="Exists('Fake.Local.EventBus.props')" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- All Fake packages -->
|
||||
<FakePackageVersion>8.0.5.20</FakePackageVersion>
|
||||
<!-- 清本地nuget缓存:dotnet nuget locals -c http-cache -->
|
||||
<FakePackageVersion>8.0.5.27</FakePackageVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
13
Fake.Local.EventBus.props
Normal file
13
Fake.Local.EventBus.props
Normal file
@ -0,0 +1,13 @@
|
||||
<Project>
|
||||
<!--
|
||||
本地调试 Fake 框架模块:不发包,直接工程引用。
|
||||
切回 NuGet:UseLocalFakeEventBus / UseLocalFakeModules = false
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<FakeRepoRoot Condition="'$(FakeRepoRoot)' == ''">C:\Users\BSI\RiderProjects\fake2</FakeRepoRoot>
|
||||
<!-- EventBus / Dashboard / IntegrationEventLog -->
|
||||
<UseLocalFakeEventBus Condition="'$(UseLocalFakeEventBus)' == ''">false</UseLocalFakeEventBus>
|
||||
<!-- RBAC / FileManagement(表前缀 fk_ 等框架改动需同源) -->
|
||||
<UseLocalFakeModules Condition="'$(UseLocalFakeModules)' == ''">false</UseLocalFakeModules>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@ -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}"
|
||||
|
||||
20
sql/drop_order_side_effect.sql
Normal file
20
sql/drop_order_side_effect.sql
Normal file
@ -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;
|
||||
24
sql/migrate_sales_product_group_wms.sql
Normal file
24
sql/migrate_sales_product_group_wms.sql
Normal file
@ -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
|
||||
);
|
||||
24
sql/migrate_user_is_enabled.sql
Normal file
24
sql/migrate_user_is_enabled.sql
Normal file
@ -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;
|
||||
@ -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<RbacUserSnapshot?> 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<RbacUserSnapshot>(sql, userId.ToString())
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> 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<int>(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<int>(hasRoleSql, userId.ToString())
|
||||
.FirstAsync(cancellationToken);
|
||||
return roleCount > 0;
|
||||
@ -60,13 +59,13 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
|
||||
|
||||
public async Task<bool> 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<int>(sql, userId.ToString()).FirstAsync(cancellationToken);
|
||||
return count > 0;
|
||||
@ -74,23 +73,22 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
|
||||
|
||||
public async Task<List<Guid>> 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<Guid>(sql, userId.ToString())
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Guid>> 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<Guid>(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<OrgPermRow>(sql).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@ -385,19 +382,19 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
|
||||
|
||||
public async Task<List<Guid>> 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<Guid>(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<OrganizationNameRow>(sql).ToListAsync(cancellationToken);
|
||||
return rows.ToDictionary(x => x.Id, x => x.Name ?? x.Id.ToString());
|
||||
}
|
||||
|
||||
public async Task<bool> 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<int>(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<int>(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<int>(sql).ToListAsync(cancellationToken);
|
||||
}
|
||||
@ -485,7 +480,7 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency
|
||||
|
||||
public async Task<List<DataResourceMetaDto>> GetDataResourcesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
var sql = $"""
|
||||
SELECT
|
||||
r.`Code` AS `Code`,
|
||||
r.`Name` AS `Name`,
|
||||
|
||||
@ -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<UserDataPermDto> GetUserDataPermAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await dbContext.Database.SqlQueryRaw<UserHeaderRow>("""
|
||||
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<UserHeaderRow>(userSql, userId.ToString())
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
?? throw new BusinessException("用户不存在");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var orgRows = await dbContext.Database.SqlQueryRaw<UserOrgPermRow>("""
|
||||
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<UserOrgPermRow>(orgSql, userId.ToString())
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var scopeRows = await dbContext.Database.SqlQueryRaw<UserDataScopeRow>("""
|
||||
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<RoleDataPermDto> GetRoleDataPermAsync(Guid roleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var role = await dbContext.Database.SqlQueryRaw<RoleHeaderRow>("""
|
||||
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<RoleHeaderRow>(roleSql, roleId.ToString())
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
?? throw new BusinessException("角色不存在");
|
||||
|
||||
var orgRows = await dbContext.Database.SqlQueryRaw<RoleOrgPermRow>("""
|
||||
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<RoleOrgPermRow>(orgSql, roleId.ToString())
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var scopeRows = await dbContext.Database.SqlQueryRaw<RoleDataScopeRow>("""
|
||||
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<List<FieldMaskItemDto>> 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<DataPermTemplateDetailDto> 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
|
||||
|
||||
25
src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs
Normal file
25
src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Oms.Application.Dtos.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// 自动触发规则 Excel 导入入参。
|
||||
/// </summary>
|
||||
public class AutoTriggerImportInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 导入模式:incremental=增量;full=全量(先清空再导入)。
|
||||
/// </summary>
|
||||
public string ImportMode { get; set; } = ImportModes.Incremental;
|
||||
|
||||
/// <summary>
|
||||
/// Excel 文件。
|
||||
/// </summary>
|
||||
public IFormFile File { get; set; } = null!;
|
||||
|
||||
public static class ImportModes
|
||||
{
|
||||
public const string Incremental = "incremental";
|
||||
public const string Full = "full";
|
||||
}
|
||||
}
|
||||
25
src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs
Normal file
25
src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs
Normal file
@ -0,0 +1,25 @@
|
||||
namespace Oms.Application.Dtos.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// 自动触发规则 Excel 导入结果。
|
||||
/// </summary>
|
||||
public class AutoTriggerImportOutput
|
||||
{
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
public int SkipCount { get; set; }
|
||||
|
||||
public int FailCount { get; set; }
|
||||
|
||||
public List<AutoTriggerImportFailRow> Failures { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入失败行。
|
||||
/// </summary>
|
||||
public class AutoTriggerImportFailRow
|
||||
{
|
||||
public int RowNumber { get; set; }
|
||||
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
108
src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs
Normal file
108
src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs
Normal file
@ -0,0 +1,108 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// 开放接口获取 Token 入参。
|
||||
/// </summary>
|
||||
public class OpenApiGetTokenInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户名(OMS 后台账号)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 密码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 开放接口简易结果(件重尺上传等)。
|
||||
/// </summary>
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仓配上传件重尺入参(入库接口可忽略 mode,固定走入库扫描「入库」逻辑)。
|
||||
/// </summary>
|
||||
public class OpenApiUploadWeightInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号(服务单号 / 跟踪号 / 下游单号,同入库扫描扫码框)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 收货实重(同入库扫描「实重」)。
|
||||
/// </summary>
|
||||
public decimal Weight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 长(同入库扫描尺寸)。
|
||||
/// </summary>
|
||||
public decimal Length { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 宽。
|
||||
/// </summary>
|
||||
public decimal Width { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 高。
|
||||
/// </summary>
|
||||
public decimal Height { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模式:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。调用 /inbound 时可省略。
|
||||
/// </summary>
|
||||
public int Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 重量单位:KG(默认)/ G / LB。
|
||||
/// </summary>
|
||||
[StringLength(8)]
|
||||
public string? WeightUnit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 长度单位:CM(默认)/ MM / IN / M。
|
||||
/// </summary>
|
||||
[StringLength(8)]
|
||||
public string? LengthUnit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仓配上传红外图片入参(落订单附件 infraredImage,转存 COS)。
|
||||
/// </summary>
|
||||
public class OpenApiUploadImageInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号(服务单号 / 跟踪号 / 下游单号)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图片 Base64(可带 data:image/...;base64, 前缀;jpg/png/gif/webp/bmp/pdf)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ImageBase64 { get; set; } = string.Empty;
|
||||
}
|
||||
@ -11,6 +11,9 @@ public class PlatformStorePageInput
|
||||
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平台编码:tiktok / temu / shopee / lazada;空或 all 表示全部。
|
||||
/// </summary>
|
||||
public string? PlatformCode { get; set; }
|
||||
|
||||
public string? StoreNickname { get; set; }
|
||||
@ -147,11 +150,6 @@ public class PlatformStoreOrderListInput : PlatformStorePageInput
|
||||
{
|
||||
public int? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平台编码:tiktok / temu / shopee / lazada;空或 all 表示全部。
|
||||
/// </summary>
|
||||
public string? PlatformCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 履约状态:pending / toShip / shipped / canceled / suspended / completed;空或 all 表示全部。
|
||||
/// </summary>
|
||||
|
||||
@ -26,6 +26,11 @@ public class InternalUserListItemDto
|
||||
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public List<InternalUserRoleDto> Roles { get; set; } = [];
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,11 @@ public class UserProfileData
|
||||
/// 企业微信客服接入链接(微信外接入)。
|
||||
/// </summary>
|
||||
public string? WecomKfUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用;缺省按启用处理。
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用,默认 true。
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用;不传则不改。
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -65,3 +85,14 @@ public class ResetUserPasswordInput
|
||||
/// </summary>
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置用户启用状态入参。
|
||||
/// </summary>
|
||||
public class SetUserEnabledInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; }
|
||||
}
|
||||
|
||||
12
src/Oms.Application/FakeTables.cs
Normal file
12
src/Oms.Application/FakeTables.cs
Normal file
@ -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";
|
||||
}
|
||||
@ -68,10 +68,18 @@ internal sealed class SmallBagTrackUpsertRequestBuilder(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅取运单跟踪号;无值则空串,不回落运单号/客户单号等。
|
||||
/// 轨迹侧 TrackNo 必填。有真实跟踪号用跟踪号;尚未下发时先用客户单号占位,后续有 TrackNo 再 Upsert 更新。
|
||||
/// </summary>
|
||||
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<SalesProductAllocationProviderSnapshotDto> providers,
|
||||
|
||||
@ -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,
|
||||
|
||||
57
src/Oms.Application/Messaging/OmsAfterCommit.cs
Normal file
57
src/Oms.Application/Messaging/OmsAfterCommit.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Fake.UnitOfWork;
|
||||
|
||||
namespace Oms.Application.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。
|
||||
/// </summary>
|
||||
internal static class OmsAfterCommit
|
||||
{
|
||||
public static Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func<Task> action)
|
||||
{
|
||||
var current = unitOfWorkManager.Current;
|
||||
if (current != null && !current.IsCompleted)
|
||||
{
|
||||
// OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。
|
||||
// 等工作单元释放后再开独立事务。
|
||||
EventHandler<UnitOfWorkEventArgs> handler = null!;
|
||||
handler = (_, _) =>
|
||||
{
|
||||
current.Disposed -= handler;
|
||||
_ = Task.Run(action);
|
||||
};
|
||||
current.Disposed += handler;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return action();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新开独立事务执行一步。失败只记日志,不抛回调用方。
|
||||
/// </summary>
|
||||
public static async Task<bool> RunStepAsync(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger logger,
|
||||
string step,
|
||||
Func<IServiceProvider, Task> work)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var unitOfWorkManager = scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -17,6 +17,16 @@ public static class OmsOutboxUnitOfWork
|
||||
IsolationLevel = IsolationLevel.ReadCommitted
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 与当前业务事务脱开的短事务,用于创单/预报成功之后的独立事件。
|
||||
/// </summary>
|
||||
public static UnitOfWorkAttribute Independent() => new()
|
||||
{
|
||||
IsTransactional = true,
|
||||
RequiresNew = true,
|
||||
IsolationLevel = IsolationLevel.ReadCommitted
|
||||
};
|
||||
|
||||
public static bool IsAmbientTransactional(IUnitOfWorkManager unitOfWorkManager) =>
|
||||
unitOfWorkManager.Current?.Context.IsTransactional == true;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<OrderLifecycleTrackAppendEventHandler> logger)
|
||||
: IEventHandler<OrderLifecycleTrackAppendIntegrationEvent>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -18,13 +18,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.EventBus.RabbitMQ" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.ObjectMapping.AutoMapper" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.ObjectStorage" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.RabbitMQ" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Infrastructure" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.0.2" />
|
||||
<PackageReference Include="FreeRedis" Version="1.5.5" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
@ -35,6 +30,29 @@
|
||||
<PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.15" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- EventBus / RabbitMQ:本地工程引用(调试) / NuGet(发包后) -->
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' == 'true'">
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus\Fake.EventBus.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus.RabbitMQ\Fake.EventBus.RabbitMQ.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus.IntegrationEventLog\Fake.EventBus.IntegrationEventLog.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.RabbitMQ\Fake.RabbitMQ.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' != 'true'">
|
||||
<PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.EventBus.RabbitMQ" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.RabbitMQ" Version="$(FakePackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- RBAC:本地工程(表前缀) / NuGet -->
|
||||
<ItemGroup Condition="'$(UseLocalFakeModules)' == 'true'">
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\rbac\Fake.Rbac.Application\Fake.Rbac.Application.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\rbac\Fake.Rbac.Infrastructure\Fake.Rbac.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseLocalFakeModules)' != 'true'">
|
||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Infrastructure" Version="$(FakePackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Oms.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -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<AdvisorRow>(sql, userId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
@ -60,18 +60,18 @@ public class CustomerServiceAdvisorResolver(FakeRbacDbContext dbContext) : IScop
|
||||
|
||||
private async Task<AdvisorRow?> 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<AdvisorRow>(sql, key)
|
||||
|
||||
@ -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<InternalUserListResultDto> 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<long>(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<InternalUserRow>(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<UserRoleRow>(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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<UserProfileData?> 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<UserProfileRow>(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<UserProfileListRow>(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<long>(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<long>(sql, normalized, excludeUserId.Value.ToString())
|
||||
@ -166,13 +214,13 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
||||
|
||||
public async Task<string> 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<EmployeeNoRow>(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
|
||||
|
||||
@ -38,6 +38,118 @@ public class AutoTriggerService : ApplicationService
|
||||
return new OmsPageResult<AutoTriggerOutput> { Items = items, Total = total };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载自动触发规则导入模板。
|
||||
/// </summary>
|
||||
[HttpGet("/channel/auto-trigger/import-template")]
|
||||
public async Task<FileContentResult> DownloadImportTemplateAsync()
|
||||
{
|
||||
var bytes = await AutoTriggerImportHelper.BuildTemplateBytesAsync();
|
||||
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
{
|
||||
FileDownloadName = AutoTriggerImportHelper.TemplateFileName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入自动触发规则(Excel)。
|
||||
/// </summary>
|
||||
[HttpPost("/channel/auto-trigger/import")]
|
||||
public async Task<AutoTriggerImportOutput> 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<string>(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}";
|
||||
|
||||
/// <summary>
|
||||
/// 获取公式字段元数据。
|
||||
/// </summary>
|
||||
|
||||
@ -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<Guid?> GetRbacUserGuidByMapIdAsync(int mapId)
|
||||
{
|
||||
return await RbacDbContext.Database.SqlQuery<Guid?>(
|
||||
$"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<Guid?>(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<int> GetRbacUserMapIdOrThrowAsync(Guid id)
|
||||
{
|
||||
var userId = await RbacDbContext.Database.SqlQuery<int?>(
|
||||
$"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<int?>(sql, id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (!userId.HasValue || userId.Value <= 0)
|
||||
{
|
||||
|
||||
@ -14,9 +14,10 @@ using Oms.Domain.OrdersAggregate.Consts;
|
||||
namespace Oms.Application.Services.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// 对外开放 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。
|
||||
/// </summary>
|
||||
[ApiExplorerSettings(GroupName = "openApi")]
|
||||
public class OpenApiService : ApplicationService
|
||||
|
||||
201
src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs
Normal file
201
src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs
Normal file
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 仓配对外开放 API(Swagger 文档分组:warehouseOpenApi)。
|
||||
/// 鉴权:Authorization: {token}(账号密码换取,有效期 48 小时;无需 Bearer 前缀)。
|
||||
/// 与客户 HMAC 分组 openApi 相互独立。
|
||||
/// </summary>
|
||||
[ApiExplorerSettings(GroupName = "warehouseOpenApi")]
|
||||
public class WarehouseOpenApiService : ApplicationService
|
||||
{
|
||||
private AccountManager AccountManager =>
|
||||
LazyServiceProvider.GetRequiredService<AccountManager>();
|
||||
|
||||
private IJwtService JwtService =>
|
||||
LazyServiceProvider.GetRequiredService<IJwtService>();
|
||||
|
||||
private OpenApiTokenIssuer TokenIssuer =>
|
||||
LazyServiceProvider.GetRequiredService<OpenApiTokenIssuer>();
|
||||
|
||||
private IOrderRepository OrderRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IOrderRepository>();
|
||||
|
||||
private OrderFileManager OrderFileManager =>
|
||||
LazyServiceProvider.GetRequiredService<OrderFileManager>();
|
||||
|
||||
private InboundRecordService InboundRecordService =>
|
||||
LazyServiceProvider.GetRequiredService<InboundRecordService>();
|
||||
|
||||
private OutboundRecordService OutboundRecordService =>
|
||||
LazyServiceProvider.GetRequiredService<OutboundRecordService>();
|
||||
|
||||
/// <summary>
|
||||
/// 获取 Token(有效期 48 小时)。后续请求 Header:Authorization: {token}(无需 Bearer)。
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("/openapi/warehouse/token")]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public async Task<string> 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<UserProfileRepository>();
|
||||
await profileRepository.EnsureEnabledOrThrowAsync(user.Id, cancellationToken);
|
||||
var claims = await JwtService.GenerateClaimsByUserIdAsync(user.Id, cancellationToken);
|
||||
return TokenIssuer.Issue(claims);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入库上传件重尺。与操作台「入库扫描」页「入库」按钮同源(POST /operation/inbound/create)。
|
||||
/// 写入收货件重尺、入库记录,并触发入库计费/自动触发。重量默认 kg,长度默认 cm。
|
||||
/// Header:Authorization: {token}(无需 Bearer)。
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpPost("/openapi/warehouse/inbound")]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public Task<OpenApiSimpleResult> InboundAsync([FromBody] OpenApiUploadWeightInput input) =>
|
||||
UploadWeightCoreAsync(input, forceMode: 0);
|
||||
|
||||
/// <summary>
|
||||
/// 上传件重尺。mode:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。
|
||||
/// Header:Authorization: {token}(无需 Bearer)。
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpPost("/openapi/warehouse/weight/upload")]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public Task<OpenApiSimpleResult> UploadWeightAsync([FromBody] OpenApiUploadWeightInput input) =>
|
||||
UploadWeightCoreAsync(input, forceMode: null);
|
||||
|
||||
private async Task<OpenApiSimpleResult> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上传红外图片。Base64 解码后转存 COS,写入订单附件 fileType=infraredImage(Source=upload),
|
||||
/// 与订单详情「附件」Tab →「红外图片」→「点击上传」(POST /order/files/upload)同源展示。
|
||||
/// 成功无响应体。Header:Authorization: {token}(无需 Bearer)。
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpPost("/openapi/warehouse/image/upload")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> 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<int>() : 0;
|
||||
// 固定红外图片类型,路径 uploads/order-files/{orderId}/infraredImage/...
|
||||
await OrderFileManager.AddFromBase64Async(
|
||||
order.Id,
|
||||
OrderFileTypeCodes.InfraredImage,
|
||||
input.ImageBase64,
|
||||
operatorUserId,
|
||||
cancellationToken);
|
||||
|
||||
return new NoContentResult();
|
||||
}
|
||||
}
|
||||
@ -82,7 +82,7 @@ public class InboundRecordService : ApplicationService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增入库记录。
|
||||
/// 新增入库记录(入库扫描页「入库」按钮;仓配开放接口 POST /openapi/warehouse/inbound 同源)。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:入库记录创建参数。</param>
|
||||
/// <returns>入库记录结果。</returns>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预报计费重试耗尽或业务失败时,把原因写回订单,避免一直显示「处理中」。
|
||||
/// 更具体的失败原因已经写上时不再覆盖。
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -363,6 +363,221 @@ public sealed class OrderFileManager(
|
||||
return ".pdf";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 Base64 写入订单附件:先转存对象存储(COS),再落 t_order_file。
|
||||
/// 与订单详情附件 Tab「点击上传」(POST /order/files/upload)同源校验与路径约定。
|
||||
/// </summary>
|
||||
public async Task<OrderFileItemOutput> 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 与 multipart 上传共用:校验扩展名/大小后写入 COS,返回逻辑 object key。
|
||||
/// </summary>
|
||||
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<List<OrderFileItemOutput>> 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) =>
|
||||
|
||||
@ -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<int, byte> 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<OrderParcelCreateOutput> 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<int>() : 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<int>() : 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
|
||||
{
|
||||
// 回滚失败不覆盖原始业务错误;半成品订单需运维清理
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建流程中途失败时删除已落库的半成品订单,避免自动变成草稿。
|
||||
/// </summary>
|
||||
@ -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<int>() : 0));
|
||||
var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
|
||||
var authorization = CaptureRequestAuthorization();
|
||||
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>();
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// 创单/预报请求的 Authorization,供排货后异步创建轨迹订单转发到轨迹网关。
|
||||
/// </summary>
|
||||
public string? Authorization { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创单事务提交后,9610、预报计费、揽收、面单、自动触发各自提交。
|
||||
/// 轨迹订单等排货成功后再创建。
|
||||
/// </summary>
|
||||
private async Task ScheduleCreateSideEffectsAsync(
|
||||
int orderId,
|
||||
OrderParcelCreateInput input,
|
||||
OrderParcelCreateOutput output,
|
||||
bool includeForecast,
|
||||
int operatorUserId)
|
||||
{
|
||||
var unitOfWorkManager = LazyServiceProvider.GetRequiredService<IUnitOfWorkManager>();
|
||||
var scopeFactory = LazyServiceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>().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<OrderService>().PublishCustomsForOrderAsync(orderId, platformCode));
|
||||
|
||||
var forecastQueued = true;
|
||||
if (includeForecast)
|
||||
{
|
||||
forecastQueued = await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp =>
|
||||
{
|
||||
var service = sp.GetRequiredService<OrderService>();
|
||||
await service.EnqueueForecastBillingByIdAsync(
|
||||
orderId,
|
||||
couponInstanceId,
|
||||
operatorUserId,
|
||||
authorization);
|
||||
});
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp =>
|
||||
sp.GetRequiredService<OrderService>().PublishForecastLabelAsync(orderId, operatorUserId));
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp =>
|
||||
sp.GetRequiredService<OrderService>().PublishForecastAutoTriggerAsync(orderId, operatorUserId));
|
||||
}
|
||||
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp =>
|
||||
sp.GetRequiredService<OrderService>().TryAttachPickupAfterParcelCreateAsync(input, output));
|
||||
|
||||
if (includeForecast && !forecastQueued)
|
||||
{
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp =>
|
||||
sp.GetRequiredService<OrderService>().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<IUnitOfWorkManager>();
|
||||
var scopeFactory = LazyServiceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("Oms.ForecastBilling");
|
||||
await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () =>
|
||||
OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", sp =>
|
||||
sp.GetRequiredService<OrderService>().EnqueueForecastBillingByIdAsync(
|
||||
orderId,
|
||||
couponInstanceId,
|
||||
operatorUserId,
|
||||
authorization)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 草稿重新预报提交后,面单和下单自动触发各自入队。轨迹等排货成功后再创建。
|
||||
/// </summary>
|
||||
private async Task ScheduleForecastSideEffectsAsync(int orderId, int operatorUserId)
|
||||
{
|
||||
var unitOfWorkManager = LazyServiceProvider.GetRequiredService<IUnitOfWorkManager>();
|
||||
var scopeFactory = LazyServiceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("Oms.ForecastBilling");
|
||||
await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () =>
|
||||
{
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp =>
|
||||
sp.GetRequiredService<OrderService>().PublishForecastLabelAsync(orderId, operatorUserId));
|
||||
await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp =>
|
||||
sp.GetRequiredService<OrderService>().PublishForecastAutoTriggerAsync(orderId, operatorUserId));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预报计费事务提交后,再创建轨迹订单(此时已有渠道和 ApiCode)。
|
||||
/// </summary>
|
||||
private async Task ScheduleTrackAfterAllocationAsync(int orderId, string? authorization)
|
||||
{
|
||||
var unitOfWorkManager = LazyServiceProvider.GetRequiredService<IUnitOfWorkManager>();
|
||||
var scopeFactory = LazyServiceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("Oms.ForecastBilling");
|
||||
await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () =>
|
||||
OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp =>
|
||||
sp.GetRequiredService<OrderService>().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<ILoggerFactory>()
|
||||
.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<IOrderSideEffectEventPublisher>();
|
||||
|
||||
/// <summary>
|
||||
/// 创单/草稿预报成功后:轨迹订单+A0101 合并事件,系统面单独立事件(Fake.EventBus.RabbitMQ)。
|
||||
/// </summary>
|
||||
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<IHttpContextAccessor>()
|
||||
.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<int>() : 0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。
|
||||
/// 入库等非创单路径仍可调用;创单路径请走 <see cref="PublishParcelCreateTrackAndLabelAsync"/>。
|
||||
/// </summary>
|
||||
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<IHttpContextAccessor>()
|
||||
.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<IHttpContextAccessor>()
|
||||
.HttpContext?
|
||||
.Request
|
||||
.Headers
|
||||
.Authorization
|
||||
.ToString();
|
||||
return string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -603,38 +603,40 @@ public partial class OrderService : ApplicationService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建订单包裹。
|
||||
/// 创建订单包裹(PC/H5 确认下单或保存草稿)。
|
||||
/// <para>
|
||||
/// 【同步,本接口事务内】RequestId 幂等 → 校验入参/销售产品 → 黑名单 →
|
||||
/// 自动触发「拦截」规则(命中直接失败)→ 生成运单号 → 落库订单与子表。
|
||||
/// 确认下单:同步写偏远标记,状态草稿 +「预报计费/排货处理中」后返回;
|
||||
/// 保存草稿:仅落库,不做预报计费。失败会回滚订单,不落草稿。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 【创单事务提交后各自投递 MQ(互不等待;失败不回滚订单)】
|
||||
/// 1) 9610 订单 Upsert(customs-9610)— 草稿与确认下单都会投;
|
||||
/// 2) 预报计费/排货任务(forecast-billing 批量任务)— 仅确认下单;
|
||||
/// 队列关闭时在后台同步跑同一套计费逻辑;
|
||||
/// 3) 系统面单 PDF(label-generate)— 仅确认下单;
|
||||
/// 4) 下单自动触发(OrderCreate:警告/问题件等,不含拦截)— 仅确认下单;
|
||||
/// 5) 上门揽收挂载(pickup-attach)— 仅上门揽收且地址/时间/仓库齐全时;
|
||||
/// 投递失败则同步回落建揽收单。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 【预报计费消费后】计费重、应收、排货应付、营销快照 → 订单改已预报。
|
||||
/// 排货成功且已有 ChannelCode 时,再投递轨迹订单 + A0101(track-upsert);
|
||||
/// 轨迹网关需要登录 Authorization,创单时写入预报任务 Payload,消费端原样转发;
|
||||
/// 排货失败不阻断预报,也不建轨迹(可页面补做)。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="input">入参:订单包裹创建参数。</param>
|
||||
/// <returns>订单包裹创建结果。</returns>
|
||||
/// <param name="input">入参:订单包裹创建参数。SaveAsDraft=true 为仅保存草稿。</param>
|
||||
/// <returns>
|
||||
/// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已排队;
|
||||
/// 上门揽收时 PickupQueued / PickupNo 视投递或同步回落结果而定。
|
||||
/// </returns>
|
||||
[HttpPost("/order/parcel/create")]
|
||||
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||
public async Task<OrderParcelCreateOutput> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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 = "上门揽收需选择揽收地址";
|
||||
|
||||
@ -525,12 +525,12 @@ internal static class ParcelBillingCalculator
|
||||
priceSnapshot.Prices.Select(p => (p.Status, p.Details)));
|
||||
|
||||
private static IReadOnlyList<string> CollectDistinctDetailZoneKeys(
|
||||
IEnumerable<(byte Status, List<ChannelPriceDetailOutput>? Details)> prices)
|
||||
IEnumerable<(byte Status, List<ChannelPriceDetailOutput> Details)> prices)
|
||||
{
|
||||
var zones = new HashSet<string>(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))
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -429,7 +431,30 @@ public class PlatformStoreOrderService : ApplicationService
|
||||
input.ProductCode,
|
||||
input.WarehouseId,
|
||||
input.Items,
|
||||
requireBind: true);
|
||||
requireBind: true,
|
||||
requireWmsProductGroup: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量创建仓储代发订单:与代发运单同链路(建单+绑定+产品计费),
|
||||
/// 但产品必须属于分组 wms(仓储代发)。
|
||||
/// </summary>
|
||||
[HttpPost("/order/platform-store-order/batch-create-fulfill-wms")]
|
||||
public async Task<PlatformStoreOrderBatchCreateLogisticsOutput> 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<PlatformStoreOrderBatchCreateLogisticsOutput> BatchCreateLogisticsInternalAsync(
|
||||
@ -437,7 +462,8 @@ public class PlatformStoreOrderService : ApplicationService
|
||||
string? productCode,
|
||||
int? warehouseId,
|
||||
List<PlatformStoreOrderBatchCreateItemInput> 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<OrderStockBindSaveItemInput>? 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<string> ResolveProductCodeRequiredAsync(string productCode)
|
||||
private async Task<string> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建运单/代发:不限制履约状态;拦截搁置与未删除的已关联物流单。
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
@ -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(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用或禁用用户。禁用后无法登录后台。
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改用户密码。
|
||||
/// </summary>
|
||||
@ -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("不能禁用当前登录账号");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<INoticeRepository>();
|
||||
private INoticeReadRepository NoticeReadRepository => LazyServiceProvider.GetRequiredService<INoticeReadRepository>();
|
||||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||
@ -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<long>(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<NoticeReceiverOptionRow>(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<UserIdQueryResult>(sql)
|
||||
.Select(x => x.UserId)
|
||||
.ToListAsync();
|
||||
|
||||
290
src/Oms.Application/Shared/AutoTriggerImportHelper.cs
Normal file
290
src/Oms.Application/Shared/AutoTriggerImportHelper.cs
Normal file
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 自动触发规则 Excel 模板与导入解析。
|
||||
/// </summary>
|
||||
public static class AutoTriggerImportHelper
|
||||
{
|
||||
public const string TemplateSheetName = "导入模板";
|
||||
public const string EnumSheetName = "枚举值";
|
||||
|
||||
private static readonly Dictionary<string, string> 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<byte[]> BuildTemplateBytesAsync()
|
||||
{
|
||||
var templateRows = new List<Dictionary<string, object?>>
|
||||
{
|
||||
new()
|
||||
{
|
||||
["规则名称"] = "英国超尺寸拦截示例",
|
||||
["优先级"] = 100,
|
||||
["状态"] = "启用",
|
||||
["操作"] = "拦截",
|
||||
["触发时机"] = "下单",
|
||||
["触发条件"] = "{\n \"expression\": \"收件国家 == \\\"GB\\\"\"\n}",
|
||||
["提示语"] = "超尺寸,禁止下单",
|
||||
["通知人"] = ""
|
||||
}
|
||||
};
|
||||
|
||||
var enumRows = new List<Dictionary<string, object?>>
|
||||
{
|
||||
new() { ["枚举类型"] = "状态", ["编码"] = "2", ["名称"] = "启用" },
|
||||
new() { ["枚举类型"] = "状态", ["编码"] = "1", ["名称"] = "禁用" },
|
||||
new() { ["枚举类型"] = "操作", ["编码"] = "intercept", ["名称"] = "拦截" },
|
||||
new() { ["枚举类型"] = "操作", ["编码"] = "warn", ["名称"] = "警告" },
|
||||
new() { ["枚举类型"] = "操作", ["编码"] = "problem", ["名称"] = "问题件" },
|
||||
new() { ["枚举类型"] = "触发时机", ["编码"] = "order_create", ["名称"] = "下单" },
|
||||
new() { ["枚举类型"] = "触发时机", ["编码"] = "inbound", ["名称"] = "入库" },
|
||||
new() { ["枚举类型"] = "触发时机", ["编码"] = "outbound", ["名称"] = "出库" },
|
||||
};
|
||||
|
||||
var sheets = new Dictionary<string, object>
|
||||
{
|
||||
[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<string> 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<List<(int RowNumber, AutoTrigger Entity)>> 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<string, object> 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<string, object> rowDict)
|
||||
{
|
||||
var mapped = new Dictionary<string, string?>(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<string, object> rowDict)
|
||||
{
|
||||
foreach (var value in rowDict.Values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(NormalizeCell(value)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -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<int?>(
|
||||
$"""
|
||||
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<int?>(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<int?>(
|
||||
$"""
|
||||
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<int?>(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<int?>(
|
||||
$"SELECT 1 AS Value FROM `user` WHERE `Account` = {account} AND `Id` <> {id.ToString()} LIMIT 1")
|
||||
.SqlQueryRaw<int?>(sql, account, id.ToString())
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return hit.HasValue;
|
||||
}
|
||||
|
||||
var any = await rbacDbContext.Database
|
||||
.SqlQuery<int?>(
|
||||
$"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<int?>(sql, account)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return any.HasValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> 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<int?>(
|
||||
$"SELECT 1 AS Value FROM `user` WHERE `Email` = {email} AND `Id` <> {id.ToString()} LIMIT 1")
|
||||
.SqlQueryRaw<int?>(sql, email, id.ToString())
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return hit.HasValue;
|
||||
}
|
||||
|
||||
var any = await rbacDbContext.Database
|
||||
.SqlQuery<int?>(
|
||||
$"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<int?>(sql, email)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return any.HasValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> 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<int?>(
|
||||
$"SELECT 1 AS Value FROM `user` WHERE `Phone` = {phone} AND `Id` <> {id.ToString()} LIMIT 1")
|
||||
.SqlQueryRaw<int?>(sql, phone, id.ToString())
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return hit.HasValue;
|
||||
}
|
||||
|
||||
var any = await rbacDbContext.Database
|
||||
.SqlQuery<int?>(
|
||||
$"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<int?>(sql, phone)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return any.HasValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> 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<int?>(
|
||||
$"SELECT 1 AS Value FROM `user` WHERE `WechatId` = {wechatId} AND `Id` <> {id.ToString()} LIMIT 1")
|
||||
.SqlQueryRaw<int?>(sql, wechatId, id.ToString())
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return hit.HasValue;
|
||||
}
|
||||
|
||||
var any = await rbacDbContext.Database
|
||||
.SqlQuery<int?>(
|
||||
$"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<int?>(sql, wechatId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return any.HasValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractDeepMessage(Exception ex)
|
||||
|
||||
@ -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<RbacUserNameRow>(sql)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
34
src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs
Normal file
34
src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs
Normal file
@ -0,0 +1,34 @@
|
||||
using Fake.Application;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 开放接口件重尺单位换算为系统内部单位(kg / cm)。
|
||||
/// </summary>
|
||||
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}")
|
||||
};
|
||||
}
|
||||
}
|
||||
47
src/Oms.Application/Shared/OpenApiTokenIssuer.cs
Normal file
47
src/Oms.Application/Shared/OpenApiTokenIssuer.cs
Normal file
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 仓配开放接口 JWT 签发(固定 48 小时有效期,签名密钥与门户共用)。
|
||||
/// </summary>
|
||||
public class OpenApiTokenIssuer(IOptions<FakeJwtOptions> 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<Claim> 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);
|
||||
}
|
||||
}
|
||||
@ -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<AccountRow>(sql, key)
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
namespace Oms.Domain.ChannelsAggregate.Consts;
|
||||
|
||||
/// <summary>
|
||||
/// 销售产品分组编码(字典 sales_product_group.DictCode)。
|
||||
/// </summary>
|
||||
public static class SalesProductGroupCodes
|
||||
{
|
||||
/// <summary>仓储代发(贴单出库 / 仓内履约计费)。</summary>
|
||||
public const string Wms = "wms";
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -132,4 +132,9 @@ public interface IOrderRepository : IRepository<Order>
|
||||
DateTime startInclusive,
|
||||
DateTime endExclusive,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 只更新标签模板 Id,避免面单生成覆盖预报计费正在写入的订单字段。
|
||||
/// </summary>
|
||||
Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -140,7 +140,7 @@ public class CustomerRepository : OmsSqlSugarRepository<Customer>, 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);
|
||||
|
||||
@ -1021,6 +1021,15 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
await db.Updateable<Order>()
|
||||
.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);
|
||||
|
||||
@ -14,8 +14,18 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Fake.Autofac" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Testing" Version="$(FakePackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- EventBus:与 Oms.Application / SimpleAdmin.Api 保持一致(本地工程或 NuGet) -->
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' == 'true'">
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus.IntegrationEventLog\Fake.EventBus.IntegrationEventLog.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' != 'true'">
|
||||
<PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="8.7.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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<OmsLoginResultDto> LoginAdminAsync(string account, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await accountManager.ValidateCredentialsAsync(account, password, cancellationToken);
|
||||
var profileRepository = LazyServiceProvider.GetRequiredService<Oms.Application.Rbac.UserProfileRepository>();
|
||||
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<Guid?> GetRbacUserGuidByMapIdAsync(int userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await rbacDbContext.Database.SqlQuery<Guid?>(
|
||||
$"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<Guid?>(sql, userId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<int?> GetMapIdByUserGuidAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await rbacDbContext.Database.SqlQuery<int?>(
|
||||
$"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<int?>(sql, userId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
namespace SimpleAdmin.Api.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 仓配开放 API:Authorization 直接传 JWT(不要求 Bearer 前缀)。
|
||||
/// 在进入 JwtBearer 前补上 Bearer,以便复用现有鉴权。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,15 +15,35 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Fake.Autofac" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Consul" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.FileManagement.Application" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.FileManagement.Infrastructure" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.ObjectStorage.TencentCos" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Serilog" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- EventBus:本地工程引用(调试) / NuGet(发包后) -->
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' == 'true'">
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus\Fake.EventBus.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus.IntegrationEventLog\Fake.EventBus.IntegrationEventLog.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\src\Fake.EventBus.Dashboard\Fake.EventBus.Dashboard.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseLocalFakeEventBus)' != 'true'">
|
||||
<PackageReference Include="Fake.EventBus.IntegrationEventLog" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.EventBus.Dashboard" Version="$(FakePackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- RBAC / FileManagement:本地工程(表前缀 fk_ 等) / NuGet -->
|
||||
<ItemGroup Condition="'$(UseLocalFakeModules)' == 'true'">
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\rbac\Fake.Rbac.Application\Fake.Rbac.Application.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\rbac\Fake.Rbac.Infrastructure\Fake.Rbac.Infrastructure.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\file-management\Fake.FileManagement.Application\Fake.FileManagement.Application.csproj" />
|
||||
<ProjectReference Include="$(FakeRepoRoot)\modules\file-management\Fake.FileManagement.Infrastructure\Fake.FileManagement.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseLocalFakeModules)' != 'true'">
|
||||
<PackageReference Include="Fake.FileManagement.Application" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.FileManagement.Infrastructure" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Oms.Application\Oms.Application.csproj" />
|
||||
<ProjectReference Include="..\Oms.SqlSugarCore\Oms.SqlSugarCore.csproj" />
|
||||
|
||||
@ -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<WarehouseOpenApiAuthHeaderMiddleware>();
|
||||
|
||||
// 认证和授权
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
@ -202,19 +208,11 @@ public class SimpleAdminApiModule : FakeModule
|
||||
// 角色/菜单写操作后失效登录用户信息缓存
|
||||
app.UseMiddleware<RbacCacheInvalidationMiddleware>();
|
||||
|
||||
// 端点映射
|
||||
// 端点映射(Dashboard 用 MapGroup,依赖 PathBase 已剥离前缀)
|
||||
app.MapControllers();
|
||||
// 必须在 UsePathBase 之后:完整对外路径为 /admin/eventbus
|
||||
app.UseFakeEventBusDashboard();
|
||||
|
||||
// FileManagement / Outbox 与业务同库并行(EF)
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var fileDb = scope.ServiceProvider.GetRequiredService<FileManagementDbContext>();
|
||||
fileDb.Database.Migrate();
|
||||
|
||||
var outboxDb = scope.ServiceProvider.GetRequiredService<IntegrationEventLogContext>();
|
||||
IntegrationEventLogSchema.EnsureTablesAsync(outboxDb).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
app.Services.GetRequiredService<IFeiShuNotificationService>().Send("Application started");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<FakeJwtOptions> jwtOptions, UserService u
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var claims = await base.GenerateClaimsByUserIdAsync(userId, cancellationToken);
|
||||
var mapId = await rbacDbContext.Database.SqlQuery<int?>(
|
||||
$"select `userid` as `Value` from `user` where `Id` = {userId}")
|
||||
var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}";
|
||||
var mapId = await rbacDbContext.Database.SqlQueryRaw<int?>(sql, userId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (mapId.HasValue)
|
||||
{
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
using Fake.EventBus.IntegrationEventLog;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SimpleAdmin.Api.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Outbox/Inbox 与业务同库时 EnsureCreated 无效(库已存在),用 IF NOT EXISTS 建表。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<int> GetRbacUserMapIdOrThrowAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = await rbacDbContext.Database.SqlQuery<int?>(
|
||||
$"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<int?>(sql, id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (!userId.HasValue || userId.Value <= 0)
|
||||
{
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user