1.05 #7

Merged
zzh merged 19 commits from test into master 2026-10-15 13:59:09 +08:00
67 changed files with 2309 additions and 670 deletions

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -82,7 +82,7 @@ public class InboundRecordService : ApplicationService
}
/// <summary>
/// 新增入库记录。
/// 新增入库记录(入库扫描页「入库」按钮;仓配开放接口 POST /openapi/warehouse/inbound 同源)。
/// </summary>
/// <param name="input">入参:入库记录创建参数。</param>
/// <returns>入库记录结果。</returns>

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = "上门揽收需选择揽收地址";

View File

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

View File

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

View File

@ -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("不能禁用当前登录账号");
}
}
}

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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