From dd1318bc00398c616fee6007bc4f6f0e0a1698ec Mon Sep 17 00:00:00 2001
From: xiaolipro <2357729423@qq.com>
Date: Tue, 22 Sep 2026 16:59:33 +0800
Subject: [PATCH 01/41] =?UTF-8?q?feat=EF=BC=9A=E5=AF=B9=E6=8E=A5fake=20oss?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 49 +-
Directory.Build.props | 2 +-
.../Labeling/LabelPdfGenerationService.cs | 5 +-
src/Oms.Application/Oms.Application.csproj | 3 +-
src/Oms.Application/OmsApplicationModule.cs | 3 +-
.../Services/Orders/OrderFileManager.cs | 9 +-
.../Services/Orders/OrderService.Helpers.cs | 8 +-
.../Services/Orders/OrderService.StockBind.cs | 1 +
.../Services/Orders/OrderService.cs | 5 +-
.../Services/Rbac/OmsUserService.cs | 9 +-
.../Services/Recharge/RechargeService.cs | 3 +-
.../Services/Storage/FileStorageService.cs | 161 ------
.../Warehouse/WarehouseService.Parcel.cs | 2 +-
.../Warehouse/WarehouseService.Sku.cs | 7 +-
.../Services/Xhs/XhsMediaService.cs | 5 +-
.../Shared/OmsFileUploadHelper.cs | 29 +-
.../Storage/DirectUploadTickets.cs | 50 --
.../Storage/FileStorageOptions.cs | 66 ---
.../Storage/FileStorageServiceExtensions.cs | 93 ----
.../Storage/FileStorageUploadPaths.cs | 59 ---
src/Oms.Application/Storage/IFileStorage.cs | 54 --
.../Storage/LocalFileStorage.cs | 171 ------
.../Storage/OmsObjectStorageExtensions.cs | 86 ++++
.../Storage/TencentCosFileStorage.cs | 487 ------------------
src/SimpleAdmin.Api/Auth/AuthService.cs | 5 +-
src/SimpleAdmin.Api/SimpleAdmin.Api.csproj | 3 +
src/SimpleAdmin.Api/SimpleAdminApiModule.cs | 23 +-
.../Storage/ObjectStorageStartupLogger.cs | 36 ++
.../appsettings.Development.json | 42 +-
29 files changed, 263 insertions(+), 1213 deletions(-)
delete mode 100644 src/Oms.Application/Services/Storage/FileStorageService.cs
delete mode 100644 src/Oms.Application/Storage/DirectUploadTickets.cs
delete mode 100644 src/Oms.Application/Storage/FileStorageOptions.cs
delete mode 100644 src/Oms.Application/Storage/FileStorageServiceExtensions.cs
delete mode 100644 src/Oms.Application/Storage/FileStorageUploadPaths.cs
delete mode 100644 src/Oms.Application/Storage/IFileStorage.cs
delete mode 100644 src/Oms.Application/Storage/LocalFileStorage.cs
create mode 100644 src/Oms.Application/Storage/OmsObjectStorageExtensions.cs
delete mode 100644 src/Oms.Application/Storage/TencentCosFileStorage.cs
create mode 100644 src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 9d38b61..e2f0a9a 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -1,10 +1,10 @@
-# 复制到 admin .gitea/workflows/deploy.yml
+# 复制到 admin .gitea/workflows/deploy.yml
+# push master → prod;push test → test
name: Build and Deploy
on:
push:
- branches: [master, main]
- workflow_dispatch:
+ branches: [master, test]
env:
CSPROJ: src/SimpleAdmin.Api/SimpleAdmin.Api.csproj
@@ -21,14 +21,47 @@ jobs:
- name: Build and deploy
run: |
set -euxo pipefail
- export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
- TAG="${{ gitea.sha }}"
- IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}"
+ REF="${{ gitea.ref_name }}"
+ case "$REF" in
+ test) TARGET=test ;;
+ master) TARGET=prod ;;
+ *) echo "unsupported branch: $REF"; exit 1 ;;
+ esac
+
+ SHA="${{ gitea.sha }}"
REPO="${{ gitea.repository }}"
test -n "$REPO" && [[ "$REPO" == */* ]]
- echo "REPO=$REPO TAG=$TAG host=$(hostname)"
+
+ case "$TARGET" in
+ test) TAG="test-${SHA}" ;;
+ *) TAG="${SHA}" ;;
+ esac
+
+ IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}"
+ echo "TARGET=$TARGET REF=$REF REPO=$REPO TAG=$TAG host=$(hostname)"
+
+ export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
+ unset NAMESPACE
cd /data/k3s
./scripts/ci-publish-container.sh "$REPO" "${{ env.CSPROJ }}" \
- "${{ env.IMAGE_NAME }}" "$TAG" "$TAG"
+ "${{ env.IMAGE_NAME }}" "$TAG" "$SHA"
+
+ case "$TARGET" in
+ test)
+ export KUBECONFIG=/etc/rancher/k3s/k3s-test.yaml
+ export NAMESPACE=test
+ export MANIFEST_ROOT=/data/k3s-test
+ ;;
+ *)
+ export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
+ export NAMESPACE=prod
+ export MANIFEST_ROOT=/data/k3s
+ ;;
+ esac
+ if [[ ! -f "$KUBECONFIG" ]]; then
+ echo "FAIL: missing KUBECONFIG=$KUBECONFIG host=$(hostname) uid=$(id -u)"
+ ls -la /etc/rancher/k3s/ /root/.kube/ 2>/dev/null || true
+ exit 1
+ fi
./scripts/ci-deploy-api.sh "${{ env.DEPLOY_NAME }}" \
"${{ env.CONTAINER_NAME }}" "$IMG"
diff --git a/Directory.Build.props b/Directory.Build.props
index e7da47d..7d6f04d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,6 @@
- 8.0.5.16
+ 8.0.5.17
diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs
index 8b3516b..a6da67b 100644
--- a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs
+++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs
@@ -1,4 +1,5 @@
using Fake.Application;
+using Fake.ObjectStorage;
using Microsoft.Extensions.Logging;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders;
@@ -22,7 +23,7 @@ public sealed class LabelPdfGenerationService(
ISystemOtherConfigRepository systemOtherConfigRepository,
IOrderRepository orderRepository,
OrderFileManager orderFileManager,
- IFileStorage fileStorage,
+ IObjectStorage fileStorage,
ILogger logger)
{
public async Task GenerateAndPersistAsync(
@@ -167,7 +168,7 @@ public sealed class LabelPdfGenerationService(
var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf";
var relativeKey = $"uploads/prints/orders/{fileName}";
await using var stream = new MemoryStream(pdfBytes);
- return await fileStorage.SaveAsync(stream, relativeKey, "application/pdf", cancellationToken);
+ return await fileStorage.SaveObjectAsync(stream, relativeKey, "application/pdf", cancellationToken);
}
}
diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj
index 8783a7d..73214d1 100644
--- a/src/Oms.Application/Oms.Application.csproj
+++ b/src/Oms.Application/Oms.Application.csproj
@@ -20,6 +20,7 @@
+
@@ -29,8 +30,6 @@
-
-
diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs
index 7603018..acd9b8a 100644
--- a/src/Oms.Application/OmsApplicationModule.cs
+++ b/src/Oms.Application/OmsApplicationModule.cs
@@ -13,7 +13,6 @@ using Oms.Application.Integrations.PlatformShop;
using Oms.Application.Labeling;
using Oms.Application.Labeling.Hiprint;
using Oms.Application.Shared;
-using Oms.Application.Storage;
using Oms.Application.Workers;
using Oms.Domain;
@@ -21,6 +20,7 @@ namespace Oms.Application;
///
/// OMS 应用层模块注册。
+/// 对象存储由 Api 宿主注册 Fake.ObjectStorage(Local / TencentCos);文件元数据走 Fake.FileManagement。
///
[DependsOn(
typeof(OmsDomainModule),
@@ -52,7 +52,6 @@ public class OmsApplicationModule : FakeModule
context.Services.AddTransient();
context.Services.AddTransient();
- context.Services.AddOmsFileStorage(configuration);
context.Services.AddOmsSmallBagTrack(configuration);
context.Services.AddOmsItfxIntegration(configuration);
context.Services.AddOmsPlatformShopIntegration(configuration);
diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs
index b4ae163..93c8fc5 100644
--- a/src/Oms.Application/Services/Orders/OrderFileManager.cs
+++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs
@@ -1,5 +1,6 @@
using Fake.Application;
using Fake.DependencyInjection;
+using Fake.ObjectStorage;
using Microsoft.AspNetCore.Http;
using Oms.Application.Dtos.Orders;
using Oms.Application.Storage;
@@ -14,7 +15,7 @@ namespace Oms.Application.Services.Orders;
///
public sealed class OrderFileManager(
IOrderFileRepository orderFileRepository,
- IFileStorage fileStorage) : IScopedDependency
+ IObjectStorage fileStorage) : IScopedDependency
{
private static readonly HashSet ImageExt =
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
@@ -94,7 +95,7 @@ public sealed class OrderFileManager(
OrderId = orderId,
FileType = OrderFileTypeCodes.SystemLabel,
FileName = fileName,
- FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
+ FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
ContentType = contentType,
FileSize = fileSize,
Sort = 0,
@@ -143,7 +144,7 @@ public sealed class OrderFileManager(
OrderId = orderId,
FileType = OrderFileTypeCodes.DownstreamLabel,
FileName = TryFileNameFromUrl(fileUrl),
- FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
+ FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
ContentType = null,
Sort = existing.Count,
Source = OrderFileSourceCodes.Downstream,
@@ -305,7 +306,7 @@ public sealed class OrderFileManager(
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.SaveAsync(stream, relativeKey, file.ContentType);
+ var key = await fileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType);
return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length);
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index fc27a3d..63a6b9c 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -488,12 +488,12 @@ public partial class OrderService
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
}
- if (!await FileStorage.ExistsAsync(template.SourceFilePath))
+ if (!await FileStorage.ExistsAsync(FileStoragePath.NormalizeKey(template.SourceFilePath)))
{
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
}
- await using var stream = await FileStorage.OpenReadAsync(template.SourceFilePath);
+ await using var stream = await FileStorage.GetAsync(FileStoragePath.NormalizeKey(template.SourceFilePath));
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
@@ -628,7 +628,7 @@ public partial class OrderService
""";
var relativeKey = $"uploads/prints/orders/{fileName}";
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(html));
- var key = await FileStorage.SaveAsync(stream, relativeKey, "text/html; charset=utf-8");
+ var key = await FileStorage.SaveObjectAsync(stream, relativeKey, "text/html; charset=utf-8");
return FileStorage.GetPublicUrl(key);
}
@@ -647,7 +647,7 @@ public partial class OrderService
private async Task CleanupExpiredLocalExportFilesAsync()
{
// COS 侧建议用生命周期规则清理;本地仍清理 24h 前导出文件。
- if (FileStorage is not LocalFileStorage)
+ if (!FileStorage.IsLocalProvider())
{
return;
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs
index 09ec87b..04b0668 100644
--- a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
+using Oms.Application.Storage;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index 91bff8f..a7c7043 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -1,5 +1,6 @@
using Fake.Application;
using Fake.Domain.Exceptions;
+using Fake.ObjectStorage;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using MiniExcelLibs;
@@ -35,7 +36,7 @@ public partial class OrderService : ApplicationService
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService();
private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService();
- private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService();
+ private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService();
private LabelPdfGenerationService LabelPdfGenerationService =>
LazyServiceProvider.GetRequiredService();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
@@ -345,7 +346,7 @@ public partial class OrderService : ApplicationService
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders));
stream.Position = 0;
- var storageKey = await FileStorage.SaveAsync(
+ var storageKey = await FileStorage.SaveObjectAsync(
stream,
relativeKey,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs
index 45ae3aa..d4f4a2e 100644
--- a/src/Oms.Application/Services/Rbac/OmsUserService.cs
+++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs
@@ -6,6 +6,7 @@ using Fake.Rbac.Application.Dtos.User;
using Fake.Rbac.Application.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Fake.ObjectStorage;
using Oms.Application.Dtos.Rbac;
using Oms.Application.Rbac;
using Oms.Application.Storage;
@@ -24,7 +25,7 @@ public class OmsUserService(
UserService userService,
UserProfileRepository profileRepository,
IEmployeeRepository employeeRepository,
- IFileStorage fileStorage) : ApplicationService
+ IObjectStorage fileStorage) : ApplicationService
{
///
/// 获取用户详情(含扩展资料)。
@@ -98,7 +99,7 @@ public class OmsUserService(
Account = input.Account,
Password = input.Password,
Email = input.Email,
- Avatar = fileStorage.ToStorageKey(input.Avatar),
+ Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
OrganizationId = input.OrganizationId,
RoleIds = input.RoleIds,
}, cancellationToken);
@@ -136,7 +137,7 @@ public class OmsUserService(
{
Name = input.Name,
Email = input.Email,
- Avatar = fileStorage.ToStorageKey(input.Avatar),
+ Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
OrganizationId = input.OrganizationId,
},
cancellationToken);
@@ -174,7 +175,7 @@ public class OmsUserService(
[FromQuery] Guid id,
[FromQuery] string avatarUrl,
CancellationToken cancellationToken = default) =>
- userService.UpdateAvatarAsync(id, fileStorage.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken);
+ userService.UpdateAvatarAsync(id, OmsObjectStorageExtensions.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken);
///
/// 删除用户。
diff --git a/src/Oms.Application/Services/Recharge/RechargeService.cs b/src/Oms.Application/Services/Recharge/RechargeService.cs
index 0479431..200f43a 100644
--- a/src/Oms.Application/Services/Recharge/RechargeService.cs
+++ b/src/Oms.Application/Services/Recharge/RechargeService.cs
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
using MiniExcelLibs;
using Oms.Application.Dtos.Recharge;
using Oms.Application.Dtos.Shared;
+using Fake.ObjectStorage;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.CustomersAggregate.Repositories;
@@ -22,7 +23,7 @@ public class RechargeService : ApplicationService
{
private IRechargeRecordRepository RechargeRecordRepository => LazyServiceProvider.GetRequiredService();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService();
- private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService();
+ private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService();
///
/// 提交充值申请。
diff --git a/src/Oms.Application/Services/Storage/FileStorageService.cs b/src/Oms.Application/Services/Storage/FileStorageService.cs
deleted file mode 100644
index f2d91b0..0000000
--- a/src/Oms.Application/Services/Storage/FileStorageService.cs
+++ /dev/null
@@ -1,161 +0,0 @@
-using Fake.Application;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Oms.Application.Storage;
-
-namespace Oms.Application.Services.Storage;
-
-///
-/// 文件存储直传 / 临时访问。私有桶:前端拿凭证直传 COS,后端只存 key。
-///
-[Authorize]
-[ApiExplorerSettings(GroupName = "OMS-Storage")]
-public class FileStorageService : ApplicationService
-{
- private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService();
-
- ///
- /// 是否支持前端直传(Cos=true)。
- ///
- [HttpGet("/file-storage/capabilities")]
- public object GetCapabilities() => new
- {
- supportsClientDirectUpload = FileStorage.SupportsClientDirectUpload,
- provider = FileStorage.GetType().Name
- };
-
- ///
- /// 签发预签名 PUT 上传地址。前端对该 URL 直接 PUT 文件体,成功后把返回的 key 交给业务接口落库。
- ///
- [HttpPost("/file-storage/presign-upload")]
- public async Task PresignUploadAsync([FromBody] PresignUploadInput input)
- {
- if (string.IsNullOrWhiteSpace(input.FileName))
- {
- throw new BusinessException("fileName 不能为空");
- }
-
- var ticket = await FileStorage.CreatePresignedUploadAsync(
- input.Folder ?? "misc",
- input.FileName.Trim(),
- input.ContentType);
-
- return new PresignUploadOutput
- {
- Key = ticket.Key,
- UploadUrl = ticket.UploadUrl,
- Method = ticket.Method,
- ContentType = ticket.ContentType,
- Headers = ticket.Headers,
- ExpireAt = ticket.ExpireAt
- };
- }
-
- ///
- /// 签发 STS 临时密钥。前端用 cos-js-sdk-v5 等 SDK 直传;落库只存 key。
- ///
- [HttpPost("/file-storage/sts-upload")]
- public async Task StsUploadAsync([FromBody] PresignUploadInput input)
- {
- if (string.IsNullOrWhiteSpace(input.FileName))
- {
- throw new BusinessException("fileName 不能为空");
- }
-
- var ticket = await FileStorage.CreateStsUploadAsync(
- input.Folder ?? "misc",
- input.FileName.Trim());
-
- return new StsUploadOutput
- {
- Key = ticket.Key,
- CosKey = ticket.CosKey,
- Bucket = ticket.Bucket,
- Region = ticket.Region,
- TmpSecretId = ticket.TmpSecretId,
- TmpSecretKey = ticket.TmpSecretKey,
- SessionToken = ticket.SessionToken,
- StartTime = ticket.StartTime,
- ExpiredTime = ticket.ExpiredTime
- };
- }
-
- ///
- /// 按库内 key 生成临时访问地址(私有桶预签名 GET)。
- ///
- [HttpGet("/file-storage/access-url")]
- public AccessUrlOutput GetAccessUrl([FromQuery] string key)
- {
- if (string.IsNullOrWhiteSpace(key))
- {
- throw new BusinessException("key 不能为空");
- }
-
- var storageKey = FileStorage.ToStorageKey(key) ?? key.Trim();
- return new AccessUrlOutput
- {
- Key = storageKey,
- Url = FileStorage.GetPublicUrl(storageKey)
- };
- }
-
- ///
- /// 将完整 URL / 相对路径规范为库内 key(业务保存前可调用)。
- ///
- [HttpPost("/file-storage/normalize-key")]
- public NormalizeKeyOutput NormalizeKey([FromBody] NormalizeKeyInput input)
- {
- var key = FileStorage.ToStorageKey(input.PathOrUrl);
- return new NormalizeKeyOutput { Key = key };
- }
-}
-
-public class PresignUploadInput
-{
- /// 业务目录:avatars / sku / parcel / recharge / order-files / xhs 等。
- public string? Folder { get; set; }
-
- public string FileName { get; set; } = string.Empty;
-
- public string? ContentType { get; set; }
-}
-
-public class PresignUploadOutput
-{
- public string Key { get; set; } = string.Empty;
- public string UploadUrl { get; set; } = string.Empty;
- public string Method { get; set; } = "PUT";
- public string? ContentType { get; set; }
- public IReadOnlyDictionary Headers { get; set; } =
- new Dictionary();
- public DateTime ExpireAt { get; set; }
-}
-
-public class StsUploadOutput
-{
- public string Key { get; set; } = string.Empty;
- public string CosKey { get; set; } = string.Empty;
- public string Bucket { get; set; } = string.Empty;
- public string Region { get; set; } = string.Empty;
- public string TmpSecretId { get; set; } = string.Empty;
- public string TmpSecretKey { get; set; } = string.Empty;
- public string SessionToken { get; set; } = string.Empty;
- public long StartTime { get; set; }
- public long ExpiredTime { get; set; }
-}
-
-public class AccessUrlOutput
-{
- public string Key { get; set; } = string.Empty;
- public string Url { get; set; } = string.Empty;
-}
-
-public class NormalizeKeyInput
-{
- public string? PathOrUrl { get; set; }
-}
-
-public class NormalizeKeyOutput
-{
- public string? Key { get; set; }
-}
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
index 156cc0e..af27b0f 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
@@ -178,7 +178,7 @@ public partial class WarehouseService
throw new BusinessException("商品数量必须大于 0");
}
- var imageUrl = FileStorage.ToStorageKey(itemInput.ImageUrl);
+ var imageUrl = OmsObjectStorageExtensions.ToStorageKey(itemInput.ImageUrl);
if (imageUrl != null && imageUrl.Length > 1024)
{
imageUrl = imageUrl[..1024];
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
index 4f16e2e..6a87085 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse;
+using Fake.ObjectStorage;
using Oms.Application.Shared;
using Oms.Application.Storage;
@@ -11,8 +12,8 @@ namespace Oms.Application.Services.Warehouse;
public partial class WarehouseService
{
- private IFileStorage FileStorage =>
- LazyServiceProvider.GetRequiredService();
+ private IObjectStorage FileStorage =>
+ LazyServiceProvider.GetRequiredService();
///
/// 查询 SKU 列表。
@@ -117,7 +118,7 @@ public partial class WarehouseService
entity.SkuCode = skuCode;
entity.SkuName = skuName;
entity.SkuNameEn = string.IsNullOrWhiteSpace(input.SkuNameEn) ? null : input.SkuNameEn.Trim();
- entity.SkuImageUrl = FileStorage.ToStorageKey(input.SkuImageUrl);
+ entity.SkuImageUrl = OmsObjectStorageExtensions.ToStorageKey(input.SkuImageUrl);
entity.UnitPrice = input.UnitPrice;
entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim();
entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim();
diff --git a/src/Oms.Application/Services/Xhs/XhsMediaService.cs b/src/Oms.Application/Services/Xhs/XhsMediaService.cs
index c4feb9c..d22ff8f 100644
--- a/src/Oms.Application/Services/Xhs/XhsMediaService.cs
+++ b/src/Oms.Application/Services/Xhs/XhsMediaService.cs
@@ -1,4 +1,5 @@
using Fake.Application;
+using Fake.ObjectStorage;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Xhs;
@@ -15,7 +16,7 @@ public class XhsMediaService : ApplicationService
{
private IXhsMediaRepository MediaRepository => LazyServiceProvider.GetRequiredService();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService();
- private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService();
+ private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService();
///
/// 上传媒体文件
@@ -34,7 +35,7 @@ public class XhsMediaService : ApplicationService
var relativeKey = $"uploads/xhs/{customerId}/{storedName}";
await using var stream = file.OpenReadStream();
- var storageKey = await FileStorage.SaveAsync(stream, relativeKey, file.ContentType);
+ var storageKey = await FileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType);
var publicUrl = FileStorage.GetPublicUrl(storageKey);
var media = new XhsMedia
diff --git a/src/Oms.Application/Shared/OmsFileUploadHelper.cs b/src/Oms.Application/Shared/OmsFileUploadHelper.cs
index 0c479fc..157cec9 100644
--- a/src/Oms.Application/Shared/OmsFileUploadHelper.cs
+++ b/src/Oms.Application/Shared/OmsFileUploadHelper.cs
@@ -1,12 +1,14 @@
using Fake.Application;
+using Fake.ObjectStorage;
using Microsoft.AspNetCore.Http;
using Oms.Application.Storage;
namespace Oms.Application.Shared;
///
-/// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。
-/// 返回值为 object key,对外展示请再调用 。
+/// OMS 上传文件辅助(经 )。
+/// 返回值为 object key,对外展示请再调用 。
+/// 用户直传请走 Fake.FileManagement(Presign/Sts + Confirm → FileId)。
///
public static class OmsFileUploadHelper
{
@@ -15,27 +17,18 @@ public static class OmsFileUploadHelper
private const long MaxImageBytes = 5 * 1024 * 1024;
- ///
- /// 保存充值截图,返回可访问 URL。
- ///
- public static Task SaveRechargeScreenshotAsync(IFormFile? file, IFileStorage storage) =>
+ public static Task SaveRechargeScreenshotAsync(IFormFile? file, IObjectStorage storage) =>
SaveImageAsync(file, storage, "recharge", "充值截图");
- ///
- /// 保存仓储 SKU 图片。
- ///
- public static Task SaveWarehouseSkuImageAsync(IFormFile file, IFileStorage storage) =>
+ public static Task SaveWarehouseSkuImageAsync(IFormFile file, IObjectStorage storage) =>
SaveImageAsync(file, storage, "sku", "SKU 图片");
- ///
- /// 保存包裹商品图片。
- ///
- public static Task SaveWarehouseParcelImageAsync(IFormFile file, IFileStorage storage) =>
+ public static Task SaveWarehouseParcelImageAsync(IFormFile file, IObjectStorage storage) =>
SaveImageAsync(file, storage, "parcel", "包裹商品图片");
private static async Task SaveImageAsync(
IFormFile? file,
- IFileStorage storage,
+ IObjectStorage storage,
string folder,
string label)
{
@@ -70,7 +63,7 @@ public static class OmsFileUploadHelper
var relativeKey = $"uploads/{folder}/{monthFolder}/{fileName}";
await using var stream = file.OpenReadStream();
- return await storage.SaveAsync(stream, relativeKey, file.ContentType);
+ return await storage.SaveObjectAsync(stream, relativeKey, file.ContentType);
}
private static readonly HashSet ExcelExtensions =
@@ -84,7 +77,7 @@ public static class OmsFileUploadHelper
public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync(
IFormFile file,
int customerId,
- IFileStorage storage)
+ IObjectStorage storage)
{
if (file == null || file.Length <= 0)
{
@@ -106,7 +99,7 @@ public static class OmsFileUploadHelper
var relativeKey = $"uploads/order-templates/{customerId}/{fileName}";
await using var stream = file.OpenReadStream();
- var key = await storage.SaveAsync(
+ var key = await storage.SaveObjectAsync(
stream,
relativeKey,
file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
diff --git a/src/Oms.Application/Storage/DirectUploadTickets.cs b/src/Oms.Application/Storage/DirectUploadTickets.cs
deleted file mode 100644
index 7b455c1..0000000
--- a/src/Oms.Application/Storage/DirectUploadTickets.cs
+++ /dev/null
@@ -1,50 +0,0 @@
-namespace Oms.Application.Storage;
-
-///
-/// 预签名 PUT 直传凭证(前端直接 PUT 到 COS,文件不经后端)。
-///
-public sealed class PresignedUploadTicket
-{
- /// 库内应保存的逻辑 key(不含 KeyPrefix)。
- public string Key { get; init; } = string.Empty;
-
- /// 一次性 PUT 地址(含签名)。
- public string UploadUrl { get; init; } = string.Empty;
-
- public string Method { get; init; } = "PUT";
-
- /// PUT 时必须带上的 Content-Type(若签发时绑定了则必填)。
- public string? ContentType { get; init; }
-
- /// PUT 时必须原样携带的请求头。
- public IReadOnlyDictionary Headers { get; init; } =
- new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- public DateTime ExpireAt { get; init; }
-}
-
-///
-/// STS 临时密钥直传凭证(前端用 cos-js-sdk 等上传)。
-///
-public sealed class StsUploadTicket
-{
- /// 库内应保存的逻辑 key。
- public string Key { get; init; } = string.Empty;
-
- /// COS 对象完整键(含 KeyPrefix),SDK putObject 用。
- public string CosKey { get; init; } = string.Empty;
-
- public string Bucket { get; init; } = string.Empty;
-
- public string Region { get; init; } = string.Empty;
-
- public string TmpSecretId { get; init; } = string.Empty;
-
- public string TmpSecretKey { get; init; } = string.Empty;
-
- public string SessionToken { get; init; } = string.Empty;
-
- public long StartTime { get; init; }
-
- public long ExpiredTime { get; init; }
-}
diff --git a/src/Oms.Application/Storage/FileStorageOptions.cs b/src/Oms.Application/Storage/FileStorageOptions.cs
deleted file mode 100644
index 9a4279c..0000000
--- a/src/Oms.Application/Storage/FileStorageOptions.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-namespace Oms.Application.Storage;
-
-///
-/// 文件存储配置。Provider=Local 落盘 wwwroot;Provider=Cos 走腾讯云 COS。
-///
-public class FileStorageOptions
-{
- public const string SectionName = "FileStorage";
-
- ///
- /// 存储提供方:Local | Cos。
- ///
- public string Provider { get; set; } = "Local";
-
- ///
- /// 腾讯云 COS 配置(Provider=Cos 时必填)。
- ///
- public TencentCosOptions Cos { get; set; } = new();
-}
-
-///
-/// 腾讯云 COS 连接参数。
-///
-public class TencentCosOptions
-{
- /// API 密钥 SecretId。
- public string SecretId { get; set; } = string.Empty;
-
- /// API 密钥 SecretKey。
- public string SecretKey { get; set; } = string.Empty;
-
- /// 地域,如 ap-guangzhou。
- public string Region { get; set; } = string.Empty;
-
- /// 存储桶,格式 bucketname-APPID。
- public string Bucket { get; set; } = string.Empty;
-
- ///
- /// 对外访问根地址。可填 CDN / 自定义域名,末尾不要斜杠。
- /// 空则默认 https://{Bucket}.cos.{Region}.myqcloud.com
- ///
- public string? BaseUrl { get; set; }
-
- ///
- /// 对象键前缀(如 oms/prod/),可空。
- ///
- public string KeyPrefix { get; set; } = string.Empty;
-
- ///
- /// 预签名读 URL 有效期(秒)。私有桶临时访问用。
- ///
- public long DurationSecond { get; set; } = 3600;
-
- ///
- /// 预签名上传 / STS 有效期(秒)。建议短于读有效期。
- ///
- public long UploadDurationSecond { get; set; } = 600;
-
- ///
- /// 是否对 GetPublicUrl 返回预签名链接。私有桶必须为 true。
- ///
- public bool SignUrls { get; set; } = true;
-
- /// 是否 HTTPS。
- public bool IsHttps { get; set; } = true;
-}
diff --git a/src/Oms.Application/Storage/FileStorageServiceExtensions.cs b/src/Oms.Application/Storage/FileStorageServiceExtensions.cs
deleted file mode 100644
index fb38e7f..0000000
--- a/src/Oms.Application/Storage/FileStorageServiceExtensions.cs
+++ /dev/null
@@ -1,93 +0,0 @@
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-
-namespace Oms.Application.Storage;
-
-public static class FileStorageServiceExtensions
-{
- public static IServiceCollection AddOmsFileStorage(this IServiceCollection services, IConfiguration configuration)
- {
- var section = configuration.GetSection(FileStorageOptions.SectionName);
- services.Configure(section);
-
- var options = section.Get() ?? new FileStorageOptions();
- var provider = ResolveProvider(options);
-
- if (string.Equals(provider, "Cos", StringComparison.OrdinalIgnoreCase))
- {
- services.AddSingleton();
- }
- else
- {
- services.AddSingleton();
- }
-
- services.AddHostedService();
- return services;
- }
-
- ///
- /// 显式 Provider 优先;未写 Provider 但 Cos 凭证齐全时自动走 Cos,避免生产误落 Local。
- ///
- internal static string ResolveProvider(FileStorageOptions options)
- {
- var configured = options.Provider?.Trim();
- if (!string.IsNullOrWhiteSpace(configured))
- {
- return configured;
- }
-
- return HasCompleteCos(options.Cos) ? "Cos" : "Local";
- }
-
- internal static bool HasCompleteCos(TencentCosOptions? cos) =>
- cos != null
- && !string.IsNullOrWhiteSpace(cos.SecretId)
- && !string.IsNullOrWhiteSpace(cos.SecretKey)
- && !string.IsNullOrWhiteSpace(cos.Region)
- && !string.IsNullOrWhiteSpace(cos.Bucket)
- && !cos.Bucket.Contains("your-bucket", StringComparison.OrdinalIgnoreCase);
-}
-
-///
-/// 启动时打印实际 IFileStorage 实现,便于核对 Consul 是否生效。
-///
-internal sealed class FileStorageStartupLogger(
- IFileStorage storage,
- IOptions options,
- ILogger logger) : IHostedService
-{
- public Task StartAsync(CancellationToken cancellationToken)
- {
- var cfg = options.Value;
- var cosReady = FileStorageServiceExtensions.HasCompleteCos(cfg.Cos);
- logger.LogInformation(
- "IFileStorage 实际类型={Implementation},配置 Provider={ConfiguredProvider},Cos凭证齐全={CosReady},Section={Section}",
- storage.GetType().FullName,
- string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider,
- cosReady,
- FileStorageOptions.SectionName);
-
- if (storage is LocalFileStorage && cosReady)
- {
- logger.LogWarning(
- "已检测到齐全 Cos 凭证,但仍在使用 LocalFileStorage。请确认 Consul 中 {Section}:Provider 是否为 Cos(当前={ConfiguredProvider})",
- FileStorageOptions.SectionName,
- string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider);
- }
-
- if (storage is LocalFileStorage && !cosReady)
- {
- logger.LogWarning(
- "当前为 Local 存储。生产若要用 Cos,请在 Consul KV(idwms/admin)配置 {Section}:Provider=Cos 及 Cos.SecretId/SecretKey/Region/Bucket",
- FileStorageOptions.SectionName);
- }
-
- return Task.CompletedTask;
- }
-
- public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
-}
diff --git a/src/Oms.Application/Storage/FileStorageUploadPaths.cs b/src/Oms.Application/Storage/FileStorageUploadPaths.cs
deleted file mode 100644
index 5410a72..0000000
--- a/src/Oms.Application/Storage/FileStorageUploadPaths.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-using Fake.Application;
-
-namespace Oms.Application.Storage;
-
-///
-/// 直传目录与 object key 生成。
-///
-public static class FileStorageUploadPaths
-{
- private static readonly HashSet AllowedFolders = new(StringComparer.OrdinalIgnoreCase)
- {
- "avatars",
- "sku",
- "parcel",
- "recharge",
- "order-files",
- "order-templates",
- "xhs",
- "prints",
- "exports",
- "misc"
- };
-
- public static string NormalizeFolder(string? folder)
- {
- var value = (folder ?? string.Empty).Trim().Trim('/').Replace('\\', '/');
- if (string.IsNullOrWhiteSpace(value))
- {
- throw new BusinessException("上传目录 folder 不能为空");
- }
-
- // 允许 uploads/sku 或 sku
- if (value.StartsWith("uploads/", StringComparison.OrdinalIgnoreCase))
- {
- value = value["uploads/".Length..];
- }
-
- var root = value.Split('/', StringSplitOptions.RemoveEmptyEntries)[0];
- if (!AllowedFolders.Contains(root))
- {
- throw new BusinessException($"不支持的上传目录: {root}");
- }
-
- return value;
- }
-
- public static string BuildLogicalKey(string folder, string fileName)
- {
- var normalizedFolder = NormalizeFolder(folder);
- var ext = Path.GetExtension(fileName);
- if (string.IsNullOrWhiteSpace(ext) || ext.Length > 16)
- {
- ext = ".bin";
- }
-
- var month = DateTime.Now.ToString("yyyyMM");
- return $"uploads/{normalizedFolder}/{month}/{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
- }
-}
diff --git a/src/Oms.Application/Storage/IFileStorage.cs b/src/Oms.Application/Storage/IFileStorage.cs
deleted file mode 100644
index cd6b501..0000000
--- a/src/Oms.Application/Storage/IFileStorage.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-namespace Oms.Application.Storage;
-
-///
-/// 统一文件存储(本地 / 腾讯云 COS)。
-/// 约定:库内只存 object key(如 uploads/avatars/a.jpg)。
-/// 私有桶场景:访问用临时签名 URL;用户上传用预签名 PUT 或 STS,文件不经业务服务器。
-///
-public interface IFileStorage
-{
- /// 是否支持前端直传(COS=true,Local=false)。
- bool SupportsClientDirectUpload { get; }
-
- ///
- /// 服务端落盘/上传(仅用于后端生成的文件,如面单 PDF、导出)。用户上传请走直传。
- ///
- Task SaveAsync(
- Stream content,
- string relativeKey,
- string? contentType = null,
- CancellationToken cancellationToken = default);
-
- ///
- /// 临时访问地址。Cos 私有桶返回预签名 GET;Local 返回 /uploads/...;外链原样返回。
- ///
- string GetPublicUrl(string? keyOrUrl);
-
- ///
- /// 签发前端直传用的预签名 PUT URL。
- ///
- Task CreatePresignedUploadAsync(
- string folder,
- string fileName,
- string? contentType = null,
- CancellationToken cancellationToken = default);
-
- ///
- /// 签发 STS 临时密钥(前端 COS SDK 直传)。不支持时抛业务异常。
- ///
- Task CreateStsUploadAsync(
- string folder,
- string fileName,
- CancellationToken cancellationToken = default);
-
- ///
- /// 将 key / 相对路径 / 完整 URL 规范为库内应保存的 object key。
- ///
- string? ToStorageKey(string? keyOrUrl);
-
- Task OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default);
-
- Task ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default);
-
- Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default);
-}
diff --git a/src/Oms.Application/Storage/LocalFileStorage.cs b/src/Oms.Application/Storage/LocalFileStorage.cs
deleted file mode 100644
index b9a54c0..0000000
--- a/src/Oms.Application/Storage/LocalFileStorage.cs
+++ /dev/null
@@ -1,171 +0,0 @@
-using Fake.Application;
-using Microsoft.AspNetCore.Hosting;
-
-namespace Oms.Application.Storage;
-
-///
-/// 本地 wwwroot 文件存储。库内存 key,对外为 /key。不支持前端直传 COS。
-///
-public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileStorage
-{
- public bool SupportsClientDirectUpload => false;
-
- public async Task SaveAsync(
- Stream content,
- string relativeKey,
- string? contentType = null,
- CancellationToken cancellationToken = default)
- {
- var key = FileStoragePath.NormalizeKey(relativeKey);
- var absolutePath = ToAbsolutePath(key);
- var directory = Path.GetDirectoryName(absolutePath);
- if (!string.IsNullOrWhiteSpace(directory))
- {
- try
- {
- Directory.CreateDirectory(directory);
- }
- catch (UnauthorizedAccessException ex)
- {
- throw new UnauthorizedAccessException(
- $"本地文件存储无法写入目录 '{directory}'(WebRoot={GetWebRoot()})。" +
- "容器请确保 wwwroot/uploads 可写,或将 Oms:FileStorage:Provider 设为 Cos。",
- ex);
- }
- catch (IOException ex)
- {
- throw new IOException(
- $"本地文件存储无法创建目录 '{directory}'(WebRoot={GetWebRoot()})。" +
- "容器请确保 wwwroot/uploads 可写,或将 Oms:FileStorage:Provider 设为 Cos。",
- ex);
- }
- }
-
- await using var file = new FileStream(
- absolutePath,
- FileMode.Create,
- FileAccess.Write,
- FileShare.None,
- bufferSize: 81920,
- useAsync: true);
- if (content.CanSeek)
- {
- content.Position = 0;
- }
-
- await content.CopyToAsync(file, cancellationToken);
- return key;
- }
-
- public string GetPublicUrl(string? keyOrUrl)
- {
- if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0")
- {
- return keyOrUrl ?? string.Empty;
- }
-
- if (FileStoragePath.IsAbsoluteUrl(keyOrUrl))
- {
- return keyOrUrl;
- }
-
- if (keyOrUrl.StartsWith('/'))
- {
- return keyOrUrl.Replace('\\', '/');
- }
-
- return "/" + FileStoragePath.NormalizeKey(keyOrUrl);
- }
-
- public Task CreatePresignedUploadAsync(
- string folder,
- string fileName,
- string? contentType = null,
- CancellationToken cancellationToken = default) =>
- throw new BusinessException(
- "当前为 Local 存储,不支持 COS 直传。请将 Oms:FileStorage:Provider 设为 Cos,或继续使用服务端 multipart 上传。");
-
- public Task CreateStsUploadAsync(
- string folder,
- string fileName,
- CancellationToken cancellationToken = default) =>
- throw new BusinessException("当前为 Local 存储,不支持 STS 直传。请将 Oms:FileStorage:Provider 设为 Cos。");
-
- public string? ToStorageKey(string? keyOrUrl)
- {
- if (string.IsNullOrWhiteSpace(keyOrUrl))
- {
- return null;
- }
-
- if (keyOrUrl == "0")
- {
- return "0";
- }
-
- if (FileStoragePath.IsAbsoluteUrl(keyOrUrl))
- {
- return keyOrUrl.Trim();
- }
-
- return FileStoragePath.NormalizeKey(keyOrUrl);
- }
-
- public Task OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default)
- {
- var absolutePath = ResolveAbsolutePath(pathOrUrl);
- if (!File.Exists(absolutePath))
- {
- throw new FileNotFoundException("本地文件不存在", absolutePath);
- }
-
- Stream stream = new FileStream(
- absolutePath,
- FileMode.Open,
- FileAccess.Read,
- FileShare.Read,
- bufferSize: 81920,
- useAsync: true);
- return Task.FromResult(stream);
- }
-
- public Task ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default)
- {
- var absolutePath = ResolveAbsolutePath(pathOrUrl);
- return Task.FromResult(File.Exists(absolutePath));
- }
-
- public Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default)
- {
- var absolutePath = ResolveAbsolutePath(pathOrUrl);
- if (File.Exists(absolutePath))
- {
- File.Delete(absolutePath);
- }
-
- return Task.CompletedTask;
- }
-
- private string ResolveAbsolutePath(string pathOrUrl)
- {
- if (string.IsNullOrWhiteSpace(pathOrUrl))
- {
- throw new ArgumentException("路径不能为空", nameof(pathOrUrl));
- }
-
- if (Path.IsPathRooted(pathOrUrl) && File.Exists(pathOrUrl))
- {
- return pathOrUrl;
- }
-
- return ToAbsolutePath(FileStoragePath.NormalizeKey(pathOrUrl));
- }
-
- private string ToAbsolutePath(string relativeKey) =>
- Path.Combine(GetWebRoot(), relativeKey.Replace('/', Path.DirectorySeparatorChar));
-
- private string GetWebRoot() =>
- string.IsNullOrWhiteSpace(environment.WebRootPath)
- ? Path.Combine(environment.ContentRootPath, "wwwroot")
- : environment.WebRootPath;
-}
diff --git a/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs b/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs
new file mode 100644
index 0000000..ab34314
--- /dev/null
+++ b/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs
@@ -0,0 +1,86 @@
+using Fake.ObjectStorage;
+
+namespace Oms.Application.Storage;
+
+///
+/// OMS 对 的约定封装:库内存逻辑 object key(或外链原样),展示用临时/公开 URL。
+///
+public static class OmsObjectStorageExtensions
+{
+ ///
+ /// 将 key / 相对路径规范为库内 object key;绝对外链原样返回;空返回 null。
+ ///
+ public static string? ToStorageKey(string? keyOrUrl)
+ {
+ if (string.IsNullOrWhiteSpace(keyOrUrl))
+ {
+ return null;
+ }
+
+ var raw = keyOrUrl.Trim();
+ if (raw == "0")
+ {
+ return "0";
+ }
+
+ if (FileStoragePath.IsAbsoluteUrl(raw))
+ {
+ return raw;
+ }
+
+ return FileStoragePath.NormalizeKey(raw);
+ }
+
+ ///
+ /// 临时访问地址。空值返回空串;外链原样返回;逻辑 key 走 。
+ ///
+ public static async Task GetPublicUrlAsync(
+ this IObjectStorage storage,
+ string? keyOrUrl,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0")
+ {
+ return string.Empty;
+ }
+
+ var raw = keyOrUrl.Trim();
+ if (FileStoragePath.IsAbsoluteUrl(raw))
+ {
+ return raw;
+ }
+
+ var key = FileStoragePath.NormalizeKey(raw);
+ return await storage.GetUrlAsync(key, cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// 同步取访问 URL(映射 DTO 等场景)。
+ ///
+ public static string GetPublicUrl(this IObjectStorage storage, string? keyOrUrl) =>
+ storage.GetPublicUrlAsync(keyOrUrl).ConfigureAwait(false).GetAwaiter().GetResult();
+
+ ///
+ /// 服务端写入对象,返回逻辑 object key。
+ ///
+ public static async Task SaveObjectAsync(
+ this IObjectStorage storage,
+ Stream content,
+ string relativeKey,
+ string? contentType = null,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await storage.SaveAsync(
+ new ObjectStorageSaveArgs
+ {
+ ObjectKey = FileStoragePath.NormalizeKey(relativeKey),
+ Content = content,
+ ContentType = contentType
+ },
+ cancellationToken);
+ return result.ObjectKey;
+ }
+
+ public static bool IsLocalProvider(this IObjectStorage storage) =>
+ string.Equals(storage.Name, "Local", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/Oms.Application/Storage/TencentCosFileStorage.cs b/src/Oms.Application/Storage/TencentCosFileStorage.cs
deleted file mode 100644
index 9f152d7..0000000
--- a/src/Oms.Application/Storage/TencentCosFileStorage.cs
+++ /dev/null
@@ -1,487 +0,0 @@
-using COSSTS;
-using COSXML;
-using COSXML.Auth;
-using COSXML.Model.Object;
-using COSXML.Model.Tag;
-using Fake.Application;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-
-namespace Oms.Application.Storage;
-
-///
-/// 腾讯云 COS:库内存逻辑 key;读返回预签名 GET;写支持预签名 PUT / STS 前端直传。
-///
-public sealed class TencentCosFileStorage : IFileStorage, IDisposable
-{
- private static readonly string[] UploadActions =
- [
- "name/cos:PutObject",
- "name/cos:PostObject",
- "name/cos:InitiateMultipartUpload",
- "name/cos:ListMultipartUploads",
- "name/cos:ListParts",
- "name/cos:UploadPart",
- "name/cos:CompleteMultipartUpload",
- "name/cos:AbortMultipartUpload"
- ];
-
- private readonly TencentCosOptions _options;
- private readonly CosXmlServer _cosXml;
- private readonly ILogger _logger;
- private readonly string _publicBaseUrl;
- private readonly string _keyPrefix;
- private readonly string _appId;
- private readonly string? _customHost;
-
- public TencentCosFileStorage(
- IOptions options,
- ILogger logger)
- {
- _logger = logger;
- _options = options.Value.Cos ?? new TencentCosOptions();
- ValidateOptions(_options);
-
- _appId = ExtractAppId(_options.Bucket);
- var config = new CosXmlConfig.Builder()
- .IsHttps(_options.IsHttps)
- .SetRegion(_options.Region)
- .SetAppid(_appId)
- .Build();
-
- var signSeconds = Math.Max(60, Math.Max(_options.DurationSecond, _options.UploadDurationSecond));
- var credential = new DefaultQCloudCredentialProvider(
- _options.SecretId,
- _options.SecretKey,
- signSeconds);
-
- _cosXml = new CosXmlServer(config, credential);
- _publicBaseUrl = ResolvePublicBaseUrl(_options);
- _keyPrefix = (_options.KeyPrefix ?? string.Empty).Replace('\\', '/').Trim('/');
- _customHost = Uri.TryCreate(_publicBaseUrl, UriKind.Absolute, out var baseUri)
- ? baseUri.Host
- : null;
- }
-
- public bool SupportsClientDirectUpload => true;
-
- public Task SaveAsync(
- Stream content,
- string relativeKey,
- string? contentType = null,
- CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var logicalKey = FileStoragePath.NormalizeKey(relativeKey);
- var objectKey = ToObjectKey(logicalKey);
- if (content.CanSeek)
- {
- content.Position = 0;
- }
-
- Stream uploadStream = content;
- MemoryStream? owned = null;
- if (!content.CanSeek)
- {
- owned = new MemoryStream();
- content.CopyTo(owned);
- owned.Position = 0;
- uploadStream = owned;
- }
-
- try
- {
- var request = new PutObjectRequest(_options.Bucket, objectKey, uploadStream);
- if (!string.IsNullOrWhiteSpace(contentType))
- {
- request.SetRequestHeader("Content-Type", contentType);
- }
-
- var result = _cosXml.PutObject(request);
- if (result.httpCode is < 200 or >= 300)
- {
- throw new BusinessException($"上传 COS 失败: HTTP {result.httpCode}");
- }
-
- _logger.LogDebug("COS 服务端上传成功: {ObjectKey}", objectKey);
- return Task.FromResult(logicalKey);
- }
- finally
- {
- owned?.Dispose();
- }
- }
-
- public string GetPublicUrl(string? keyOrUrl)
- {
- if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0")
- {
- return keyOrUrl ?? string.Empty;
- }
-
- if (FileStoragePath.IsAbsoluteUrl(keyOrUrl))
- {
- if (!IsManagedPublicUrl(keyOrUrl))
- {
- return keyOrUrl;
- }
-
- // 已是签名 URL:原样返回,避免重复签名
- if (keyOrUrl.Contains("q-signature=", StringComparison.OrdinalIgnoreCase)
- || keyOrUrl.Contains("sign=", StringComparison.OrdinalIgnoreCase))
- {
- return keyOrUrl;
- }
-
- var logical = StripKeyPrefix(FileStoragePath.NormalizeKey(keyOrUrl));
- return BuildSignedGetUrl(logical);
- }
-
- return BuildSignedGetUrl(keyOrUrl);
- }
-
- public Task CreatePresignedUploadAsync(
- string folder,
- string fileName,
- string? contentType = null,
- CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var logicalKey = FileStorageUploadPaths.BuildLogicalKey(folder, fileName);
- var objectKey = ToObjectKey(logicalKey);
- var duration = Math.Max(60, _options.UploadDurationSecond);
- var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
- if (!string.IsNullOrWhiteSpace(contentType))
- {
- headers["Content-Type"] = contentType.Trim();
- }
-
- var preSign = new PreSignatureStruct
- {
- appid = _appId,
- bucket = _options.Bucket,
- region = _options.Region,
- key = objectKey,
- httpMethod = "PUT",
- isHttps = _options.IsHttps,
- signDurationSecond = duration,
- headers = headers.Count > 0 ? headers : null,
- queryParameters = null
- };
- ApplyCustomHost(preSign);
-
- var uploadUrl = _cosXml.GenerateSignURL(preSign);
- return Task.FromResult(new PresignedUploadTicket
- {
- Key = logicalKey,
- UploadUrl = uploadUrl,
- Method = "PUT",
- ContentType = string.IsNullOrWhiteSpace(contentType) ? null : contentType.Trim(),
- Headers = headers,
- ExpireAt = DateTime.UtcNow.AddSeconds(duration)
- });
- }
-
- public Task CreateStsUploadAsync(
- string folder,
- string fileName,
- CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var logicalKey = FileStorageUploadPaths.BuildLogicalKey(folder, fileName);
- var objectKey = ToObjectKey(logicalKey);
- var duration = (int)Math.Max(60, _options.UploadDurationSecond);
-
- var values = new Dictionary
- {
- ["bucket"] = _options.Bucket,
- ["region"] = _options.Region,
- ["allowPrefix"] = objectKey,
- ["allowActions"] = UploadActions,
- ["durationSeconds"] = duration,
- ["secretId"] = _options.SecretId,
- ["secretKey"] = _options.SecretKey
- };
-
- Dictionary credential;
- try
- {
- credential = STSClient.genCredential(values);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "申请 COS STS 临时密钥失败");
- throw new BusinessException("申请 COS 临时上传凭证失败,请检查 SecretId/SecretKey 与 CAM 权限");
- }
-
- var credentials = ExtractCredentials(credential["Credentials"]);
- var startTime = ToUnixSeconds(credential.GetValueOrDefault("StartTime"));
- var expiredTime = ToUnixSeconds(credential.GetValueOrDefault("ExpiredTime"));
- if (startTime <= 0)
- {
- startTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
- }
-
- if (expiredTime <= 0)
- {
- expiredTime = startTime + duration;
- }
-
- return Task.FromResult(new StsUploadTicket
- {
- Key = logicalKey,
- CosKey = objectKey,
- Bucket = _options.Bucket,
- Region = _options.Region,
- TmpSecretId = credentials.tmpSecretId,
- TmpSecretKey = credentials.tmpSecretKey,
- SessionToken = credentials.token,
- StartTime = startTime,
- ExpiredTime = expiredTime
- });
- }
-
- public string? ToStorageKey(string? keyOrUrl)
- {
- if (string.IsNullOrWhiteSpace(keyOrUrl))
- {
- return null;
- }
-
- if (keyOrUrl == "0")
- {
- return "0";
- }
-
- if (FileStoragePath.IsAbsoluteUrl(keyOrUrl))
- {
- if (!IsManagedPublicUrl(keyOrUrl))
- {
- return keyOrUrl.Trim();
- }
-
- var pathKey = FileStoragePath.NormalizeKey(keyOrUrl);
- return StripKeyPrefix(pathKey);
- }
-
- return FileStoragePath.NormalizeKey(keyOrUrl);
- }
-
- public Task OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- var objectKey = ToObjectKey(pathOrUrl);
- var request = new GetObjectBytesRequest(_options.Bucket, objectKey);
- var result = _cosXml.GetObject(request);
- if (result.httpCode is < 200 or >= 300)
- {
- throw new FileNotFoundException($"COS 对象不存在或读取失败: {objectKey}");
- }
-
- Stream stream = new MemoryStream(result.content);
- stream.Position = 0;
- return Task.FromResult(stream);
- }
-
- public Task ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- try
- {
- var objectKey = ToObjectKey(pathOrUrl);
- var request = new HeadObjectRequest(_options.Bucket, objectKey);
- var result = _cosXml.HeadObject(request);
- return Task.FromResult(result.httpCode is >= 200 and < 300);
- }
- catch
- {
- return Task.FromResult(false);
- }
- }
-
- public Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- try
- {
- var objectKey = ToObjectKey(pathOrUrl);
- var request = new DeleteObjectRequest(_options.Bucket, objectKey);
- _cosXml.DeleteObject(request);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "删除 COS 对象失败: {Path}", pathOrUrl);
- }
-
- return Task.CompletedTask;
- }
-
- public void Dispose()
- {
- }
-
- private string BuildSignedGetUrl(string logicalOrRelativeKey)
- {
- var objectKey = ToObjectKey(logicalOrRelativeKey);
- if (!_options.SignUrls)
- {
- _logger.LogWarning(
- "Cos.SignUrls=false,私有桶下浏览器将无法直接打开对象。建议保持 SignUrls=true。ObjectKey={ObjectKey}",
- objectKey);
- return $"{_publicBaseUrl}/{objectKey}";
- }
-
- try
- {
- var preSign = new PreSignatureStruct
- {
- appid = _appId,
- bucket = _options.Bucket,
- region = _options.Region,
- key = objectKey,
- httpMethod = "GET",
- isHttps = _options.IsHttps,
- signDurationSecond = Math.Max(60, _options.DurationSecond),
- headers = null,
- queryParameters = null
- };
- ApplyCustomHost(preSign);
- return _cosXml.GenerateSignURL(preSign);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "生成 COS 预签名 GET 失败: {ObjectKey}", objectKey);
- throw new BusinessException("生成临时访问地址失败");
- }
- }
-
- private void ApplyCustomHost(PreSignatureStruct preSign)
- {
- if (!string.IsNullOrWhiteSpace(_customHost)
- && !_customHost.EndsWith(".myqcloud.com", StringComparison.OrdinalIgnoreCase))
- {
- preSign.host = _customHost;
- preSign.signHost = true;
- }
- }
-
- private string ToObjectKey(string pathOrUrl)
- {
- var key = FileStoragePath.NormalizeKey(pathOrUrl);
- if (!string.IsNullOrEmpty(_keyPrefix)
- && !key.StartsWith(_keyPrefix + "/", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(key, _keyPrefix, StringComparison.OrdinalIgnoreCase))
- {
- key = $"{_keyPrefix}/{key}";
- }
-
- return key;
- }
-
- private string StripKeyPrefix(string objectOrLogicalKey)
- {
- if (string.IsNullOrEmpty(_keyPrefix))
- {
- return objectOrLogicalKey;
- }
-
- if (objectOrLogicalKey.StartsWith(_keyPrefix + "/", StringComparison.OrdinalIgnoreCase))
- {
- return objectOrLogicalKey[(_keyPrefix.Length + 1)..];
- }
-
- if (string.Equals(objectOrLogicalKey, _keyPrefix, StringComparison.OrdinalIgnoreCase))
- {
- return string.Empty;
- }
-
- return objectOrLogicalKey;
- }
-
- private bool IsManagedPublicUrl(string url)
- {
- if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
- {
- return false;
- }
-
- if (Uri.TryCreate(_publicBaseUrl, UriKind.Absolute, out var baseUri)
- && string.Equals(uri.Host, baseUri.Host, StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
-
- var defaultHost = $"{_options.Bucket}.cos.{_options.Region}.myqcloud.com";
- return string.Equals(uri.Host, defaultHost, StringComparison.OrdinalIgnoreCase);
- }
-
- private static (string tmpSecretId, string tmpSecretKey, string token) ExtractCredentials(object? raw)
- {
- if (raw == null)
- {
- throw new BusinessException("STS 返回 Credentials 为空");
- }
-
- var json = JsonConvert.SerializeObject(raw);
- var obj = JObject.Parse(json);
- var id = obj.Value("TmpSecretId") ?? obj.Value("tmpSecretId");
- var key = obj.Value("TmpSecretKey") ?? obj.Value("tmpSecretKey");
- var token = obj.Value("Token") ?? obj.Value("sessionToken") ?? obj.Value("token");
- if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(token))
- {
- throw new BusinessException("STS 凭证字段不完整");
- }
-
- return (id, key, token);
- }
-
- private static long ToUnixSeconds(object? value) =>
- value switch
- {
- null => 0,
- long l => l,
- int i => i,
- string s when long.TryParse(s, out var n) => n,
- JValue j when j.Type == JTokenType.Integer => j.Value(),
- _ => 0
- };
-
- private static string ResolvePublicBaseUrl(TencentCosOptions options)
- {
- if (!string.IsNullOrWhiteSpace(options.BaseUrl))
- {
- return options.BaseUrl.Trim().TrimEnd('/');
- }
-
- var scheme = options.IsHttps ? "https" : "http";
- return $"{scheme}://{options.Bucket}.cos.{options.Region}.myqcloud.com";
- }
-
- private static string ExtractAppId(string bucket)
- {
- var idx = bucket.LastIndexOf('-');
- if (idx <= 0 || idx >= bucket.Length - 1)
- {
- throw new BusinessException("COS Bucket 格式应为 bucketname-APPID");
- }
-
- return bucket[(idx + 1)..];
- }
-
- private static void ValidateOptions(TencentCosOptions options)
- {
- if (string.IsNullOrWhiteSpace(options.SecretId)
- || string.IsNullOrWhiteSpace(options.SecretKey)
- || string.IsNullOrWhiteSpace(options.Region)
- || string.IsNullOrWhiteSpace(options.Bucket))
- {
- throw new BusinessException(
- "Oms:FileStorage:Cos 配置不完整,请填写 SecretId / SecretKey / Region / Bucket");
- }
- }
-}
diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs
index 6cc1886..632f352 100644
--- a/src/SimpleAdmin.Api/Auth/AuthService.cs
+++ b/src/SimpleAdmin.Api/Auth/AuthService.cs
@@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
+using Fake.ObjectStorage;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.CustomersAggregate.Entities;
@@ -43,7 +44,7 @@ public partial class AuthService(
IWeChatLoginSessionStore weChatLoginSessionStore,
IMemoryCache memoryCache,
IRbacAuthCache rbacAuthCache,
- IFileStorage fileStorage)
+ IObjectStorage fileStorage)
: ApplicationService
{
private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3);
@@ -418,7 +419,7 @@ public partial class AuthService(
}
output.Position = 0;
- var avatarKey = await fileStorage.SaveAsync(output, relativeKey, "image/jpeg", cancellationToken);
+ var avatarKey = await fileStorage.SaveObjectAsync(output, relativeKey, "image/jpeg", cancellationToken);
var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken);
user.UpdateAvatar(avatarKey);
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
diff --git a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj
index 2648d19..0143f14 100644
--- a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj
+++ b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj
@@ -15,6 +15,9 @@
+
+
+
diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
index bf93dc0..0d0e2a1 100644
--- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
+++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
@@ -8,15 +8,20 @@ using Fake.AspNetCore.ExceptionHandling;
using Fake.AspNetCore.Newtonsoft;
using Fake.AspNetCore.Swagger;
using Fake.FeiShu;
+using Fake.FileManagement.Application;
+using Fake.FileManagement.Infrastructure;
+using Fake.ObjectStorage;
using Fake.Security.Claims;
using Fake.Serilog;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc.Authorization;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Oms.Application;
using Oms.SqlSugarCore;
using SimpleAdmin.Api.Auth;
+using SimpleAdmin.Api.Storage;
using SimpleAdmin.Api.Swagger;
using SimpleAdmin.Api.WeChat;
using Swashbuckle.AspNetCore.SwaggerGen;
@@ -29,6 +34,9 @@ namespace SimpleAdmin.Api;
typeof(OmsSqlSugarCoreModule),
typeof(FakeRbacApplicationModule),
typeof(FakeRbacInfrastructureModule),
+ typeof(FakeFileManagementApplicationModule),
+ typeof(FakeFileManagementInfrastructureModule),
+ typeof(FakeObjectStorageTencentCosModule),
typeof(FakeAspNetCoreModule),
typeof(FakeSerilogModule)
)]
@@ -46,7 +54,7 @@ public class SimpleAdminApiModule : FakeModule
var services = context.Services;
var configuration = context.Services.GetConfiguration();
- // 配置动态 API - 扫描 OMS 与 RBAC 应用服务
+ // 配置动态 API - 扫描 OMS / RBAC / FileManagement 应用服务
services.Configure(options =>
{
options.ApplicationServices2Controller();
@@ -63,6 +71,10 @@ public class SimpleAdminApiModule : FakeModule
type != typeof(Fake.Rbac.Application.Services.AuthService) &&
type != typeof(Fake.Rbac.Application.Services.UserService);
});
+ options.ApplicationServices2Controller(settings =>
+ {
+ settings.RootPath = "files";
+ });
});
services.AddOmsWeChat(configuration);
@@ -128,6 +140,8 @@ public class SimpleAdminApiModule : FakeModule
});
// 添加授权服务
services.AddAuthorization();
+
+ services.AddHostedService();
}
public override void ConfigureApplication(ApplicationConfigureContext context)
@@ -162,6 +176,13 @@ public class SimpleAdminApiModule : FakeModule
// 端点映射
app.MapControllers();
+
+ // FileManagement 与 RBAC 同库并行(EF);应用 Pending migrations 建 stored_file
+ using (var scope = app.Services.CreateScope())
+ {
+ var fileDb = scope.ServiceProvider.GetRequiredService();
+ fileDb.Database.Migrate();
+ }
app.Services.GetRequiredService().Send("Application started");
}
diff --git a/src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs b/src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs
new file mode 100644
index 0000000..90a61bc
--- /dev/null
+++ b/src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs
@@ -0,0 +1,36 @@
+using Fake.ObjectStorage;
+using Microsoft.Extensions.Options;
+
+namespace SimpleAdmin.Api.Storage;
+
+///
+/// 启动时打印实际 IObjectStorage 实现,便于核对 Consul ObjectStorage 配置是否生效。
+///
+internal sealed class ObjectStorageStartupLogger(
+ IObjectStorage storage,
+ IOptions options,
+ ILogger logger) : IHostedService
+{
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ var cfg = options.Value;
+ logger.LogInformation(
+ "IObjectStorage 实际类型={Implementation},Name={Name},配置 Provider={ConfiguredProvider},SignUrlsByDefault={SignUrlsByDefault},KeyPrefix={KeyPrefix}",
+ storage.GetType().FullName,
+ storage.Name,
+ string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider,
+ cfg.SignUrlsByDefault,
+ string.IsNullOrWhiteSpace(cfg.KeyPrefix) ? "(empty)" : cfg.KeyPrefix);
+
+ if (string.Equals(storage.Name, "Local", StringComparison.OrdinalIgnoreCase)
+ && string.Equals(cfg.Provider, "TencentCos", StringComparison.OrdinalIgnoreCase))
+ {
+ logger.LogWarning(
+ "配置 Provider=TencentCos,但仍在使用 Local。请确认已引用 Fake.ObjectStorage.TencentCos 且 Consul 中 ObjectStorage:Provider 已生效");
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
diff --git a/src/SimpleAdmin.Api/appsettings.Development.json b/src/SimpleAdmin.Api/appsettings.Development.json
index 987adaf..1b28af5 100644
--- a/src/SimpleAdmin.Api/appsettings.Development.json
+++ b/src/SimpleAdmin.Api/appsettings.Development.json
@@ -59,22 +59,34 @@
"FollowUrl": ""
}
},
- "Oms": {
- "FileStorage": {
- "Provider": "Local",
- "Cos": {
- "SecretId": "",
- "SecretKey": "",
- "Region": "ap-guangzhou",
- "Bucket": "your-bucket-1250000000",
- "BaseUrl": "",
- "KeyPrefix": "",
- "DurationSecond": 3600,
- "UploadDurationSecond": 600,
- "SignUrls": true,
- "IsHttps": true
- }
+ "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",
--
2.45.2
From c9084ee5cab625aed21f1c7242e00946f34c9f2f Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Wed, 23 Sep 2026 11:37:27 +0800
Subject: [PATCH 02/41] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=20master=20?=
=?UTF-8?q?=E5=90=8E=E6=94=B9=E7=94=A8=20IObjectStorage=EF=BC=8C=E5=AF=B9?=
=?UTF-8?q?=E9=BD=90=20fake=20oss?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
.../Services/Orders/Batch/OrderBatchActionExecutor.cs | 3 ++-
.../Services/Orders/Batch/OrderBatchJobCoordinator.cs | 3 ++-
.../Services/Orders/OrderService.OrderEdit.cs | 3 ++-
.../Services/Orders/OrderService.ParcelBilling.cs | 3 ++-
.../Shared/OrderDownstreamCreateOneBodyBuilder.cs | 5 +++--
src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs | 4 ++--
6 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
index a88f537..913cd7a 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
@@ -1,5 +1,6 @@
using Fake.Application;
using Fake.DependencyInjection;
+using Fake.ObjectStorage;
using Newtonsoft.Json;
using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.Itfx;
@@ -19,7 +20,7 @@ public sealed class OrderBatchActionExecutor(
IOrderRepository orderRepository,
ICountryRepository countryRepository,
IServiceChannelRepository serviceChannelRepository,
- IFileStorage fileStorage,
+ IObjectStorage fileStorage,
ISmallBagTrackOrderClient smallBagTrackOrderClient,
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
OrderDownstreamCredentialResolver downstreamCredentialResolver,
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
index fed05fe..b73fa91 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
@@ -7,6 +7,7 @@ using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Messaging;
+using Fake.ObjectStorage;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Repositories;
@@ -26,7 +27,7 @@ public sealed class OrderBatchJobCoordinator(
IOrderRepository orderRepository,
ICountryRepository countryRepository,
IServiceChannelRepository serviceChannelRepository,
- IFileStorage fileStorage,
+ IObjectStorage fileStorage,
IOrderBatchJobPublisher publisher,
OrderBatchJobProcessor batchJobProcessor,
IOptions batchOptions,
diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
index 57aec08..616c9f0 100644
--- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
@@ -5,6 +5,7 @@ using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
+using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
@@ -1129,7 +1130,7 @@ public partial class OrderService
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode ?? x.ImHsCode,
DeclareElement = x.DeclareElement,
- SkuImage = FileStorage.ToStorageKey(x.SkuImage),
+ SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage),
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 0ef8833..1217f92 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -10,6 +10,7 @@ using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Labeling;
using Oms.Application.Messaging;
using Oms.Application.Shared;
+using Oms.Application.Storage;
using Oms.Application.Services.Orders.Batch;
using Oms.Application.Services.Orders.Issues;
using Oms.Domain.ChannelsAggregate.Consts;
@@ -2013,7 +2014,7 @@ public partial class OrderService
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode,
DeclareElement = x.DeclareElement,
- SkuImage = FileStorage.ToStorageKey(x.SkuImage),
+ SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage),
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,
diff --git a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs
index 22bdf36..9908371 100644
--- a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs
+++ b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs
@@ -1,3 +1,4 @@
+using Fake.ObjectStorage;
using Oms.Application.Dtos.Orders;
using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Repositories;
@@ -20,7 +21,7 @@ public static class OrderDownstreamCreateOneBodyBuilder
IReadOnlyList weights,
ICountryRepository countryRepository,
IServiceChannelRepository serviceChannelRepository,
- IFileStorage? fileStorage = null,
+ IObjectStorage? fileStorage = null,
CancellationToken cancellationToken = default)
{
var countryIds = new List();
@@ -185,7 +186,7 @@ public static class OrderDownstreamCreateOneBodyBuilder
};
}
- private static OrderParcelItemInput MapItem(OrderDeclaration x, IFileStorage? fileStorage) =>
+ private static OrderParcelItemInput MapItem(OrderDeclaration x, IObjectStorage? fileStorage) =>
new()
{
CnName = x.CnName,
diff --git a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
index 444ade9..37ecb2f 100644
--- a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
+++ b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
@@ -1,12 +1,12 @@
using FluentAssertions;
using Fake.Application;
+using Fake.ObjectStorage;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Oms.Application.Dtos.Orders;
using Oms.Application.Messaging;
using Oms.Application.Services.Orders.Batch;
-using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
@@ -21,7 +21,7 @@ public class OrderBatchJobCoordinatorGovernanceTests
private readonly Mock _orderRepository = new(MockBehavior.Loose);
private readonly Mock _countryRepository = new(MockBehavior.Loose);
private readonly Mock _serviceChannelRepository = new(MockBehavior.Loose);
- private readonly Mock _fileStorage = new(MockBehavior.Loose);
+ private readonly Mock _fileStorage = new(MockBehavior.Loose);
private readonly Mock _publisher = new(MockBehavior.Strict);
private readonly OrderBatchJobCoordinator _coordinator;
--
2.45.2
From 26c840cdb195297c1c3e01e1fe99157660661094 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Thu, 24 Sep 2026 09:51:02 +0800
Subject: [PATCH 03/41] =?UTF-8?q?fix:=20=E5=8A=A0=E5=9B=BA=E6=9C=AC?=
=?UTF-8?q?=E5=9C=B0=E5=AD=98=E5=82=A8=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=9B=9E?=
=?UTF-8?q?=E6=BB=9A=E5=88=9B=E5=8D=95=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84?=
=?UTF-8?q?=E6=8F=BD=E6=94=B6=E5=A4=A7=E5=8C=85=E8=BD=A8=E8=BF=B9=E5=90=8C?=
=?UTF-8?q?=E6=AD=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
避免 /app/wwwroot 不可写时预报成功被回滚;同步推送 test 部署出口资质等已合入能力。
Co-authored-by: Cursor
---
.../ISmallBagTrackOrderClient.cs | 30 +-
.../SmallBagTrack/PickupBigBagTrackSyncer.cs | 205 +++++++++++
.../SmallBagTrackLifecycleEvents.cs | 8 +-
.../SmallBagTrackLinkOrderApiRequest.cs | 24 ++
.../SmallBagTrack/SmallBagTrackOrderClient.cs | 324 +++++++++++++++---
.../SmallBagTrackServiceExtensions.cs | 1 +
.../SmallBagTrackUpsertBigBagApiRequest.cs | 22 ++
.../SmallBagTrackUpsertBigBagRequest.cs | 21 ++
.../SmallBagTrackUpsertTrackApiRequest.cs | 5 +-
.../Services/Orders/OrderService.Helpers.cs | 13 +-
.../Orders/OrderService.ParcelBilling.cs | 41 ++-
.../Services/Orders/PickupOrderService.cs | 9 +
.../Storage/OmsObjectStorageExtensions.cs | 18 +-
13 files changed, 661 insertions(+), 60 deletions(-)
create mode 100644 src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
create mode 100644 src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLinkOrderApiRequest.cs
create mode 100644 src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs
create mode 100644 src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs
index 9714178..c3e8190 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs
@@ -5,7 +5,7 @@ public interface ISmallBagTrackOrderClient
Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
///
- /// 追加一条生命周期轨迹节点(POST ProviderService/Track/Upsert)。
+ /// 追加一条生命周期轨迹节点(POST ProviderService/Track/Upsert),默认小包 orderType=1。
///
Task AppendLifecycleTrackAsync(
string orderNo,
@@ -13,8 +13,36 @@ public interface ISmallBagTrackOrderClient
string? authorization = null,
CancellationToken cancellationToken = default);
+ ///
+ /// 追加生命周期轨迹节点,返回调用结果(用于按序编排)。
+ ///
+ Task AppendLifecycleTrackResultAsync(
+ string orderNo,
+ string trackCode,
+ int orderType,
+ string? authorization = null,
+ CancellationToken cancellationToken = default);
+
///
/// 同步调用并返回完整请求/响应(仅调试)。
///
Task UpsertDebugAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// 创建/更新大包轨迹订单(POST ProviderService/Order/UpsertBigBag)。
+ ///
+ Task UpsertBigBagAsync(
+ SmallBagTrackUpsertBigBagRequest request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 关联大包与小包(POST ProviderService/Order/Link)。
+ ///
+ Task LinkOrdersAsync(
+ string parentOrderNo,
+ int parentOrderType,
+ IReadOnlyList linkOrderNos,
+ int linkOrderType,
+ string? authorization = null,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
new file mode 100644
index 0000000..e3bca81
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
@@ -0,0 +1,205 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Oms.Domain.ChannelsAggregate.Repositories;
+using Oms.Domain.CustomersAggregate.Repositories;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+///
+/// 揽收单预报下游成功后:按序 UpsertBigBag → Link → Track/Upsert(A0201)。
+///
+public interface IPickupBigBagTrackSyncer
+{
+ Task SyncAfterDownstreamForwardAsync(PickupOrder pickup, CancellationToken cancellationToken = default);
+}
+
+internal sealed class PickupBigBagTrackSyncer(
+ ISmallBagTrackOrderClient trackClient,
+ ICustomerRepository customerRepository,
+ IPickupOrderLineRepository pickupOrderLineRepository,
+ ISalesProductRepository salesProductRepository,
+ IServiceChannelRepository serviceChannelRepository,
+ IHttpContextAccessor httpContextAccessor,
+ ILogger logger) : IPickupBigBagTrackSyncer
+{
+ public async Task SyncAfterDownstreamForwardAsync(
+ PickupOrder pickup,
+ CancellationToken cancellationToken = default)
+ {
+ if (pickup == null || string.IsNullOrWhiteSpace(pickup.PickupNo))
+ {
+ logger.LogWarning("Skip big-bag track sync: pickup or PickupNo empty");
+ return;
+ }
+
+ var bagNo = pickup.PickupNo.Trim();
+ var authorization = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
+ if (string.IsNullOrWhiteSpace(authorization))
+ {
+ authorization = null;
+ }
+
+ try
+ {
+ var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == pickup.CustomerId, cancellationToken);
+ var clientCode = customer?.CustomerCode?.Trim() ?? string.Empty;
+ var (channel, product) = await ResolveChannelProductAsync(pickup.CarrierService, cancellationToken);
+ var country = ResolveCountry(pickup);
+
+ // 1) UpsertBigBag
+ var upsertResult = await trackClient.UpsertBigBagAsync(
+ new SmallBagTrackUpsertBigBagRequest
+ {
+ Client = clientCode,
+ BagNo = bagNo,
+ OtherNo = pickup.CarrierWaybillNo,
+ Channel = channel,
+ Product = product,
+ Country = country,
+ Authorization = authorization
+ },
+ cancellationToken);
+
+ if (upsertResult.Skipped)
+ {
+ logger.LogDebug(
+ "UpsertBigBag skipped for pickup {PickupNo}: {Reason}",
+ bagNo,
+ upsertResult.SkipReason);
+ return;
+ }
+
+ if (!upsertResult.Success)
+ {
+ logger.LogWarning(
+ "Stop big-bag track sync after UpsertBigBag failure for pickup {PickupNo}",
+ bagNo);
+ return;
+ }
+
+ // 2) Link(无明细单号则跳过,继续写轨迹)
+ var lines = await pickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id, cancellationToken);
+ var serviceNos = lines
+ .Select(x => x.ServiceNo)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x!.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (serviceNos.Count > 0)
+ {
+ var linkResult = await trackClient.LinkOrdersAsync(
+ bagNo,
+ SmallBagTrackLifecycleEvents.BigBagOrderType,
+ serviceNos,
+ SmallBagTrackLifecycleEvents.OrderType,
+ authorization,
+ cancellationToken);
+
+ if (linkResult.Skipped)
+ {
+ logger.LogDebug(
+ "Link skipped for pickup {PickupNo}: {Reason}",
+ bagNo,
+ linkResult.SkipReason);
+ }
+ else if (!linkResult.Success)
+ {
+ logger.LogWarning(
+ "Stop big-bag track sync after Link failure for pickup {PickupNo}",
+ bagNo);
+ return;
+ }
+ }
+ else
+ {
+ logger.LogDebug("Skip Link for pickup {PickupNo}: no bound ServiceNo", bagNo);
+ }
+
+ // 3) Track Upsert A0201
+ var trackResult = await trackClient.AppendLifecycleTrackResultAsync(
+ bagNo,
+ SmallBagTrackLifecycleEvents.PickupCreateTrackCode,
+ SmallBagTrackLifecycleEvents.BigBagOrderType,
+ authorization,
+ cancellationToken);
+
+ if (trackResult.Skipped)
+ {
+ logger.LogDebug(
+ "Track A0201 skipped for pickup {PickupNo}: {Reason}",
+ bagNo,
+ trackResult.SkipReason);
+ return;
+ }
+
+ if (!trackResult.Success)
+ {
+ logger.LogWarning(
+ "Track A0201 failed for pickup {PickupNo}: {StatusCode} {Body}",
+ bagNo,
+ trackResult.StatusCode,
+ trackResult.ResponseBody);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Big-bag track sync failed for pickup {PickupNo}", bagNo);
+ }
+ }
+
+ private async Task<(string? Channel, string? Product)> ResolveChannelProductAsync(
+ string? carrierService,
+ CancellationToken cancellationToken)
+ {
+ var salesProductCode = carrierService?.Trim();
+ if (string.IsNullOrWhiteSpace(salesProductCode))
+ {
+ return (null, null);
+ }
+
+ var product = await salesProductRepository.GetByProductCodeAsync(salesProductCode, cancellationToken);
+ if (product == null)
+ {
+ return (null, salesProductCode);
+ }
+
+ var providers = await salesProductRepository.GetAllocationProvidersAsync(product.Id, cancellationToken);
+ var channelId = providers
+ .Where(x => x.ServiceChannelId is > 0)
+ .OrderBy(x => x.SerialNumber)
+ .Select(x => x.ServiceChannelId!.Value)
+ .FirstOrDefault();
+ if (channelId <= 0)
+ {
+ return (null, product.ProductCode);
+ }
+
+ var channel = await serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId, cancellationToken);
+ var channelCode = channel?.ChannelCode?.Trim();
+ return (
+ string.IsNullOrWhiteSpace(channelCode) ? null : channelCode,
+ product.ProductCode);
+ }
+
+ private static string ResolveCountry(PickupOrder pickup)
+ {
+ var isReverse = string.Equals(
+ PickupDirectionCodes.NormalizeOrDefault(pickup.Direction),
+ PickupDirectionCodes.Reverse,
+ StringComparison.OrdinalIgnoreCase);
+
+ if (isReverse)
+ {
+ return string.IsNullOrWhiteSpace(pickup.SenderCountryCode)
+ ? "CN"
+ : pickup.SenderCountryCode.Trim().ToUpperInvariant();
+ }
+
+ // 正向:目的仓,仓库无国别字段,默认 CN
+ return "CN";
+ }
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs
index fceed43..15d6ca8 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs
@@ -1,12 +1,15 @@
namespace Oms.Application.Integrations.SmallBagTrack;
///
-/// 小包订单生命周期默认轨迹节点(POST ProviderService/Track/Upsert)。
+/// 订单生命周期默认轨迹节点(POST ProviderService/Track/Upsert)。
///
internal static class SmallBagTrackLifecycleEvents
{
public const string CreateTrackCode = "A0101";
+ /// 揽收大包创单轨迹。
+ public const string PickupCreateTrackCode = "A0201";
+
public const string InboundTrackCode = "A0301";
public const string OutboundTrackCode = "A0701";
@@ -18,6 +21,9 @@ internal static class SmallBagTrackLifecycleEvents
/// 轨迹订单类型:小包。
public const int OrderType = 1;
+ /// 轨迹订单类型:大包。
+ public const int BigBagOrderType = 2;
+
/// 数据来源:内部系统。
public const int Source = 2;
}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLinkOrderApiRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLinkOrderApiRequest.cs
new file mode 100644
index 0000000..9a32edd
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLinkOrderApiRequest.cs
@@ -0,0 +1,24 @@
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+///
+/// POST /ProviderService/Order/Link 业务请求体。
+///
+internal sealed class SmallBagTrackLinkOrderApiRequest
+{
+ public string AppKey { get; set; } = string.Empty;
+
+ public bool IsLink { get; set; } = true;
+
+ public int OrderType { get; set; }
+
+ public string OrderNo { get; set; } = string.Empty;
+
+ public string LinkAppKey { get; set; } = string.Empty;
+
+ public int LinkOrderType { get; set; }
+
+ public List LinkOrderNos { get; set; } = [];
+
+ public static string BuildEndpointUrl(string baseUrl) =>
+ $"{baseUrl.Trim().TrimEnd('/')}/ProviderService/Order/Link";
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
index b97d847..b6e5077 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
@@ -51,7 +51,114 @@ internal sealed class SmallBagTrackOrderClient(
string trackCode,
string? authorization = null,
CancellationToken cancellationToken = default) =>
- AppendLifecycleTrackCoreAsync(orderNo, trackCode, authorization, cancellationToken);
+ AppendLifecycleTrackResultAsync(
+ orderNo,
+ trackCode,
+ SmallBagTrackLifecycleEvents.OrderType,
+ authorization,
+ cancellationToken);
+
+ public async Task AppendLifecycleTrackResultAsync(
+ string orderNo,
+ string trackCode,
+ int orderType,
+ string? authorization = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(orderNo))
+ {
+ logger.LogWarning("Skip lifecycle track append: orderNo empty");
+ return Skipped("orderNo empty");
+ }
+
+ if (string.IsNullOrWhiteSpace(trackCode))
+ {
+ logger.LogWarning("Skip lifecycle track append for order {OrderNo}: trackCode empty", orderNo);
+ return Skipped("trackCode empty");
+ }
+
+ var settings = await configProvider.GetAsync(cancellationToken);
+ if (!settings.Enabled)
+ {
+ return Skipped("TrackService disabled");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.BaseUrl))
+ {
+ return Skipped("BaseUrl empty");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
+ {
+ return Skipped("AppKey or AppSecret not configured");
+ }
+
+ var appKey = settings.AppKey.Trim();
+ var auth = ResolveAuthorization(authorization);
+ var trackTime = DateTime.Now;
+ var body = SmallBagTrackUpsertTrackApiRequest.CreateLifecycleEvent(
+ appKey,
+ orderNo.Trim(),
+ trackCode.Trim(),
+ trackTime,
+ orderType);
+ var url = SmallBagTrackUpsertTrackApiRequest.BuildEndpointUrl(settings.BaseUrl);
+ var requestJson = JsonSerializer.Serialize(body, JsonOptions);
+
+ try
+ {
+ var response = await SendSignedPostAsync(
+ settings,
+ appKey,
+ url,
+ body,
+ auth,
+ cancellationToken);
+
+ var result = new SmallBagTrackUpsertResult
+ {
+ Skipped = false,
+ Success = response.Response.IsSuccessStatusCode,
+ Url = url,
+ RequestJson = requestJson,
+ StatusCode = (int)response.Response.StatusCode,
+ ResponseBody = response.ResponseBody
+ };
+
+ if (result.Success)
+ {
+ logger.LogInformation(
+ "Lifecycle track {TrackCode} appended for order {OrderNo} (orderType={OrderType}) at {TrackPlace}",
+ trackCode,
+ orderNo,
+ orderType,
+ SmallBagTrackLifecycleEvents.TrackPlace);
+ }
+ else
+ {
+ logger.LogWarning(
+ "Lifecycle track {TrackCode} append failed for order {OrderNo}: {StatusCode} {Body}",
+ trackCode,
+ orderNo,
+ result.StatusCode,
+ result.ResponseBody);
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Lifecycle track {TrackCode} append failed for order {OrderNo}", trackCode, orderNo);
+ return new SmallBagTrackUpsertResult
+ {
+ Skipped = false,
+ Success = false,
+ Url = url,
+ RequestJson = requestJson,
+ ResponseBody = ex.Message
+ };
+ }
+ }
public async Task UpsertDebugAsync(
SmallBagTrackUpsertRequest request,
@@ -76,7 +183,7 @@ internal sealed class SmallBagTrackOrderClient(
var appKey = settings.AppKey.Trim();
var body = SmallBagTrackUpsertApiRequest.FromBusinessRequest(request);
var url = SmallBagTrackUpsertApiRequest.BuildEndpointUrl(settings.BaseUrl);
- var authorization = ResolveAuthorization(request);
+ var authorization = ResolveAuthorization(request.Authorization);
var requestJson = JsonSerializer.Serialize(body, JsonOptions);
var response = await SendSignedPostAsync(
@@ -120,41 +227,44 @@ internal sealed class SmallBagTrackOrderClient(
return result;
}
- private async Task AppendLifecycleTrackCoreAsync(
- string orderNo,
- string trackCode,
- string? authorization,
- CancellationToken cancellationToken)
+ public async Task UpsertBigBagAsync(
+ SmallBagTrackUpsertBigBagRequest request,
+ CancellationToken cancellationToken = default)
{
- if (string.IsNullOrWhiteSpace(orderNo))
+ if (string.IsNullOrWhiteSpace(request.BagNo))
{
- logger.LogWarning("Skip lifecycle track append: orderNo empty");
- return;
- }
-
- if (string.IsNullOrWhiteSpace(trackCode))
- {
- logger.LogWarning("Skip lifecycle track append for order {OrderNo}: trackCode empty", orderNo);
- return;
+ return Skipped("bagNo empty");
}
var settings = await configProvider.GetAsync(cancellationToken);
- if (!settings.Enabled
- || string.IsNullOrWhiteSpace(settings.BaseUrl)
- || string.IsNullOrWhiteSpace(settings.AppKey)
- || string.IsNullOrWhiteSpace(settings.AppSecret))
+ if (!settings.Enabled)
{
- return;
+ return Skipped("TrackService disabled");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.BaseUrl))
+ {
+ return Skipped("BaseUrl empty");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
+ {
+ return Skipped("AppKey or AppSecret not configured");
}
var appKey = settings.AppKey.Trim();
- var trackTime = DateTime.Now;
- var body = SmallBagTrackUpsertTrackApiRequest.CreateLifecycleEvent(
- appKey,
- orderNo.Trim(),
- trackCode.Trim(),
- trackTime);
- var url = SmallBagTrackUpsertTrackApiRequest.BuildEndpointUrl(settings.BaseUrl);
+ var body = new SmallBagTrackUpsertBigBagApiRequest
+ {
+ Client = request.Client?.Trim() ?? string.Empty,
+ BagNo = request.BagNo.Trim(),
+ OtherNo = request.OtherNo?.Trim() ?? string.Empty,
+ Channel = request.Channel?.Trim() ?? string.Empty,
+ Product = request.Product?.Trim() ?? string.Empty,
+ Country = request.Country?.Trim() ?? string.Empty
+ };
+ var url = SmallBagTrackUpsertBigBagApiRequest.BuildEndpointUrl(settings.BaseUrl);
+ var authorization = ResolveAuthorization(request.Authorization);
+ var requestJson = JsonSerializer.Serialize(body, JsonOptions);
try
{
@@ -166,26 +276,150 @@ internal sealed class SmallBagTrackOrderClient(
authorization,
cancellationToken);
- if (response.Response.IsSuccessStatusCode)
+ var result = new SmallBagTrackUpsertResult
{
- logger.LogInformation(
- "Lifecycle track {TrackCode} appended for order {OrderNo} at {TrackPlace}",
- trackCode,
- orderNo,
- SmallBagTrackLifecycleEvents.TrackPlace);
- return;
+ Skipped = false,
+ Success = response.Response.IsSuccessStatusCode,
+ Url = url,
+ RequestJson = requestJson,
+ StatusCode = (int)response.Response.StatusCode,
+ ResponseBody = response.ResponseBody
+ };
+
+ if (result.Success)
+ {
+ logger.LogInformation("UpsertBigBag succeeded for bag {BagNo}", body.BagNo);
+ }
+ else
+ {
+ logger.LogWarning(
+ "UpsertBigBag failed for bag {BagNo}: {StatusCode} {Body}",
+ body.BagNo,
+ result.StatusCode,
+ result.ResponseBody);
}
- logger.LogWarning(
- "Lifecycle track {TrackCode} append failed for order {OrderNo}: {StatusCode} {Body}",
- trackCode,
- orderNo,
- (int)response.Response.StatusCode,
- response.ResponseBody);
+ return result;
}
catch (Exception ex)
{
- logger.LogWarning(ex, "Lifecycle track {TrackCode} append failed for order {OrderNo}", trackCode, orderNo);
+ logger.LogWarning(ex, "UpsertBigBag failed for bag {BagNo}", request.BagNo);
+ return new SmallBagTrackUpsertResult
+ {
+ Skipped = false,
+ Success = false,
+ Url = url,
+ RequestJson = requestJson,
+ ResponseBody = ex.Message
+ };
+ }
+ }
+
+ public async Task LinkOrdersAsync(
+ string parentOrderNo,
+ int parentOrderType,
+ IReadOnlyList linkOrderNos,
+ int linkOrderType,
+ string? authorization = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(parentOrderNo))
+ {
+ return Skipped("parentOrderNo empty");
+ }
+
+ var nos = (linkOrderNos ?? [])
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x.Trim())
+ .Where(x => !string.Equals(x, parentOrderNo.Trim(), StringComparison.OrdinalIgnoreCase))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (nos.Count == 0)
+ {
+ return Skipped("linkOrderNos empty");
+ }
+
+ var settings = await configProvider.GetAsync(cancellationToken);
+ if (!settings.Enabled)
+ {
+ return Skipped("TrackService disabled");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.BaseUrl))
+ {
+ return Skipped("BaseUrl empty");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
+ {
+ return Skipped("AppKey or AppSecret not configured");
+ }
+
+ var appKey = settings.AppKey.Trim();
+ var body = new SmallBagTrackLinkOrderApiRequest
+ {
+ AppKey = appKey,
+ IsLink = true,
+ OrderType = parentOrderType,
+ OrderNo = parentOrderNo.Trim(),
+ LinkAppKey = appKey,
+ LinkOrderType = linkOrderType,
+ LinkOrderNos = nos
+ };
+ var url = SmallBagTrackLinkOrderApiRequest.BuildEndpointUrl(settings.BaseUrl);
+ var auth = ResolveAuthorization(authorization);
+ var requestJson = JsonSerializer.Serialize(body, JsonOptions);
+
+ try
+ {
+ var response = await SendSignedPostAsync(
+ settings,
+ appKey,
+ url,
+ body,
+ auth,
+ cancellationToken);
+
+ var result = new SmallBagTrackUpsertResult
+ {
+ Skipped = false,
+ Success = response.Response.IsSuccessStatusCode,
+ Url = url,
+ RequestJson = requestJson,
+ StatusCode = (int)response.Response.StatusCode,
+ ResponseBody = response.ResponseBody
+ };
+
+ if (result.Success)
+ {
+ logger.LogInformation(
+ "LinkOrders succeeded for parent {OrderNo} -> {Count} children",
+ body.OrderNo,
+ nos.Count);
+ }
+ else
+ {
+ logger.LogWarning(
+ "LinkOrders failed for parent {OrderNo}: {StatusCode} {Body}",
+ body.OrderNo,
+ result.StatusCode,
+ result.ResponseBody);
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "LinkOrders failed for parent {OrderNo}", parentOrderNo);
+ return new SmallBagTrackUpsertResult
+ {
+ Skipped = false,
+ Success = false,
+ Url = url,
+ RequestJson = requestJson,
+ ResponseBody = ex.Message
+ };
}
}
@@ -215,11 +449,11 @@ internal sealed class SmallBagTrackOrderClient(
return new SignedTrackHttpResponse(response, responseBody, timestamp, sign);
}
- private string? ResolveAuthorization(SmallBagTrackUpsertRequest request)
+ private string? ResolveAuthorization(string? authorization)
{
- if (!string.IsNullOrWhiteSpace(request.Authorization))
+ if (!string.IsNullOrWhiteSpace(authorization))
{
- return request.Authorization.Trim();
+ return authorization.Trim();
}
return httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs
index d8d0bb3..2427d8e 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs
@@ -12,6 +12,7 @@ public static class SmallBagTrackServiceExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddSingleton();
return services;
}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs
new file mode 100644
index 0000000..72e1c00
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs
@@ -0,0 +1,22 @@
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+///
+/// POST /ProviderService/Order/UpsertBigBag 业务请求体。
+///
+internal sealed class SmallBagTrackUpsertBigBagApiRequest
+{
+ public string Client { get; set; } = string.Empty;
+
+ public string BagNo { get; set; } = string.Empty;
+
+ public string OtherNo { get; set; } = string.Empty;
+
+ public string Channel { get; set; } = string.Empty;
+
+ public string Product { get; set; } = string.Empty;
+
+ public string Country { get; set; } = string.Empty;
+
+ public static string BuildEndpointUrl(string baseUrl) =>
+ $"{baseUrl.Trim().TrimEnd('/')}/ProviderService/Order/UpsertBigBag";
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs
new file mode 100644
index 0000000..3635ec7
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs
@@ -0,0 +1,21 @@
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+///
+/// 大包轨迹订单业务请求(UpsertBigBag)。
+///
+public class SmallBagTrackUpsertBigBagRequest
+{
+ public string Client { get; set; } = string.Empty;
+
+ public string BagNo { get; set; } = string.Empty;
+
+ public string? OtherNo { get; set; }
+
+ public string? Channel { get; set; }
+
+ public string? Product { get; set; }
+
+ public string? Country { get; set; }
+
+ public string? Authorization { get; set; }
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs
index 24f2c05..261462f 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs
@@ -24,11 +24,12 @@ internal sealed class SmallBagTrackUpsertTrackApiRequest
string appKey,
string orderNo,
string trackCode,
- DateTime trackTime) =>
+ DateTime trackTime,
+ int orderType = SmallBagTrackLifecycleEvents.OrderType) =>
new()
{
AppKey = appKey,
- OrderType = SmallBagTrackLifecycleEvents.OrderType,
+ OrderType = orderType,
OrderNo = orderNo,
IsAdd = true,
TrackCode = trackCode,
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index 79d6295..7d1d00b 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -640,7 +640,18 @@ public partial class OrderService
? Path.Combine(WebHostEnvironment.ContentRootPath, "wwwroot")
: WebHostEnvironment.WebRootPath;
// 只确保 uploads 子目录可写,避免在只读 ContentRoot 上强建 wwwroot 根目录。
- Directory.CreateDirectory(Path.Combine(webRoot, "uploads"));
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(webRoot, "uploads"));
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // 容器非 root + /app/wwwroot 未预建/权限错误时,导出清理等旁路不应炸主流程
+ }
+ catch (IOException)
+ {
+ }
+
return webRoot;
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 1217f92..fb58dee 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -2588,26 +2588,49 @@ public partial class OrderService
private async Task BuildParcelCreateOutputAsync(Order order)
{
- var mapped = (await MapOrderOutputsAsync([order])).First();
- var systemLabelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id);
- var labelUrl = systemLabelUrl ?? string.Empty;
- if (string.IsNullOrWhiteSpace(labelUrl) && order.OrderStatus == OrderStatusCodes.Predeclared)
+ // 预报已成功时,映射/面单/本地存储失败不得回滚订单(常见:容器 /app/wwwroot 不可写)。
+ var logger = LazyServiceProvider.GetRequiredService()
+ .CreateLogger("Oms.OrderParcelCreate");
+ var labelUrl = string.Empty;
+ try
{
- // 回落 HTML 面单仅用于返回 LabelUrl;写盘失败不得回滚已预报成功的订单。
+ var mapped = (await MapOrderOutputsAsync([order])).First();
try
{
- labelUrl = await CreateOrderPrintFileAsync(mapped);
+ labelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id) ?? string.Empty;
}
catch (Exception ex)
{
- var logger = LazyServiceProvider.GetRequiredService()
- .CreateLogger("Oms.OrderParcelCreate");
logger.LogWarning(
ex,
- "预报成功但回落面单落盘失败 OrderId={OrderId} ServiceNo={ServiceNo}",
+ "预报成功但读取系统面单 URL 失败 OrderId={OrderId} ServiceNo={ServiceNo}",
order.Id,
order.ServiceNo);
}
+
+ if (string.IsNullOrWhiteSpace(labelUrl) && order.OrderStatus == OrderStatusCodes.Predeclared)
+ {
+ try
+ {
+ labelUrl = await CreateOrderPrintFileAsync(mapped);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(
+ ex,
+ "预报成功但回落面单落盘失败 OrderId={OrderId} ServiceNo={ServiceNo}",
+ order.Id,
+ order.ServiceNo);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(
+ ex,
+ "预报成功但组装创单出参失败 OrderId={OrderId} ServiceNo={ServiceNo}",
+ order.Id,
+ order.ServiceNo);
}
return new OrderParcelCreateOutput
diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs
index fc10454..bd33958 100644
--- a/src/Oms.Application/Services/Orders/PickupOrderService.cs
+++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Integrations.Itfx;
+using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Repositories;
@@ -65,6 +66,9 @@ public class PickupOrderService : ApplicationService
private IItfxMetadataClient ItfxMetadataClient =>
LazyServiceProvider.GetRequiredService();
+ private IPickupBigBagTrackSyncer PickupBigBagTrackSyncer =>
+ LazyServiceProvider.GetRequiredService();
+
///
/// 创建揽收单并挂载物流单(原子:全部校验通过才落库)。
///
@@ -983,6 +987,11 @@ public class PickupOrderService : ApplicationService
}
}
+ if (forward.Success)
+ {
+ await PickupBigBagTrackSyncer.SyncAfterDownstreamForwardAsync(pickup);
+ }
+
return new OrderDownstreamForwardResult
{
Success = forward.Success,
diff --git a/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs b/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs
index ab34314..28910bc 100644
--- a/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs
+++ b/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs
@@ -33,6 +33,7 @@ public static class OmsObjectStorageExtensions
///
/// 临时访问地址。空值返回空串;外链原样返回;逻辑 key 走 。
+ /// Local 存储目录不可写/不可读时降级返回 key,避免拖垮下单等主流程。
///
public static async Task GetPublicUrlAsync(
this IObjectStorage storage,
@@ -51,7 +52,22 @@ public static class OmsObjectStorageExtensions
}
var key = FileStoragePath.NormalizeKey(raw);
- return await storage.GetUrlAsync(key, cancellationToken: cancellationToken);
+ try
+ {
+ return await storage.GetUrlAsync(key, cancellationToken: cancellationToken);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return key;
+ }
+ catch (DirectoryNotFoundException)
+ {
+ return key;
+ }
+ catch (IOException)
+ {
+ return key;
+ }
}
///
--
2.45.2
From dd4644e914ed7cc144861e4f10a45eac172cd0c8 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Thu, 24 Sep 2026 15:00:36 +0800
Subject: [PATCH 04/41] =?UTF-8?q?feat:=20=E6=8F=BD=E6=94=B6=E5=A4=A7?=
=?UTF-8?q?=E5=8C=85=E8=BD=A8=E8=BF=B9=E6=89=8B=E5=8A=A8=E5=90=8C=E6=AD=A5?=
=?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E7=AE=A1?=
=?UTF-8?q?=E7=90=86=E5=91=98=E9=87=8D=E7=BD=AE=E5=AF=86=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增 track-upsert-big-bag 返回三步联调结果;补齐 reset-password API。
Co-authored-by: Cursor
---
.../Dtos/Rbac/OmsUserProfileDtos.cs | 11 +++
.../SmallBagTrack/PickupBigBagTrackSyncer.cs | 80 +++++++++++++++++--
.../Services/Orders/PickupOrderService.cs | 10 +++
.../Services/Rbac/OmsUserService.cs | 30 +++++++
4 files changed, 125 insertions(+), 6 deletions(-)
diff --git a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs
index 903a8f2..415831c 100644
--- a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs
+++ b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs
@@ -54,3 +54,14 @@ public class OmsUserDetailDto : UserDto
public string? WecomKfUrl { get; set; }
}
+
+///
+/// 管理员重置用户密码入参。
+///
+public class ResetUserPasswordInput
+{
+ ///
+ /// 新密码。
+ ///
+ public string NewPassword { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
index e3bca81..fc63b2c 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
@@ -9,11 +9,36 @@ using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Integrations.SmallBagTrack;
///
-/// 揽收单预报下游成功后:按序 UpsertBigBag → Link → Track/Upsert(A0201)。
+/// 揽收大包轨迹同步结果(UpsertBigBag → Link → Track A0201)。
+///
+public class PickupBigBagTrackSyncResult
+{
+ public string PickupNo { get; set; } = string.Empty;
+
+ public bool Success { get; set; }
+
+ public string? FailStep { get; set; }
+
+ public string? Message { get; set; }
+
+ public SmallBagTrackUpsertResult? UpsertBigBag { get; set; }
+
+ public SmallBagTrackUpsertResult? Link { get; set; }
+
+ public SmallBagTrackUpsertResult? TrackUpsert { get; set; }
+}
+
+///
+/// 揽收单预报下游成功后 / 手动:按序 UpsertBigBag → Link → Track/Upsert(A0201)。
///
public interface IPickupBigBagTrackSyncer
{
Task SyncAfterDownstreamForwardAsync(PickupOrder pickup, CancellationToken cancellationToken = default);
+
+ ///
+ /// 同步执行三步并返回逐步请求/响应(手动「创建轨迹订单」联调)。
+ ///
+ Task SyncDebugAsync(PickupOrder pickup, CancellationToken cancellationToken = default);
}
internal sealed class PickupBigBagTrackSyncer(
@@ -29,13 +54,29 @@ internal sealed class PickupBigBagTrackSyncer(
PickupOrder pickup,
CancellationToken cancellationToken = default)
{
+ await SyncDebugAsync(pickup, cancellationToken);
+ }
+
+ public async Task SyncDebugAsync(
+ PickupOrder pickup,
+ CancellationToken cancellationToken = default)
+ {
+ var result = new PickupBigBagTrackSyncResult
+ {
+ PickupNo = pickup?.PickupNo?.Trim() ?? string.Empty
+ };
+
if (pickup == null || string.IsNullOrWhiteSpace(pickup.PickupNo))
{
+ result.Success = false;
+ result.FailStep = "validate";
+ result.Message = "pickup or PickupNo empty";
logger.LogWarning("Skip big-bag track sync: pickup or PickupNo empty");
- return;
+ return result;
}
var bagNo = pickup.PickupNo.Trim();
+ result.PickupNo = bagNo;
var authorization = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization))
{
@@ -62,22 +103,29 @@ internal sealed class PickupBigBagTrackSyncer(
Authorization = authorization
},
cancellationToken);
+ result.UpsertBigBag = upsertResult;
if (upsertResult.Skipped)
{
+ result.Success = false;
+ result.FailStep = "UpsertBigBag";
+ result.Message = upsertResult.SkipReason ?? "skipped";
logger.LogDebug(
"UpsertBigBag skipped for pickup {PickupNo}: {Reason}",
bagNo,
upsertResult.SkipReason);
- return;
+ return result;
}
if (!upsertResult.Success)
{
+ result.Success = false;
+ result.FailStep = "UpsertBigBag";
+ result.Message = $"HTTP {upsertResult.StatusCode}";
logger.LogWarning(
"Stop big-bag track sync after UpsertBigBag failure for pickup {PickupNo}",
bagNo);
- return;
+ return result;
}
// 2) Link(无明细单号则跳过,继续写轨迹)
@@ -98,6 +146,7 @@ internal sealed class PickupBigBagTrackSyncer(
SmallBagTrackLifecycleEvents.OrderType,
authorization,
cancellationToken);
+ result.Link = linkResult;
if (linkResult.Skipped)
{
@@ -108,10 +157,13 @@ internal sealed class PickupBigBagTrackSyncer(
}
else if (!linkResult.Success)
{
+ result.Success = false;
+ result.FailStep = "Link";
+ result.Message = $"HTTP {linkResult.StatusCode}";
logger.LogWarning(
"Stop big-bag track sync after Link failure for pickup {PickupNo}",
bagNo);
- return;
+ return result;
}
}
else
@@ -126,28 +178,44 @@ internal sealed class PickupBigBagTrackSyncer(
SmallBagTrackLifecycleEvents.BigBagOrderType,
authorization,
cancellationToken);
+ result.TrackUpsert = trackResult;
if (trackResult.Skipped)
{
+ result.Success = false;
+ result.FailStep = "TrackUpsert";
+ result.Message = trackResult.SkipReason ?? "skipped";
logger.LogDebug(
"Track A0201 skipped for pickup {PickupNo}: {Reason}",
bagNo,
trackResult.SkipReason);
- return;
+ return result;
}
if (!trackResult.Success)
{
+ result.Success = false;
+ result.FailStep = "TrackUpsert";
+ result.Message = $"HTTP {trackResult.StatusCode}";
logger.LogWarning(
"Track A0201 failed for pickup {PickupNo}: {StatusCode} {Body}",
bagNo,
trackResult.StatusCode,
trackResult.ResponseBody);
+ return result;
}
+
+ result.Success = true;
+ result.Message = "ok";
+ return result;
}
catch (Exception ex)
{
+ result.Success = false;
+ result.FailStep ??= "exception";
+ result.Message = ex.Message;
logger.LogWarning(ex, "Big-bag track sync failed for pickup {PickupNo}", bagNo);
+ return result;
}
}
diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs
index bd33958..9020543 100644
--- a/src/Oms.Application/Services/Orders/PickupOrderService.cs
+++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs
@@ -1005,6 +1005,16 @@ public class PickupOrderService : ApplicationService
};
}
+ ///
+ /// 手动创建大包轨迹订单:按序 UpsertBigBag → Link → Track/Upsert(A0201),返回逐步请求/响应。
+ ///
+ [HttpPost("/order/pickup-order/track-upsert-big-bag")]
+ public async Task TrackUpsertBigBagAsync([FromBody] PickupOrderIdInput input)
+ {
+ var pickup = await GetOwnedPickupOrThrowAsync(input.Id);
+ return await PickupBigBagTrackSyncer.SyncDebugAsync(pickup);
+ }
+
///
/// 凭证:优先揽收单 CarrierCode 作为 supplierCode;AppKey/Secret 仍按承运服务→渠道分组解析。
///
diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs
index d4f4a2e..8abb5fb 100644
--- a/src/Oms.Application/Services/Rbac/OmsUserService.cs
+++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs
@@ -4,6 +4,7 @@ using Fake.Rbac.Application.Dtos.Common;
using Fake.Rbac.Application.Dtos.Role;
using Fake.Rbac.Application.Dtos.User;
using Fake.Rbac.Application.Services;
+using Fake.Rbac.Domain.Managers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Fake.ObjectStorage;
@@ -23,6 +24,7 @@ namespace Oms.Application.Services.Rbac;
[ApiExplorerSettings(GroupName = "RBAC-User")]
public class OmsUserService(
UserService userService,
+ AccountManager accountManager,
UserProfileRepository profileRepository,
IEmployeeRepository employeeRepository,
IObjectStorage fileStorage) : ApplicationService
@@ -167,6 +169,34 @@ public class OmsUserService(
CancellationToken cancellationToken = default) =>
userService.UpdatePasswordAsync(id, input, cancellationToken);
+ ///
+ /// 管理员重置用户密码(无需旧密码)。
+ ///
+ [HttpPost("/rbac/user/reset-password")]
+ public async Task ResetPasswordAsync(
+ [FromQuery] Guid id,
+ [FromBody] ResetUserPasswordInput input,
+ CancellationToken cancellationToken = default)
+ {
+ if (id == Guid.Empty)
+ {
+ throw new BusinessException("用户 Id 无效");
+ }
+
+ var newPassword = input?.NewPassword?.Trim();
+ if (string.IsNullOrWhiteSpace(newPassword))
+ {
+ throw new BusinessException("新密码不能为空");
+ }
+
+ if (newPassword.Length < 6)
+ {
+ throw new BusinessException("密码长度不能少于6位");
+ }
+
+ await accountManager.ResetPasswordAsync(id, newPassword, cancellationToken);
+ }
+
///
/// 更新用户头像。
///
--
2.45.2
From ddb8efba158cb200904eab0100796cf52f93700e Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Thu, 24 Sep 2026 16:12:39 +0800
Subject: [PATCH 05/41] =?UTF-8?q?feat:=209610=E6=B1=87=E6=80=BB=E4=B8=8B?=
=?UTF-8?q?=E5=8D=95=E5=85=B3=E8=81=94=E5=87=BA=E5=8F=A3=E8=B5=84=E8=B4=A8?=
=?UTF-8?q?=EF=BC=8C=E5=B9=B6=E8=A1=A5=E9=BD=90=E6=89=B9=E9=87=8F=E6=A8=A1?=
=?UTF-8?q?=E6=9D=BF=E5=AD=97=E6=AE=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
下单/导入在 9610Summary 时校验并落库 ExportQualificationId(可回落默认备案),写入 9610 申报企业信息;专线小包模板长宽高改必填并增加资质备案列。
Co-authored-by: Cursor
---
db.sql | 2 +
sql/migrate_order_export_qualification_id.sql | 24 +++++++++
.../CustomerExportQualificationDtos.cs | 9 ++--
.../Dtos/Orders/OrderOutput.cs | 5 ++
.../Dtos/Orders/OrderParcelCreateInput.cs | 5 ++
.../CustomerExportQualificationService.cs | 27 +++++++---
.../Services/Orders/Customs9610OrderWriter.cs | 54 ++++++++++++++++++-
.../Services/Orders/OrderService.Helpers.cs | 1 +
.../Orders/OrderService.ParcelBilling.cs | 36 +++++++++++++
.../Templates/OrderTemplateFieldRegistry.cs | 18 ++++---
.../Templates/OrderTemplateHeaderMatcher.cs | 4 ++
.../Templates/OrderTemplateRowMapper.cs | 21 +++++++-
.../ICustomerExportQualificationRepository.cs | 4 ++
.../OrdersAggregate/Entities/Order.cs | 6 +++
.../CustomerExportQualificationRepository.cs | 7 +++
15 files changed, 204 insertions(+), 19 deletions(-)
create mode 100644 sql/migrate_order_export_qualification_id.sql
diff --git a/db.sql b/db.sql
index 72e747b..a800793 100644
--- a/db.sql
+++ b/db.sql
@@ -124,6 +124,7 @@ CREATE TABLE `t_order` (
`ConsigneeCountryName` VARCHAR(64) NULL COMMENT '国家中文名称',
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
`ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式',
+ `ExportQualificationId` INT NULL COMMENT '出口资质备案 Id(t_customer_export_qualification.Id)',
`PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因',
`AllocationPendingReason` VARCHAR(512) NULL COMMENT '待排货原因(排货或渠道买价未匹配)',
`AllocationScheme` VARCHAR(32) NULL COMMENT '最近一次排货方案 costLowest/timeFastest/overallBest/designated',
@@ -204,6 +205,7 @@ CREATE TABLE `t_order` (
KEY `idx_ord_source_ctime` (`OrderSource`, `CreateTime`),
KEY `idx_order_pickup` (`PickupOrderId`),
KEY `idx_order_inbound_method` (`InboundMethod`),
+ KEY `idx_order_export_qualification` (`ExportQualificationId`),
KEY `idx_ord_fulfillment_customer` (`FulfillmentType`, `CustomerId`, `OrderStatus`)
) COMMENT='订单表';
diff --git a/sql/migrate_order_export_qualification_id.sql b/sql/migrate_order_export_qualification_id.sql
new file mode 100644
index 0000000..7cb5d9b
--- /dev/null
+++ b/sql/migrate_order_export_qualification_id.sql
@@ -0,0 +1,24 @@
+-- 存量库:t_order 增加出口资质备案 Id(9610Summary 选用)
+-- 可重复执行。
+
+SET @db := DATABASE();
+
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order'
+ AND COLUMN_NAME = 'ExportQualificationId'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order`
+ ADD COLUMN `ExportQualificationId` INT NULL COMMENT ''出口资质备案 Id(t_customer_export_qualification.Id)'' AFTER `ExportCustomsMode`,
+ ADD KEY `idx_order_export_qualification` (`ExportQualificationId`)',
+ 'SELECT ''t_order.ExportQualificationId already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs b/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs
index d068309..a0268b8 100644
--- a/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs
+++ b/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs
@@ -48,18 +48,21 @@ public class CreateCustomerExportQualificationInput
[StringLength(200)]
public string DomesticConsignor { get; set; } = string.Empty;
+ [Required]
[StringLength(64)]
- public string? DomesticConsignorCustomsCode { get; set; }
+ public string DomesticConsignorCustomsCode { get; set; } = string.Empty;
[Required]
[StringLength(200)]
public string ProducerName { get; set; } = string.Empty;
+ [Required]
[StringLength(64)]
- public string? ProducerCustomsCode { get; set; }
+ public string ProducerCustomsCode { get; set; } = string.Empty;
+ [Required]
[StringLength(100)]
- public string? CustomsPort { get; set; }
+ public string CustomsPort { get; set; } = string.Empty;
public bool IsDefault { get; set; }
diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
index 47b72f4..a82aea9 100644
--- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
@@ -205,6 +205,11 @@ public class OrderOutput
///
public string? ExportCustomsMode { get; set; }
+ ///
+ /// 出口资质备案 Id。
+ ///
+ public int? ExportQualificationId { get; set; }
+
///
/// 预报失败原因(草稿订单)。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs
index f3048f6..14ab496 100644
--- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs
@@ -45,6 +45,11 @@ public class OrderParcelCreateInput
///
public string? ExportCustomsMode { get; set; }
+ ///
+ /// 出口资质备案 Id(ExportCustomsMode=9610Summary 时必填)。
+ ///
+ public int? ExportQualificationId { get; set; }
+
///
/// 仓库主键。
///
diff --git a/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs b/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs
index 4818a7b..16e86e0 100644
--- a/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs
+++ b/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs
@@ -86,10 +86,10 @@ public class CustomerExportQualificationService : ApplicationService
CompanyName = input.CompanyName.Trim(),
SocialCreditCode = input.SocialCreditCode.Trim(),
DomesticConsignor = input.DomesticConsignor.Trim(),
- DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode),
+ DomesticConsignorCustomsCode = input.DomesticConsignorCustomsCode.Trim(),
ProducerName = input.ProducerName.Trim(),
- ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode),
- CustomsPort = TrimOrNull(input.CustomsPort),
+ ProducerCustomsCode = input.ProducerCustomsCode.Trim(),
+ CustomsPort = input.CustomsPort.Trim(),
IsDefault = input.IsDefault ? (byte)1 : (byte)0,
Remark = TrimOrNull(input.Remark)
};
@@ -115,10 +115,10 @@ public class CustomerExportQualificationService : ApplicationService
entity.CompanyName = input.CompanyName.Trim();
entity.SocialCreditCode = input.SocialCreditCode.Trim();
entity.DomesticConsignor = input.DomesticConsignor.Trim();
- entity.DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode);
+ entity.DomesticConsignorCustomsCode = input.DomesticConsignorCustomsCode.Trim();
entity.ProducerName = input.ProducerName.Trim();
- entity.ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode);
- entity.CustomsPort = TrimOrNull(input.CustomsPort);
+ entity.ProducerCustomsCode = input.ProducerCustomsCode.Trim();
+ entity.CustomsPort = input.CustomsPort.Trim();
entity.IsDefault = input.IsDefault ? (byte)1 : (byte)0;
entity.Remark = TrimOrNull(input.Remark);
@@ -183,10 +183,25 @@ public class CustomerExportQualificationService : ApplicationService
throw new BusinessException("境内发货人不能为空");
}
+ if (string.IsNullOrWhiteSpace(input.DomesticConsignorCustomsCode))
+ {
+ throw new BusinessException("境内发货人海关代码不能为空");
+ }
+
if (string.IsNullOrWhiteSpace(input.ProducerName))
{
throw new BusinessException("生产销售单位不能为空");
}
+
+ if (string.IsNullOrWhiteSpace(input.ProducerCustomsCode))
+ {
+ throw new BusinessException("生产销售单位海关代码不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(input.CustomsPort))
+ {
+ throw new BusinessException("备案海关港口地不能为空");
+ }
}
private static Expression> BuildPredicate(
diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs
index f0a4f1d..22b9437 100644
--- a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs
+++ b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs
@@ -1,4 +1,6 @@
using Fake.DependencyInjection;
+using Oms.Domain.CustomersAggregate.Entities;
+using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
@@ -13,15 +15,18 @@ public class Customs9610OrderWriter : IScopedDependency
private readonly ICustoms9610OrderRepository _headerRepository;
private readonly ICustoms9610OrderItemRepository _itemRepository;
private readonly IOrderRepository _orderRepository;
+ private readonly ICustomerExportQualificationRepository _exportQualificationRepository;
public Customs9610OrderWriter(
ICustoms9610OrderRepository headerRepository,
ICustoms9610OrderItemRepository itemRepository,
- IOrderRepository orderRepository)
+ IOrderRepository orderRepository,
+ ICustomerExportQualificationRepository exportQualificationRepository)
{
_headerRepository = headerRepository;
_itemRepository = itemRepository;
_orderRepository = orderRepository;
+ _exportQualificationRepository = exportQualificationRepository;
}
public async Task UpsertFromOrderAsync(Order order, CancellationToken cancellationToken = default)
@@ -55,6 +60,7 @@ public class Customs9610OrderWriter : IScopedDependency
var grossWeight = ResolveGrossWeight(weights);
var serviceNo = order.ServiceNo?.Trim() ?? string.Empty;
var clientNo = FirstNonEmpty(order.ClientNo, order.ServiceNo) ?? string.Empty;
+ var qualification = await ResolveQualificationAsync(order, cancellationToken);
if (existing != null
&& string.Equals(existing.Status, Customs9610StatusCodes.Declared, StringComparison.OrdinalIgnoreCase))
@@ -98,6 +104,8 @@ public class Customs9610OrderWriter : IScopedDependency
header.PreRecordFlag = "1";
}
+ ApplyQualification(header, qualification);
+
header.GrossWeight = grossWeight;
header.Currency = FirstNonEmpty(order.DeclaredCurrency, header.Currency, "CNY")!;
header.DestCountry = FirstNonEmpty(order.ConsigneeCountry, header.DestCountry) ?? string.Empty;
@@ -141,6 +149,50 @@ public class Customs9610OrderWriter : IScopedDependency
}
}
+ private async Task ResolveQualificationAsync(
+ Order order,
+ CancellationToken cancellationToken)
+ {
+ if (order.CustomerId is not > 0)
+ {
+ return null;
+ }
+
+ if (order.ExportQualificationId is > 0)
+ {
+ var owned = await _exportQualificationRepository.GetOwnedAsync(
+ order.ExportQualificationId.Value,
+ order.CustomerId.Value,
+ cancellationToken);
+ if (owned != null)
+ {
+ return owned;
+ }
+ }
+
+ return await _exportQualificationRepository.GetDefaultAsync(
+ order.CustomerId.Value,
+ cancellationToken);
+ }
+
+ private static void ApplyQualification(Customs9610Order header, CustomerExportQualification? qualification)
+ {
+ if (qualification == null)
+ {
+ return;
+ }
+
+ header.EcomCorpCode = Truncate(
+ FirstNonEmpty(qualification.DomesticConsignorCustomsCode, qualification.SocialCreditCode),
+ 18);
+ header.EcomCorpName = Truncate(
+ FirstNonEmpty(qualification.CompanyName, qualification.DomesticConsignor),
+ 100);
+ header.ProducerCode = Truncate(qualification.ProducerCustomsCode, 18);
+ header.ProducerName = Truncate(qualification.ProducerName, 100);
+ header.ActualSellerCode = Truncate(qualification.SocialCreditCode, 18);
+ }
+
private static decimal ResolveGrossWeight(IReadOnlyList weights)
{
var forecast = weights.FirstOrDefault(x =>
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index 7d1d00b..9444025 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -200,6 +200,7 @@ public partial class OrderService
: null,
RequestId = order.RequestId,
ExportCustomsMode = order.ExportCustomsMode,
+ ExportQualificationId = order.ExportQualificationId,
PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason)
? null
: order.PredeclareFailReason.Trim(),
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index fb58dee..ab6f286 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -40,6 +40,8 @@ public partial class OrderService
private static readonly ConcurrentDictionary InboundBillingInFlight = new();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService();
+ private ICustomerExportQualificationRepository ExportQualificationRepository =>
+ LazyServiceProvider.GetRequiredService();
private IMemberLevelRepository MemberLevelRepository => LazyServiceProvider.GetRequiredService();
@@ -409,6 +411,39 @@ public partial class OrderService
throw new BusinessException("出口报关方式不合法");
}
+ var is9610Summary = string.Equals(
+ input.ExportCustomsMode,
+ ExportCustomsModeCodes.Mode9610Summary,
+ StringComparison.OrdinalIgnoreCase);
+ if (is9610Summary)
+ {
+ CustomerExportQualification? qualification = null;
+ if (input.ExportQualificationId is > 0)
+ {
+ qualification = await ExportQualificationRepository.GetOwnedAsync(
+ input.ExportQualificationId.Value,
+ input.CustomerId!.Value);
+ if (qualification == null)
+ {
+ throw new BusinessException("出口资质备案不存在或不属于当前客户");
+ }
+ }
+ else
+ {
+ qualification = await ExportQualificationRepository.GetDefaultAsync(input.CustomerId!.Value);
+ if (qualification == null)
+ {
+ throw new BusinessException("9610汇总申报必须选择出口资质备案(或先维护默认备案)");
+ }
+
+ input.ExportQualificationId = qualification.Id;
+ }
+ }
+ else
+ {
+ input.ExportQualificationId = null;
+ }
+
if (string.IsNullOrWhiteSpace(input.DeclaredCurrency))
{
throw new BusinessException("申报币种不能为空");
@@ -1906,6 +1941,7 @@ public partial class OrderService
ConsigneeCountryName = consigneeCountryName,
CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General,
ExportCustomsMode = input.ExportCustomsMode,
+ ExportQualificationId = input.ExportQualificationId,
WarehouseId = input.WarehouseId,
CustomerId = input.CustomerId,
OrderSource = orderSource,
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
index ba2043d..cb8505c 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
@@ -110,10 +110,11 @@ public static class OrderTemplateFieldRegistry
Add("warehouseId", "发货仓库", true, "select", "warehouse", "basic", "1", hint: "从下拉选择发货仓库 ID");
Add("actualWeight", "实重(kg)", true, "number", example: "1.2", hint: "包裹实际重量,单位千克");
Add("quantity", "包裹件数", true, "number", example: "1", hint: "一般为 1");
- Add("length", "长(cm)", false, "number", example: "10", hint: "选填,用于材积计费");
- Add("width", "宽(cm)", false, "number", example: "10", hint: "选填,用于材积计费");
- Add("height", "高(cm)", false, "number", example: "10", hint: "选填,用于材积计费");
- Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报");
+ Add("length", "长(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
+ Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
+ Add("height", "高(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
+ Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报;填 9610Summary 时需有默认出口资质或填备案Id");
+ Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案");
Add("receiver.recipientsName", "收件人姓名", true, group: "receiver", example: "John Smith", hint: "必填");
Add("receiver.phone", "收件人手机", true, group: "receiver", example: "13900139000", hint: "必填");
@@ -167,11 +168,12 @@ public static class OrderTemplateFieldRegistry
Add("clientNo", "客户单号", true, example: "C202604230001", hint: "您自己的订单编号,同一客户不可重复");
Add("productCode", "运输方式", true, "select", "sales_product", "basic", "PROD001", hint: "从下拉选择产品编码");
Add("cargoAttribute", "货物属性", true, "select", "cargo_attribute", "basic", "general", hint: "普货选 general");
- Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报");
+ Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报;填 9610Summary 时需有默认出口资质或填备案Id");
+ Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案");
Add("warehouseId", "发货仓库", true, "select", "warehouse", "basic", "1", hint: "从下拉选择仓库 ID");
- Add("length", "长(cm)", false, "number", example: "10", hint: "材积计费用");
- Add("width", "宽(cm)", false, "number", example: "10", hint: "材积计费用");
- Add("height", "高(cm)", false, "number", example: "10", hint: "材积计费用");
+ Add("length", "长(cm)", true, "number", example: "10", hint: "必填,材积计费用");
+ Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,材积计费用");
+ Add("height", "高(cm)", true, "number", example: "10", hint: "必填,材积计费用");
Add("actualWeight", "实重(kg)", true, "number", example: "1.2", hint: "必填");
Add("isCod", "是否COD", false, "boolean", example: "false", hint: "货到付款填 true");
Add("codCurrency", "COD币种", false, "select", "currency", example: "USD", hint: "COD 订单填写");
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs
index 76830e7..7ba39ba 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs
@@ -20,6 +20,10 @@ public static class OrderTemplateHeaderMatcher
["产品编码"] = "productCode",
["货物属性"] = "cargoAttribute",
["出口报关方式"] = "exportCustomsMode",
+ ["出口报关"] = "exportCustomsMode",
+ ["出口资质备案Id"] = "exportQualificationId",
+ ["出口资质备案"] = "exportQualificationId",
+ ["资质备案Id"] = "exportQualificationId",
["仓库"] = "warehouseId",
["仓库ID"] = "warehouseId",
["长cm"] = "length",
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs
index e9146fb..b1470a6 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs
@@ -27,7 +27,7 @@ public static class OrderTemplateRowMapper
private static readonly HashSet ForceNumberFields = new(StringComparer.OrdinalIgnoreCase)
{
- "warehouseId", "length", "width", "height", "actualWeight", "codSum", "declaredSum", "quantity",
+ "warehouseId", "exportQualificationId", "length", "width", "height", "actualWeight", "codSum", "declaredSum", "quantity",
"receiver.taxType", "receiver.certificateType", "items[0].unitPrice", "items[0].unitWeight",
"items[0].quantity", "items[0].amount", "items[0].weight"
};
@@ -75,9 +75,28 @@ public static class OrderTemplateRowMapper
}
ApplyDerivedDeclarationFields(input);
+ ApplyParcelDimensionDefaults(input);
return input;
}
+ private static void ApplyParcelDimensionDefaults(OrderParcelCreateInput input)
+ {
+ if (!input.Length.HasValue || input.Length <= 0)
+ {
+ input.Length = 10;
+ }
+
+ if (!input.Width.HasValue || input.Width <= 0)
+ {
+ input.Width = 10;
+ }
+
+ if (!input.Height.HasValue || input.Height <= 0)
+ {
+ input.Height = 10;
+ }
+ }
+
private static void ApplyDerivedDeclarationFields(OrderParcelCreateInput input)
{
if (string.IsNullOrWhiteSpace(input.DeclaredCurrency))
diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerExportQualificationRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerExportQualificationRepository.cs
index 2745c10..a017633 100644
--- a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerExportQualificationRepository.cs
+++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerExportQualificationRepository.cs
@@ -10,6 +10,10 @@ public interface ICustomerExportQualificationRepository : IRepository GetDefaultAsync(
+ int customerId,
+ CancellationToken cancellationToken = default);
+
Task ClearDefaultAsync(
int customerId,
int? excludeId = null,
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
index f58d638..611dd00 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
@@ -52,6 +52,12 @@ public class Order : OmsAuditedAggregateRoot
public string? ExportCustomsMode { get; set; }
+ ///
+ /// 出口资质备案 Id(9610 汇总申报选用)。
+ ///
+ [SugarColumn(IsNullable = true)]
+ public int? ExportQualificationId { get; set; }
+
public string? PredeclareFailReason { get; set; }
///
diff --git a/src/Oms.SqlSugarCore/Customers/CustomerExportQualificationRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerExportQualificationRepository.cs
index 6852a27..f4886cc 100644
--- a/src/Oms.SqlSugarCore/Customers/CustomerExportQualificationRepository.cs
+++ b/src/Oms.SqlSugarCore/Customers/CustomerExportQualificationRepository.cs
@@ -18,6 +18,13 @@ public class CustomerExportQualificationRepository
return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken);
}
+ public Task GetDefaultAsync(
+ int customerId,
+ CancellationToken cancellationToken = default)
+ {
+ return FirstOrDefaultAsync(x => x.CustomerId == customerId && x.IsDefault == 1, cancellationToken);
+ }
+
public async Task ClearDefaultAsync(
int customerId,
int? excludeId = null,
--
2.45.2
From 6c8f7cb7811da37577fd10d641b9af1643f934dc Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Mon, 28 Sep 2026 14:25:11 +0800
Subject: [PATCH 06/41] =?UTF-8?q?feat:=209610=E6=B1=87=E6=80=BB=E4=B8=8B?=
=?UTF-8?q?=E5=8D=95=E5=BF=85=E5=A1=AB=E7=94=B5=E5=95=86=E5=B9=B3=E5=8F=B0?=
=?UTF-8?q?=E5=86=99=E5=85=A5=E9=A2=84=E6=8A=A5=EF=BC=8C=E5=B9=B6=E5=A2=9E?=
=?UTF-8?q?=E5=BC=BA=E5=AE=98=E7=BD=91=E4=BA=A7=E5=93=81=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E7=AD=9B=E9=80=89=E9=A1=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
.../SalesProductPortalCatalogOutput.cs | 43 +++-
.../Dtos/Orders/OrderParcelCreateInput.cs | 5 +
.../SmallBagTrack/PickupBigBagTrackSyncer.cs | 8 +-
.../Channels/SalesProductService.Portal.cs | 192 +++++++++++++++++-
.../Services/Orders/Customs9610OrderWriter.cs | 12 +-
.../Orders/OrderService.ParcelBilling.cs | 20 +-
.../Templates/OrderTemplateFieldRegistry.cs | 6 +-
7 files changed, 269 insertions(+), 17 deletions(-)
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs
index fff802a..840ca84 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs
@@ -6,10 +6,15 @@ namespace Oms.Application.Dtos.Channels;
public class SalesProductPortalCatalogOutput
{
///
- /// 分组列表(已排除国内揽收,且仅含有产品的分组)。
+ /// 分组列表(字典 sales_product_group,已排除国内揽收,且仅含有产品的分组)。
///
public List Groups { get; set; } = [];
+ ///
+ /// 货型筛选项(字典 cargo_attribute)。
+ ///
+ public List Cargos { get; set; } = [];
+
///
/// 启用中的销售产品(已排除国内揽收)。
///
@@ -22,12 +27,28 @@ public class SalesProductPortalCatalogOutput
public class SalesProductPortalGroupOutput
{
///
- /// 分组编码(字典 code)。
+ /// 分组编码(字典 sales_product_group.code)。
///
public string Code { get; set; } = string.Empty;
///
- /// 分组名称。
+ /// 分组名称(字典 sales_product_group.name)。
+ ///
+ public string Name { get; set; } = string.Empty;
+}
+
+///
+/// 官网货型筛选项。
+///
+public class SalesProductPortalCargoOutput
+{
+ ///
+ /// 货型编码(字典 cargo_attribute.code)。
+ ///
+ public string Code { get; set; } = string.Empty;
+
+ ///
+ /// 货型名称(字典 cargo_attribute.name)。
///
public string Name { get; set; } = string.Empty;
}
@@ -81,4 +102,20 @@ public class SalesProductPortalItemOutput
/// 产品说明。
///
public string? ProductDescription { get; set; }
+
+ ///
+ /// 官网目的地区域编码:us / eu / sea / ca_mx / au / global。
+ /// 优先由价格分区国家推导;国际快递跨区时含 global。
+ ///
+ public List DestRegions { get; set; } = [];
+
+ ///
+ /// 覆盖国家二字码(来自价格分区明细,去重后截断)。
+ ///
+ public List CoverageCountries { get; set; } = [];
+
+ ///
+ /// 货型标签(字典 cargo_attribute.code,如 general / electronics / sensitive)。
+ ///
+ public List CargoTags { get; set; } = [];
}
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs
index 14ab496..653924a 100644
--- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs
@@ -50,6 +50,11 @@ public class OrderParcelCreateInput
///
public int? ExportQualificationId { get; set; }
+ ///
+ /// 电商平台(ExportCustomsMode=9610Summary 时必填,写入 t_9610_order.PlatformCode)。
+ ///
+ public string? PlatformCode { get; set; }
+
///
/// 仓库主键。
///
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
index fc63b2c..7081b08 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Oms.Domain.ChannelsAggregate.Repositories;
-using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
@@ -43,7 +42,6 @@ public interface IPickupBigBagTrackSyncer
internal sealed class PickupBigBagTrackSyncer(
ISmallBagTrackOrderClient trackClient,
- ICustomerRepository customerRepository,
IPickupOrderLineRepository pickupOrderLineRepository,
ISalesProductRepository salesProductRepository,
IServiceChannelRepository serviceChannelRepository,
@@ -85,16 +83,14 @@ internal sealed class PickupBigBagTrackSyncer(
try
{
- var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == pickup.CustomerId, cancellationToken);
- var clientCode = customer?.CustomerCode?.Trim() ?? string.Empty;
var (channel, product) = await ResolveChannelProductAsync(pickup.CarrierService, cancellationToken);
var country = ResolveCountry(pickup);
- // 1) UpsertBigBag
+ // 1) UpsertBigBag:client 取 product(销售产品编码)
var upsertResult = await trackClient.UpsertBigBagAsync(
new SmallBagTrackUpsertBigBagRequest
{
- Client = clientCode,
+ Client = product ?? string.Empty,
BagNo = bagNo,
OtherNo = pickup.CarrierWaybillNo,
Channel = channel,
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs b/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs
index 12c0ed9..5599706 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Channels;
+using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.Shared.Enums;
using Oms.Domain.SystemAggregate.Repositories;
@@ -9,8 +10,22 @@ namespace Oms.Application.Services.Channels;
public partial class SalesProductService
{
private const string SalesProductGroupDictType = "sales_product_group";
+ private const string CargoAttributeDictType = "cargo_attribute";
private const string UngroupedProductGroupCode = "_ungrouped";
private const string DomesticPickupGroupNameKeyword = "国内揽收";
+ private const string ExpressProductGroupCode = "express";
+ private const int PortalCoverageCountryLimit = 40;
+
+ private static readonly HashSet PortalEuCountryCodes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT",
+ "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "UK"
+ };
+
+ private static readonly HashSet PortalSeaCountryCodes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "MY", "SG", "PH", "TH", "VN", "ID"
+ };
private IDictRepository DictRepository =>
LazyServiceProvider.GetRequiredService();
@@ -24,6 +39,7 @@ public partial class SalesProductService
{
var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled);
var dictRows = await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType);
+ var cargoDictRows = await DictRepository.GetListAsync(x => x.DictType == CargoAttributeDictType);
var domesticPickupGroupCodes = dictRows
.Where(x => !string.IsNullOrWhiteSpace(x.DictName)
@@ -59,6 +75,26 @@ public partial class SalesProductService
.ThenBy(p => p.Id)
.ToList();
+ var areaIds = filtered
+ .Select(p => p.PriceAreaId)
+ .Where(id => id is > 0)
+ .Select(id => id!.Value)
+ .Distinct()
+ .ToList();
+
+ var countriesByAreaId = new Dictionary>();
+ foreach (var areaId in areaIds)
+ {
+ var infos = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId);
+ var codes = infos
+ .Select(x => (x.CountryCode ?? string.Empty).Trim().ToUpperInvariant())
+ .Where(code => code.Length == 2)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(code => code, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ countriesByAreaId[areaId] = codes;
+ }
+
var usedGroupCodes = new HashSet(StringComparer.OrdinalIgnoreCase);
var items = filtered.Select(p =>
{
@@ -67,6 +103,11 @@ public partial class SalesProductService
: p.ProductGroup.Trim();
usedGroupCodes.Add(groupCode);
groupNameMap.TryGetValue(groupCode, out var groupName);
+
+ var coverageCountries = ResolvePortalCoverageCountries(p, countriesByAreaId);
+ var destRegions = ResolvePortalDestRegions(p, groupCode, coverageCountries);
+ var cargoTags = ResolvePortalCargoTags(p);
+
return new SalesProductPortalItemOutput
{
Id = p.Id,
@@ -77,7 +118,10 @@ public partial class SalesProductService
MaxTimeliness = p.MaxTimeliness,
TradeTerms = string.IsNullOrWhiteSpace(p.TradeTerms) ? null : p.TradeTerms.Trim().ToUpperInvariant(),
RouteSort = p.RouteSort,
- ProductDescription = p.ProductDescription
+ ProductDescription = p.ProductDescription,
+ DestRegions = destRegions,
+ CoverageCountries = coverageCountries.Take(PortalCoverageCountryLimit).ToList(),
+ CargoTags = cargoTags
};
}).ToList();
@@ -121,10 +165,156 @@ public partial class SalesProductService
});
}
+ var cargos = cargoDictRows
+ .Where(x => !string.IsNullOrWhiteSpace(x.DictCode))
+ .GroupBy(x => x.DictCode!.Trim(), StringComparer.OrdinalIgnoreCase)
+ .Select(g => g.OrderBy(x => x.DictSort).ThenBy(x => x.DictCode).First())
+ .OrderBy(x => x.DictSort)
+ .ThenBy(x => x.DictCode)
+ .Select(x => new SalesProductPortalCargoOutput
+ {
+ Code = x.DictCode!.Trim(),
+ Name = string.IsNullOrWhiteSpace(x.DictName) ? x.DictCode!.Trim() : x.DictName.Trim()
+ })
+ .ToList();
+
return new SalesProductPortalCatalogOutput
{
Groups = groups,
+ Cargos = cargos,
Items = items
};
}
+
+ private static List ResolvePortalCoverageCountries(
+ SalesProduct product,
+ IReadOnlyDictionary> countriesByAreaId)
+ {
+ if (product.PriceAreaId is not > 0)
+ {
+ return [];
+ }
+
+ return countriesByAreaId.TryGetValue(product.PriceAreaId.Value, out var codes)
+ ? codes
+ : [];
+ }
+
+ private static List ResolvePortalDestRegions(
+ SalesProduct product,
+ string groupCode,
+ IReadOnlyCollection coverageCountries)
+ {
+ var regions = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var code in coverageCountries)
+ {
+ if (string.Equals(code, "US", StringComparison.OrdinalIgnoreCase))
+ {
+ regions.Add("us");
+ continue;
+ }
+
+ if (string.Equals(code, "CA", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(code, "MX", StringComparison.OrdinalIgnoreCase))
+ {
+ regions.Add("ca_mx");
+ continue;
+ }
+
+ if (string.Equals(code, "AU", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(code, "NZ", StringComparison.OrdinalIgnoreCase))
+ {
+ regions.Add("au");
+ continue;
+ }
+
+ if (PortalSeaCountryCodes.Contains(code))
+ {
+ regions.Add("sea");
+ continue;
+ }
+
+ if (PortalEuCountryCodes.Contains(code))
+ {
+ regions.Add("eu");
+ }
+ }
+
+ // 国际快递:价格分区跨多区,或分区为空时仍作为全球快递备选
+ var isExpress = string.Equals(groupCode, ExpressProductGroupCode, StringComparison.OrdinalIgnoreCase)
+ || ContainsAnyIgnoreCase(product.ProductName, "DHL", "UPS", "FedEx", "FEDEX", "3PE");
+ if (isExpress && (regions.Count >= 2 || coverageCountries.Count == 0 || coverageCountries.Count >= 12))
+ {
+ regions.Add("global");
+ }
+
+ // 无价格分区时,用产品名做轻量兜底(与官网前端关键词对齐)
+ if (regions.Count == 0)
+ {
+ var blob = $"{product.ProductName} {product.ProductDescription}";
+ if (ContainsAnyIgnoreCase(blob, "美国", "USA", "United States", "美东", "美西")) regions.Add("us");
+ if (ContainsAnyIgnoreCase(blob, "东南", "越南", "马来", "新加坡", "泰国", "菲律宾", "印尼")) regions.Add("sea");
+ if (ContainsAnyIgnoreCase(blob, "欧盟", "欧洲", "英国", "德国", "法国")) regions.Add("eu");
+ if (ContainsAnyIgnoreCase(blob, "加拿大", "墨西哥")) regions.Add("ca_mx");
+ if (ContainsAnyIgnoreCase(blob, "澳洲", "澳大利亚", "新西兰")) regions.Add("au");
+ if (isExpress) regions.Add("global");
+ }
+
+ return regions
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ ///
+ /// 将产品说明映射到字典 cargo_attribute 编码(general / electronics / sensitive)。
+ /// 销售产品本身暂无货型字段,官网先按说明关键词归类,便于筛选项与字典一致。
+ ///
+ private static List ResolvePortalCargoTags(SalesProduct product)
+ {
+ var blob = $"{product.ProductName} {product.ProductDescription}";
+ var tags = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ if (ContainsAnyIgnoreCase(blob, "带电", "电池", "锂电", "3C", "lithium", "battery", "electronics"))
+ {
+ tags.Add("electronics");
+ }
+
+ if (ContainsAnyIgnoreCase(
+ blob,
+ "化妆品",
+ "cosmetic",
+ "敏感",
+ "特殊",
+ "疑难",
+ "超长",
+ "超重",
+ "超大",
+ "oversize",
+ "overweight",
+ "special",
+ "sensitive"))
+ {
+ tags.Add("sensitive");
+ }
+
+ if (ContainsAnyIgnoreCase(blob, "普货", "general cargo", "general"))
+ {
+ tags.Add("general");
+ }
+
+ return tags
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private static bool ContainsAnyIgnoreCase(string? text, params string[] needles)
+ {
+ if (string.IsNullOrWhiteSpace(text) || needles.Length == 0)
+ {
+ return false;
+ }
+
+ return needles.Any(n => text.Contains(n, StringComparison.OrdinalIgnoreCase));
+ }
}
diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs
index 22b9437..14da193 100644
--- a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs
+++ b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs
@@ -29,7 +29,10 @@ public class Customs9610OrderWriter : IScopedDependency
_exportQualificationRepository = exportQualificationRepository;
}
- public async Task UpsertFromOrderAsync(Order order, CancellationToken cancellationToken = default)
+ public async Task UpsertFromOrderAsync(
+ Order order,
+ string? platformCode = null,
+ CancellationToken cancellationToken = default)
{
if (order.Id <= 0)
{
@@ -106,6 +109,13 @@ public class Customs9610OrderWriter : IScopedDependency
ApplyQualification(header, qualification);
+ // 下单传入的电商平台优先;刷新时若未传则保留已有值
+ var normalizedPlatform = Truncate(FirstNonEmpty(platformCode, header.PlatformCode), 16);
+ if (!string.IsNullOrWhiteSpace(normalizedPlatform))
+ {
+ header.PlatformCode = normalizedPlatform;
+ }
+
header.GrossWeight = grossWeight;
header.Currency = FirstNonEmpty(order.DeclaredCurrency, header.Currency, "CNY")!;
header.DestCountry = FirstNonEmpty(order.ConsigneeCountry, header.DestCountry) ?? string.Empty;
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index ab6f286..993df8f 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -142,7 +142,7 @@ public partial class OrderService
// 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。
if (input.SaveAsDraft)
{
- await TryUpsertCustoms9610Async(order);
+ await TryUpsertCustoms9610Async(order, input.PlatformCode);
return await BuildParcelCreateOutputAsync(order);
}
@@ -158,7 +158,7 @@ public partial class OrderService
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
SyncWeightRows(weights, forecast);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
- await TryUpsertCustoms9610Async(order);
+ await TryUpsertCustoms9610Async(order, input.PlatformCode);
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
var priceTierKey = await ResolvePriceTierKeyAsync(order.CustomerId);
@@ -261,12 +261,12 @@ public partial class OrderService
await OrderRepository.DeleteAsync(order);
}
- private async Task TryUpsertCustoms9610Async(Order order)
+ private async Task TryUpsertCustoms9610Async(Order order, string? platformCode = null)
{
try
{
await LazyServiceProvider.GetRequiredService()
- .UpsertFromOrderAsync(order);
+ .UpsertFromOrderAsync(order, platformCode);
}
catch
{
@@ -438,10 +438,22 @@ public partial class OrderService
input.ExportQualificationId = qualification.Id;
}
+
+ if (string.IsNullOrWhiteSpace(input.PlatformCode))
+ {
+ throw new BusinessException("9610汇总申报必须填写电商平台");
+ }
+
+ input.PlatformCode = input.PlatformCode.Trim();
+ if (input.PlatformCode.Length > 16)
+ {
+ throw new BusinessException("电商平台长度不能超过 16");
+ }
}
else
{
input.ExportQualificationId = null;
+ input.PlatformCode = null;
}
if (string.IsNullOrWhiteSpace(input.DeclaredCurrency))
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
index cb8505c..b5be7bd 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs
@@ -113,8 +113,9 @@ public static class OrderTemplateFieldRegistry
Add("length", "长(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
Add("height", "高(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
- Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报;填 9610Summary 时需有默认出口资质或填备案Id");
+ Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报;填 9610Summary 时需有默认出口资质或填备案Id,并填电商平台");
Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案");
+ Add("platformCode", "电商平台", false, example: "Amazon", hint: "9610汇总申报时必填,写入海关申报电商平台");
Add("receiver.recipientsName", "收件人姓名", true, group: "receiver", example: "John Smith", hint: "必填");
Add("receiver.phone", "收件人手机", true, group: "receiver", example: "13900139000", hint: "必填");
@@ -168,8 +169,9 @@ public static class OrderTemplateFieldRegistry
Add("clientNo", "客户单号", true, example: "C202604230001", hint: "您自己的订单编号,同一客户不可重复");
Add("productCode", "运输方式", true, "select", "sales_product", "basic", "PROD001", hint: "从下拉选择产品编码");
Add("cargoAttribute", "货物属性", true, "select", "cargo_attribute", "basic", "general", hint: "普货选 general");
- Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报;填 9610Summary 时需有默认出口资质或填备案Id");
+ Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报;填 9610Summary 时需有默认出口资质或填备案Id,并填电商平台");
Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案");
+ Add("platformCode", "电商平台", false, example: "Amazon", hint: "9610汇总申报时必填,写入海关申报电商平台");
Add("warehouseId", "发货仓库", true, "select", "warehouse", "basic", "1", hint: "从下拉选择仓库 ID");
Add("length", "长(cm)", true, "number", example: "10", hint: "必填,材积计费用");
Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,材积计费用");
--
2.45.2
From 8226542ffd1dd9eff1ed76a3d4b3a3692425d6de Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Mon, 28 Sep 2026 14:33:52 +0800
Subject: [PATCH 07/41] =?UTF-8?q?fix:=209610=E9=A2=84=E6=8A=A5=E4=B8=8B?=
=?UTF-8?q?=E6=B8=B8=E7=BD=91=E5=85=B3=E5=9C=B0=E5=9D=80=E6=94=B9=E4=B8=BA?=
=?UTF-8?q?=E5=8F=96=E8=87=AA=20api=5Fcenter=20=E9=85=8D=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
.../Services/Orders/Customs9610OrderService.cs | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
index 57b3d84..7a6a3a7 100644
--- a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
+++ b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
@@ -242,7 +242,7 @@ public class Customs9610OrderService : ApplicationService
}
///
- /// 预报下游(写死 create9610Order / BH 网关地址)。
+ /// 预报下游(create9610Order / BH;网关地址取自其他配置 api_center)。
///
[HttpPost("/order/customs-9610/fetch-declaration-form-batch")]
public async Task FetchDeclarationFormBatchAsync([FromBody] Customs9610IdsInput input)
@@ -274,10 +274,10 @@ public class Customs9610OrderService : ApplicationService
}
///
- /// 写死的 9610 预报下游地址:create9610Order + supplierCode=BH。
+ /// 9610 预报下游标准码 / 供应商码(网关根地址由 api_center.BaseUrl 拼装)。
///
- private const string Create9610OrderForwardUrl =
- "https://api.ideship.com/itfx/InterfaceForward/ForwardWithSubscribe?standardCode=create9610Order&supplierCode=BH";
+ private const string Create9610OrderStandardCode = "create9610Order";
+ private const string Create9610OrderSupplierCode = "BH";
private async Task DownstreamForwardOneAsync(int id)
{
@@ -303,8 +303,10 @@ public class Customs9610OrderService : ApplicationService
ItfxForwardResult forward;
try
{
- forward = await ItfxForwardClient.ForwardToAbsoluteUrlAsync(
- Create9610OrderForwardUrl,
+ // BaseUrl 来自其他配置 api_center(ItfxForwardClient 内部解析)
+ forward = await ItfxForwardClient.ForwardWithSubscribeAsync(
+ Create9610OrderStandardCode,
+ Create9610OrderSupplierCode,
creds.AppKey,
creds.AppSecret,
body);
@@ -322,7 +324,6 @@ public class Customs9610OrderService : ApplicationService
Status = header.Status,
ServiceNo = header.ServiceNo,
ClientNo = header.ClientNo,
- Url = Create9610OrderForwardUrl,
};
}
--
2.45.2
From 3bde909238f9d82582b4a309fdff8fa0edad5df3 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Mon, 28 Sep 2026 16:58:00 +0800
Subject: [PATCH 08/41] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=A7?=
=?UTF-8?q?=E5=8C=85=E8=BD=A8=E8=BF=B9=E5=90=8C=E6=AD=A5=E4=B8=8B=E5=8F=91?=
=?UTF-8?q?=20apiCode?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
.../SmallBagTrack/PickupBigBagTrackSyncer.cs | 45 ++++++++++++++++---
.../SmallBagTrack/SmallBagTrackOrderClient.cs | 3 +-
.../SmallBagTrackUpsertBigBagApiRequest.cs | 2 +
.../SmallBagTrackUpsertBigBagRequest.cs | 5 +++
4 files changed, 48 insertions(+), 7 deletions(-)
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
index 7081b08..edffb5b 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
+using Oms.Application.Integrations.Itfx;
+using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
@@ -45,6 +47,8 @@ internal sealed class PickupBigBagTrackSyncer(
IPickupOrderLineRepository pickupOrderLineRepository,
ISalesProductRepository salesProductRepository,
IServiceChannelRepository serviceChannelRepository,
+ IServiceChannelGroupRepository serviceChannelGroupRepository,
+ IItfxMetadataClient itfxMetadataClient,
IHttpContextAccessor httpContextAccessor,
ILogger logger) : IPickupBigBagTrackSyncer
{
@@ -83,7 +87,7 @@ internal sealed class PickupBigBagTrackSyncer(
try
{
- var (channel, product) = await ResolveChannelProductAsync(pickup.CarrierService, cancellationToken);
+ var (channel, product, apiCode) = await ResolveChannelProductAsync(pickup.CarrierService, cancellationToken);
var country = ResolveCountry(pickup);
// 1) UpsertBigBag:client 取 product(销售产品编码)
@@ -96,6 +100,7 @@ internal sealed class PickupBigBagTrackSyncer(
Channel = channel,
Product = product,
Country = country,
+ ApiCode = apiCode,
Authorization = authorization
},
cancellationToken);
@@ -215,20 +220,20 @@ internal sealed class PickupBigBagTrackSyncer(
}
}
- private async Task<(string? Channel, string? Product)> ResolveChannelProductAsync(
+ private async Task<(string? Channel, string? Product, string? ApiCode)> ResolveChannelProductAsync(
string? carrierService,
CancellationToken cancellationToken)
{
var salesProductCode = carrierService?.Trim();
if (string.IsNullOrWhiteSpace(salesProductCode))
{
- return (null, null);
+ return (null, null, null);
}
var product = await salesProductRepository.GetByProductCodeAsync(salesProductCode, cancellationToken);
if (product == null)
{
- return (null, salesProductCode);
+ return (null, salesProductCode, null);
}
var providers = await salesProductRepository.GetAllocationProvidersAsync(product.Id, cancellationToken);
@@ -239,14 +244,42 @@ internal sealed class PickupBigBagTrackSyncer(
.FirstOrDefault();
if (channelId <= 0)
{
- return (null, product.ProductCode);
+ return (null, product.ProductCode, null);
}
var channel = await serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId, cancellationToken);
var channelCode = channel?.ChannelCode?.Trim();
+ var apiCode = channel == null
+ ? null
+ : await ResolveApiCodeFromServiceChannelAsync(channel, cancellationToken);
return (
string.IsNullOrWhiteSpace(channelCode) ? null : channelCode,
- product.ProductCode);
+ product.ProductCode,
+ apiCode);
+ }
+
+ private async Task ResolveApiCodeFromServiceChannelAsync(
+ ServiceChannel channel,
+ CancellationToken cancellationToken)
+ {
+ if (!channel.GroupId.HasValue || channel.GroupId.Value <= 0)
+ {
+ return null;
+ }
+
+ var group = await serviceChannelGroupRepository.FirstOrDefaultAsync(
+ x => x.Id == channel.GroupId.Value,
+ cancellationToken);
+ if (group == null || group.ApiServiceProviderId <= 0)
+ {
+ return null;
+ }
+
+ var serviceProvider = await itfxMetadataClient.GetServiceProviderAsync(
+ group.ApiServiceProviderId,
+ cancellationToken);
+ var code = serviceProvider?.Code?.Trim();
+ return string.IsNullOrWhiteSpace(code) ? null : code;
}
private static string ResolveCountry(PickupOrder pickup)
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
index b6e5077..ecb80a1 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
@@ -260,7 +260,8 @@ internal sealed class SmallBagTrackOrderClient(
OtherNo = request.OtherNo?.Trim() ?? string.Empty,
Channel = request.Channel?.Trim() ?? string.Empty,
Product = request.Product?.Trim() ?? string.Empty,
- Country = request.Country?.Trim() ?? string.Empty
+ Country = request.Country?.Trim() ?? string.Empty,
+ ApiCode = request.ApiCode?.Trim() ?? string.Empty
};
var url = SmallBagTrackUpsertBigBagApiRequest.BuildEndpointUrl(settings.BaseUrl);
var authorization = ResolveAuthorization(request.Authorization);
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs
index 72e1c00..a694b0c 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs
@@ -17,6 +17,8 @@ internal sealed class SmallBagTrackUpsertBigBagApiRequest
public string Country { get; set; } = string.Empty;
+ public string ApiCode { get; set; } = string.Empty;
+
public static string BuildEndpointUrl(string baseUrl) =>
$"{baseUrl.Trim().TrimEnd('/')}/ProviderService/Order/UpsertBigBag";
}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs
index 3635ec7..9ac9c1a 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs
@@ -17,5 +17,10 @@ public class SmallBagTrackUpsertBigBagRequest
public string? Country { get; set; }
+ ///
+ /// API 服务商 code(轨迹拉取配置 ServiceProviderCode)。
+ ///
+ public string? ApiCode { get; set; }
+
public string? Authorization { get; set; }
}
--
2.45.2
From 28366010626b54cd787f49cb4be1d36d223556b2 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Tue, 29 Sep 2026 12:17:04 +0800
Subject: [PATCH 09/41] chore: retrigger test deploy for 9610 downstream
forward
Co-authored-by: Cursor
--
2.45.2
From 24b8c29c9b261b7bc7531038141f6f60a47c75d4 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Tue, 29 Sep 2026 14:50:27 +0800
Subject: [PATCH 10/41] =?UTF-8?q?fix:=209610=E9=A2=84=E6=8A=A5=E4=B8=8B?=
=?UTF-8?q?=E6=B8=B8=E5=9B=9E=E5=86=99=E4=B8=8B=E6=B8=B8=E5=8D=95=E5=8F=B7?=
=?UTF-8?q?=E5=85=BC=E5=AE=B9=20serviceNo/orderNo?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
.../Orders/Customs9610OrderService.cs | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
index 7a6a3a7..2e45bea 100644
--- a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
+++ b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
@@ -982,12 +982,27 @@ public class Customs9610OrderService : ApplicationService
if (root.TryGetProperty("data", out var data) || root.TryGetProperty("Data", out data))
{
customsOrderNo = ReadString(data, "orderNo", "OrderNo", "customsOrderNo", "CustomsOrderNo");
- referenceNo = ReadString(data, "referenceNo", "ReferenceNo");
+ // 下游单号:优先 serviceNo;兼容 referenceNo;BH create9610Order 常回 orderNo
+ referenceNo = ReadString(
+ data,
+ "serviceNo",
+ "ServiceNo",
+ "referenceNo",
+ "ReferenceNo",
+ "orderNo",
+ "OrderNo");
}
else
{
customsOrderNo = ReadString(root, "orderNo", "OrderNo", "customsOrderNo", "CustomsOrderNo");
- referenceNo = ReadString(root, "referenceNo", "ReferenceNo");
+ referenceNo = ReadString(
+ root,
+ "serviceNo",
+ "ServiceNo",
+ "referenceNo",
+ "ReferenceNo",
+ "orderNo",
+ "OrderNo");
}
}
catch
--
2.45.2
From 5fb6888b5574a91e061d66b5e65eeee554e7edaf Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Tue, 29 Sep 2026 19:19:24 +0800
Subject: [PATCH 11/41] =?UTF-8?q?feat:=20=E6=8F=BD=E6=94=B6=E6=89=BF?=
=?UTF-8?q?=E8=BF=90=E6=8E=A8=E9=80=81=E4=BB=B6=E9=87=8D=E5=B0=BA=E5=B9=B6?=
=?UTF-8?q?=E6=8C=89=E5=8D=96=E4=BB=B7=E4=B9=B0=E4=BB=B7=E5=87=BA=E8=B4=A6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
db.sql | 9 +
sql/migrate_pickup_fee_push.sql | 11 +
.../Dtos/Orders/PickupOrderDtos.cs | 103 ++++
.../Services/Orders/PickupOpenApiService.cs | 76 +++
.../Orders/PickupOrderFeePushService.cs | 515 ++++++++++++++++++
.../Services/Orders/PickupOrderService.cs | 9 +
.../System/SystemOtherConfigService.cs | 7 +
.../OrdersAggregate/Entities/PickupOrder.cs | 48 ++
.../Consts/SystemOtherConfigCodes.cs | 13 +-
9 files changed, 787 insertions(+), 4 deletions(-)
create mode 100644 sql/migrate_pickup_fee_push.sql
create mode 100644 src/Oms.Application/Services/Orders/PickupOpenApiService.cs
create mode 100644 src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
diff --git a/db.sql b/db.sql
index a800793..876be41 100644
--- a/db.sql
+++ b/db.sql
@@ -1907,6 +1907,15 @@ CREATE TABLE `t_pickup_order` (
`ParcelCount` INT NOT NULL DEFAULT 0 COMMENT '关联票数',
`TotalActualWeight` DECIMAL(18,3) NULL COMMENT '汇总实重',
`ChargeableWeight` DECIMAL(18,3) NULL COMMENT '计费重',
+ `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重',
+ `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON',
+ `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)',
+ `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)',
+ `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)',
+ `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文',
+ `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON',
+ `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON',
+ `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间',
`Currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '币种',
`FreightAmount` DECIMAL(18,2) NULL COMMENT '对客揽收运费',
`OtherFeeAmount` DECIMAL(18,2) NULL COMMENT '对客其他费',
diff --git a/sql/migrate_pickup_fee_push.sql b/sql/migrate_pickup_fee_push.sql
new file mode 100644
index 0000000..539f2a0
--- /dev/null
+++ b/sql/migrate_pickup_fee_push.sql
@@ -0,0 +1,11 @@
+-- 揽收单:承运推送件重尺 + 系统卖价/买价费用快照
+ALTER TABLE `t_pickup_order`
+ ADD COLUMN `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重' AFTER `ChargeableWeight`,
+ ADD COLUMN `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON' AFTER `VolumetricWeight`,
+ ADD COLUMN `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)' AFTER `PackageSizesJson`,
+ ADD COLUMN `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)' AFTER `CarrierFreightWeight`,
+ ADD COLUMN `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)' AFTER `CarrierQuotedFreightAmount`,
+ ADD COLUMN `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文' AFTER `CarrierQuotedReceivableAmount`,
+ ADD COLUMN `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON' AFTER `CarrierQuoteJson`,
+ ADD COLUMN `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON' AFTER `ReceivableFeeJson`,
+ ADD COLUMN `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间' AFTER `PayableFeeJson`;
diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
index 0706a9f..b046a71 100644
--- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
@@ -273,6 +273,24 @@ public class PickupOrderOutput
public decimal? ChargeableWeight { get; set; }
+ public decimal? VolumetricWeight { get; set; }
+
+ public List PackageSizes { get; set; } = [];
+
+ public decimal? CarrierFreightWeight { get; set; }
+
+ public decimal? CarrierQuotedFreightAmount { get; set; }
+
+ public decimal? CarrierQuotedReceivableAmount { get; set; }
+
+ public PickupCarrierQuoteOutput? CarrierQuote { get; set; }
+
+ public List ReceivableFees { get; set; } = [];
+
+ public List PayableFees { get; set; } = [];
+
+ public DateTime? FeePushedAt { get; set; }
+
public string Currency { get; set; } = "CNY";
public decimal? FreightAmount { get; set; }
@@ -303,3 +321,88 @@ public class PickupOrderOutput
public List Rejected { get; set; } = [];
}
+
+public class PickupPackageSizeOutput
+{
+ public decimal Length { get; set; }
+
+ public decimal Width { get; set; }
+
+ public decimal Height { get; set; }
+
+ public int Count { get; set; }
+}
+
+public class PickupFeeLineOutput
+{
+ public string CostItem { get; set; } = string.Empty;
+
+ public decimal Amount { get; set; }
+
+ public string Currency { get; set; } = string.Empty;
+}
+
+public class PickupCarrierQuoteOutput
+{
+ public string? ServiceMode { get; set; }
+
+ public string? PayMode { get; set; }
+
+ public List ValueInfoList { get; set; } = [];
+}
+
+public class PickupCarrierValueFeeOutput
+{
+ public string? FeeCode { get; set; }
+
+ public string? FeeName { get; set; }
+
+ public decimal? FeeAmount { get; set; }
+}
+
+public class PickupOpenApiEnvelope
+{
+ public string Code { get; set; } = "200";
+
+ public string Msg { get; set; } = "成功";
+
+ public PickupOpenApiPushFeeResult? Data { get; set; }
+}
+
+public class PickupOpenApiPushFeeResult
+{
+ public string PickupNo { get; set; } = string.Empty;
+
+ public decimal ActualWeight { get; set; }
+
+ public decimal ChargeableWeight { get; set; }
+
+ public decimal? VolumetricWeight { get; set; }
+
+ public decimal ReceivableAmount { get; set; }
+
+ public decimal PayableAmount { get; set; }
+
+ public decimal? CarrierQuotedFreightAmount { get; set; }
+}
+
+public class PickupOpenApiPushFeeInput
+{
+ public string? WaybillNumber { get; set; }
+
+ public decimal ActualWeight { get; set; }
+
+ public decimal? FreightWeight { get; set; }
+
+ public decimal? TotalFreightAmount { get; set; }
+
+ public decimal? ReceivableAmount { get; set; }
+
+ public List? ValueInfoList { get; set; }
+
+ public List? GoodsSizeList { get; set; }
+
+ public string? ServiceMode { get; set; }
+
+ public string? PayMode { get; set; }
+}
diff --git a/src/Oms.Application/Services/Orders/PickupOpenApiService.cs b/src/Oms.Application/Services/Orders/PickupOpenApiService.cs
new file mode 100644
index 0000000..a5a2dd4
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/PickupOpenApiService.cs
@@ -0,0 +1,76 @@
+using Fake.Application;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Orders;
+
+namespace Oms.Application.Services.Orders;
+
+///
+/// 国内揽收开放接口(承运商推送件重尺)。
+///
+[ApiExplorerSettings(GroupName = "OMS-Order")]
+public class PickupOpenApiService : ApplicationService
+{
+ private PickupOrderFeePushService FeePush =>
+ LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 开放平台路由:method=open.api.openCommon.pushFee,仅校验 appKey。
+ ///
+ [AllowAnonymous]
+ [HttpPost("/openapi")]
+ public Task InvokeAsync(
+ [FromQuery] string? appKey,
+ [FromQuery] string? method,
+ [FromBody] PickupOpenApiPushFeeInput? body) =>
+ HandlePushFeeAsync(appKey, method, body);
+
+ ///
+ /// 推送揽收重量/尺寸并按当前产品计算应收应付。
+ ///
+ [AllowAnonymous]
+ [HttpPost("/openapi/openCommon/pushFee")]
+ public Task PushFeeAsync(
+ [FromQuery] string? appKey,
+ [FromBody] PickupOpenApiPushFeeInput? body) =>
+ HandlePushFeeAsync(appKey, PickupOrderFeePushService.PushFeeMethod, body);
+
+ private async Task HandlePushFeeAsync(
+ string? appKey,
+ string? method,
+ PickupOpenApiPushFeeInput? body)
+ {
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(method)
+ && !string.Equals(method.Trim(), PickupOrderFeePushService.PushFeeMethod, StringComparison.OrdinalIgnoreCase))
+ {
+ return Fail("400", $"不支持的 method:{method}");
+ }
+
+ var data = await FeePush.PushFeeAsync(appKey, body);
+ return new PickupOpenApiEnvelope
+ {
+ Code = "200",
+ Msg = "成功",
+ Data = data
+ };
+ }
+ catch (BusinessException ex)
+ {
+ return Fail("400", ex.Message);
+ }
+ catch (Exception ex)
+ {
+ return Fail("500", string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message);
+ }
+ }
+
+ private static PickupOpenApiEnvelope Fail(string code, string msg) =>
+ new()
+ {
+ Code = code,
+ Msg = msg,
+ Data = null
+ };
+}
diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
new file mode 100644
index 0000000..73dbafa
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
@@ -0,0 +1,515 @@
+using Fake.Application;
+using Fake.DependencyInjection;
+using Oms.Application.Dtos.Channels;
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Integrations.Itfx;
+using Oms.Application.Services.Orders.Issues;
+using Oms.Application.Shared;
+using Oms.Domain.ChannelsAggregate.Entities;
+using Oms.Domain.ChannelsAggregate.Repositories;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+using Oms.Domain.SystemAggregate.Consts;
+using Oms.Domain.SystemAggregate.Repositories;
+
+namespace Oms.Application.Services.Orders;
+
+///
+/// 承运推送揽收件重尺后,按当前销售产品卖价/买价出账。
+///
+public class PickupOrderFeePushService : IScopedDependency
+{
+ public const string PushFeeMethod = "open.api.openCommon.pushFee";
+
+ private readonly IPickupOrderRepository _pickupOrderRepository;
+ private readonly ISystemOtherConfigRepository _otherConfigRepository;
+ private readonly ISalesProductRepository _salesProductRepository;
+ private readonly IServiceChannelRepository _serviceChannelRepository;
+ private readonly IServiceChannelGroupRepository _serviceChannelGroupRepository;
+ private readonly IWeightRuleRepository _weightRuleRepository;
+ private readonly IWeightRoundingRepository _weightRoundingRepository;
+ private readonly IKpiAreaRepository _kpiAreaRepository;
+ private readonly IWarehouseAddressRepository _warehouseAddressRepository;
+ private readonly IItfxMetadataClient _itfxMetadataClient;
+
+ public PickupOrderFeePushService(
+ IPickupOrderRepository pickupOrderRepository,
+ ISystemOtherConfigRepository otherConfigRepository,
+ ISalesProductRepository salesProductRepository,
+ IServiceChannelRepository serviceChannelRepository,
+ IServiceChannelGroupRepository serviceChannelGroupRepository,
+ IWeightRuleRepository weightRuleRepository,
+ IWeightRoundingRepository weightRoundingRepository,
+ IKpiAreaRepository kpiAreaRepository,
+ IWarehouseAddressRepository warehouseAddressRepository,
+ IItfxMetadataClient itfxMetadataClient)
+ {
+ _pickupOrderRepository = pickupOrderRepository;
+ _otherConfigRepository = otherConfigRepository;
+ _salesProductRepository = salesProductRepository;
+ _serviceChannelRepository = serviceChannelRepository;
+ _serviceChannelGroupRepository = serviceChannelGroupRepository;
+ _weightRuleRepository = weightRuleRepository;
+ _weightRoundingRepository = weightRoundingRepository;
+ _kpiAreaRepository = kpiAreaRepository;
+ _warehouseAddressRepository = warehouseAddressRepository;
+ _itfxMetadataClient = itfxMetadataClient;
+ }
+
+ public async Task PushFeeAsync(string? appKey, PickupOpenApiPushFeeInput? input)
+ {
+ var key = (appKey ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ throw new BusinessException("appKey 不能为空");
+ }
+
+ if (input == null)
+ {
+ throw new BusinessException("请求体不能为空");
+ }
+
+ var pickupNo = (input.WaybillNumber ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(pickupNo))
+ {
+ throw new BusinessException("waybillNumber 不能为空");
+ }
+
+ if (input.ActualWeight <= 0)
+ {
+ throw new BusinessException("actualWeight 必须大于 0");
+ }
+
+ var sizes = NormalizeSizes(input.GoodsSizeList);
+ if (sizes.Count == 0)
+ {
+ throw new BusinessException("goodsSizeList 不能为空");
+ }
+
+ var pickup = await _pickupOrderRepository.GetByPickupNoAsync(pickupNo)
+ ?? throw new BusinessException("揽收单不存在");
+
+ await EnsureAppKeyAsync(key, pickup);
+
+ if (pickup.PayStatus == PickupPayStatusCodes.Paid)
+ {
+ throw new BusinessException("揽收单已支付,拒绝修改件重尺与费用");
+ }
+
+ if (PickupOrderStatusCodes.IsTerminal(pickup.Status)
+ && pickup.Status != PickupOrderStatusCodes.Inbound)
+ {
+ throw new BusinessException("当前揽收单状态不可接收件重尺");
+ }
+
+ if (string.IsNullOrWhiteSpace(pickup.CarrierService))
+ {
+ throw new BusinessException("揽收单未选择承运服务(销售产品),无法计费");
+ }
+
+ var product = await _salesProductRepository.GetByProductCodeAsync(pickup.CarrierService.Trim())
+ ?? throw new BusinessException($"未找到承运服务对应销售产品「{pickup.CarrierService}」");
+
+ var totalVolume = sizes.Sum(x => x.Length * x.Width * x.Height * x.Count);
+ var weightRow = new OrderWeight
+ {
+ Type = OrderWeightTypeCodes.Receive,
+ Length = totalVolume,
+ Width = 1,
+ Height = 1,
+ ActualWeight = input.ActualWeight,
+ Cubic = ParcelBillingCalculator.CalculateCubic(totalVolume, 1, 1)
+ };
+
+ decimal volumetricWeight;
+ decimal chargeableWeight;
+ try
+ {
+ (volumetricWeight, chargeableWeight) = await CalculateChargeableAsync(product, weightRow);
+ }
+ catch (InvalidOperationException ex)
+ {
+ throw new BusinessException($"计费重计算失败:{ex.Message}");
+ }
+
+ var countryCode = string.IsNullOrWhiteSpace(pickup.SenderCountryCode)
+ ? "CN"
+ : pickup.SenderCountryCode.Trim().ToUpperInvariant();
+ var priceSnapshot = await BuildSellPriceSnapshotAsync(product);
+ var zoneKeys = ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
+ priceSnapshot,
+ countryCode,
+ pickup.SenderProvince,
+ pickup.SenderCity,
+ pickup.SenderZipCode);
+ if (zoneKeys == null || zoneKeys.Count == 0)
+ {
+ throw new BusinessException("卖价分区未匹配,请检查揽收地址与产品价格分区");
+ }
+
+ var warehouse = await _warehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == pickup.WarehouseId);
+ var warehouseName = warehouse?.WarehouseName;
+ var priceVersion = DateTime.Now.ToString("yyyyMMddHHmmss");
+ var priceEffectiveTime = DateTime.Now;
+ var cargoAttribute = CargoAttributeCodes.General;
+ var conditionContext = OrderRuleEvaluationContext.FromOrderOnly(new Order
+ {
+ ProductCode = product.ProductCode,
+ ConsigneeCountry = countryCode,
+ CargoAttribute = cargoAttribute,
+ WarehouseId = pickup.WarehouseId,
+ CustomerId = pickup.CustomerId
+ });
+
+ var receivableLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
+ priceSnapshot,
+ priceVersion,
+ priceEffectiveTime,
+ chargeableWeight,
+ zoneKeys,
+ cargoAttribute,
+ priceTierKey: null,
+ pickup.WarehouseId,
+ warehouseName,
+ conditionContext);
+
+ if (receivableLines.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(priceSnapshot))
+ {
+ var detail = ParcelBillingCalculator.ExplainQuotePriceMiss(
+ priceSnapshot,
+ chargeableWeight,
+ zoneKeys,
+ cargoAttribute,
+ null,
+ pickup.WarehouseId,
+ warehouseName);
+ throw new BusinessException($"卖价未匹配到有效价格:{detail}");
+ }
+
+ var payable = await CalculatePayableAsync(
+ product,
+ pickup,
+ zoneKeys,
+ chargeableWeight,
+ cargoAttribute,
+ warehouseName,
+ priceVersion,
+ priceEffectiveTime,
+ conditionContext);
+
+ var receivableFees = receivableLines.Select(x => ToFeeLine(x.Fee)).ToList();
+ var payableFees = payable.Select(ToFeeLine).ToList();
+ var receivableTotal = receivableFees.Sum(x => x.Amount);
+ var payableTotal = payableFees.Sum(x => x.Amount);
+ var freight = receivableFees.FirstOrDefault()?.Amount ?? receivableTotal;
+ var other = Math.Max(0, receivableTotal - freight);
+
+ pickup.TotalActualWeight = input.ActualWeight;
+ pickup.VolumetricWeight = volumetricWeight;
+ pickup.ChargeableWeight = chargeableWeight;
+ pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes);
+ pickup.CarrierFreightWeight = input.FreightWeight;
+ pickup.CarrierQuotedFreightAmount = input.TotalFreightAmount;
+ pickup.CarrierQuotedReceivableAmount = input.ReceivableAmount;
+ pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput
+ {
+ ServiceMode = input.ServiceMode,
+ PayMode = input.PayMode,
+ ValueInfoList = input.ValueInfoList ?? []
+ });
+ pickup.ReceivableFeeJson = OmsDocHelper.SerializeJson(receivableFees);
+ pickup.PayableFeeJson = OmsDocHelper.SerializeJson(payableFees);
+ pickup.FreightAmount = freight;
+ pickup.OtherFeeAmount = other;
+ pickup.ReceivableAmount = receivableTotal;
+ pickup.CarrierPayableAmount = payableTotal;
+ pickup.FeePushedAt = DateTime.Now;
+ pickup.UpdateTime = DateTime.Now;
+ await _pickupOrderRepository.UpdateAsync(pickup);
+
+ return new PickupOpenApiPushFeeResult
+ {
+ PickupNo = pickup.PickupNo,
+ ActualWeight = input.ActualWeight,
+ ChargeableWeight = chargeableWeight,
+ VolumetricWeight = volumetricWeight,
+ ReceivableAmount = receivableTotal,
+ PayableAmount = payableTotal,
+ CarrierQuotedFreightAmount = input.TotalFreightAmount
+ };
+ }
+
+ private async Task EnsureAppKeyAsync(string appKey, PickupOrder pickup)
+ {
+ var config = await _otherConfigRepository.GetByConfigCodeAsync(SystemOtherConfigCodes.PickupOpenApi);
+ if (config != null && config.Enabled && !string.IsNullOrWhiteSpace(config.AppKey))
+ {
+ if (!string.Equals(config.AppKey.Trim(), appKey, StringComparison.Ordinal))
+ {
+ throw new BusinessException("appKey 不正确");
+ }
+
+ return;
+ }
+
+ var expected = await ResolvePickupAppKeyAsync(pickup);
+ if (string.IsNullOrWhiteSpace(expected)
+ || !string.Equals(expected, appKey, StringComparison.Ordinal))
+ {
+ throw new BusinessException("appKey 不正确");
+ }
+ }
+
+ private async Task ResolvePickupAppKeyAsync(PickupOrder pickup)
+ {
+ if (string.IsNullOrWhiteSpace(pickup.CarrierService))
+ {
+ return null;
+ }
+
+ var product = await _salesProductRepository.GetByProductCodeAsync(pickup.CarrierService.Trim());
+ if (product == null)
+ {
+ return null;
+ }
+
+ var providers = await _salesProductRepository.GetAllocationProvidersAsync(product.Id);
+ var channelId = providers
+ .Where(x => x.ServiceChannelId is > 0)
+ .OrderBy(x => x.SerialNumber)
+ .Select(x => x.ServiceChannelId!.Value)
+ .FirstOrDefault();
+ if (channelId <= 0)
+ {
+ return null;
+ }
+
+ var channel = await _serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId);
+ if (channel?.GroupId is not > 0)
+ {
+ return null;
+ }
+
+ var group = await _serviceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == channel.GroupId.Value);
+ if (group == null || group.AppId <= 0)
+ {
+ return null;
+ }
+
+ var app = await _itfxMetadataClient.GetAppAsync(group.AppId);
+ return app?.AppKey?.Trim();
+ }
+
+ private async Task<(decimal VolumetricWeight, decimal ChargeableWeight)> CalculateChargeableAsync(
+ SalesProduct product,
+ OrderWeight weight)
+ {
+ if (product.WeightRuleId is not > 0)
+ {
+ throw new BusinessException("未配置计重规则");
+ }
+
+ var ruleInfos = await _weightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value);
+ var (volumetric, raw) = ParcelBillingCalculator.CalculateRawBillableWeight(weight, ruleInfos);
+ if (product.WeightRoundingId is not > 0)
+ {
+ throw new BusinessException("未配置计重进位");
+ }
+
+ var ranges = await _weightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value);
+ var chargeable = ParcelBillingCalculator.ApplyRounding(raw, ranges);
+ return (volumetric, chargeable);
+ }
+
+ private async Task BuildSellPriceSnapshotAsync(SalesProduct product)
+ {
+ var snapshot = new SalesProductPricePublishSnapshotDto
+ {
+ SalesProductId = product.Id,
+ Prices = (await GetSellPriceOutputsAsync(product.Id)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
+ };
+ await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, _kpiAreaRepository);
+ return snapshot;
+ }
+
+ private async Task> GetSellPriceOutputsAsync(int salesProductId)
+ {
+ var prices = await _salesProductRepository.GetPricesAsync(salesProductId);
+ var priceIds = prices.Select(x => x.Id).ToArray();
+ var factorMap = (await _salesProductRepository.GetPriceFactorsAsync(priceIds))
+ .GroupBy(x => x.PriceId)
+ .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList());
+ var detailMap = (await _salesProductRepository.GetPriceDetailsAsync(priceIds))
+ .GroupBy(x => x.PriceId)
+ .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
+
+ return prices.Select(price => new SalesProductPriceListOutput
+ {
+ Id = price.Id,
+ SalesProductId = price.SalesProductId,
+ SerialNumber = price.SerialNumber,
+ Status = (byte)price.Status,
+ PriceName = price.PriceName,
+ CostItem = price.CostItem,
+ CostUnit = price.CostUnit,
+ Currency = price.Currency,
+ ChargingCondition = price.ChargingCondition,
+ Factors = factorMap.TryGetValue(price.Id, out var factors)
+ ? factors.Select(f => new ChannelPriceFactorOutput
+ {
+ Id = f.Id,
+ PriceId = f.PriceId,
+ SerialNumber = f.SerialNumber,
+ Factor = f.Factor,
+ FactorValues = OmsDocHelper.DeserializeJson>(f.FactorValues) ?? []
+ }).ToList()
+ : [],
+ Details = detailMap.TryGetValue(price.Id, out var details)
+ ? details.Select(d => new ChannelPriceDetailOutput
+ {
+ Id = d.Id,
+ PriceId = d.PriceId,
+ FactorValuesJson = OmsDocHelper.DeserializeJsonObject(d.FactorValuesJson),
+ CalculationFormula = d.CalculationFormula,
+ CalculatedValue = d.CalculatedValue,
+ CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(d.CustomerGroupValues)
+ }).ToList()
+ : []
+ }).ToList();
+ }
+
+ private async Task> CalculatePayableAsync(
+ SalesProduct product,
+ PickupOrder pickup,
+ IReadOnlyList zoneKeys,
+ decimal chargeableWeight,
+ string cargoAttribute,
+ string? warehouseName,
+ string priceVersion,
+ DateTime priceEffectiveTime,
+ OrderRuleEvaluationContext conditionContext)
+ {
+ var providers = await _salesProductRepository.GetAllocationProvidersAsync(product.Id);
+ var channelIds = providers.Where(x => x.ServiceChannelId is > 0).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray();
+ var channels = channelIds.Length == 0
+ ? []
+ : await _serviceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id));
+ var channelNameMap = channels.ToDictionary(x => x.Id, x => x.ChannelName);
+ var snapshots = providers.Select(provider => new SalesProductAllocationProviderSnapshotDto
+ {
+ Id = provider.Id,
+ SalesProductId = provider.SalesProductId,
+ SerialNumber = provider.SerialNumber,
+ ProviderName = provider.ProviderName,
+ ServiceChannelId = provider.ServiceChannelId,
+ ServiceChannelName = provider.ServiceChannelId is > 0
+ && channelNameMap.TryGetValue(provider.ServiceChannelId.Value, out var name)
+ ? name
+ : null,
+ Weight = provider.Weight,
+ CustomerIds = OmsDocHelper.DeserializeJson>(provider.CustomerIds) ?? [],
+ MatchCondition = provider.MatchCondition
+ }).ToList();
+
+ var filtered = AllocationResolver.FilterProvidersDetailed(snapshots, pickup.CustomerId, conditionContext).Passed;
+ var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0);
+ if (provider?.ServiceChannelId is not > 0)
+ {
+ throw new BusinessException("未配置可用排货服务渠道,无法计算应付");
+ }
+
+ var channelPrices = await GetChannelPriceOutputsAsync(provider.ServiceChannelId.Value);
+ if (channelPrices.Count == 0)
+ {
+ throw new BusinessException($"服务渠道 {provider.ServiceChannelId} 无买价");
+ }
+
+ var payable = ParcelBillingCalculator.CalculatePayableFees(
+ channelPrices,
+ priceVersion,
+ priceEffectiveTime,
+ chargeableWeight,
+ zoneKeys,
+ cargoAttribute,
+ OrderFeeChargingStageCodes.Forecast,
+ pickup.WarehouseId,
+ warehouseName,
+ conditionContext);
+
+ if (payable.Count == 0)
+ {
+ throw new BusinessException($"服务渠道 {provider.ServiceChannelId} 买价未匹配");
+ }
+
+ return payable;
+ }
+
+ private async Task> GetChannelPriceOutputsAsync(int serviceChannelId)
+ {
+ var prices = await _serviceChannelRepository.GetPricesAsync(serviceChannelId);
+ var priceIds = prices.Select(x => x.Id).ToArray();
+ var factorMap = (await _serviceChannelRepository.GetPriceFactorsAsync(priceIds))
+ .GroupBy(x => x.PriceId)
+ .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList());
+ var detailMap = (await _serviceChannelRepository.GetPriceDetailsAsync(priceIds))
+ .GroupBy(x => x.PriceId)
+ .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
+
+ return prices.Select(price => new ServiceChannelPriceListOutput
+ {
+ Id = price.Id,
+ ServiceChannelId = price.ServiceChannelId,
+ SerialNumber = price.SerialNumber,
+ Status = (byte)price.Status,
+ PriceName = price.PriceName,
+ CostItem = price.CostItem,
+ CostUnit = price.CostUnit,
+ Currency = price.Currency,
+ ChargingCondition = price.ChargingCondition,
+ Factors = factorMap.TryGetValue(price.Id, out var factors)
+ ? factors.Select(f => new ChannelPriceFactorOutput
+ {
+ Id = f.Id,
+ PriceId = f.PriceId,
+ SerialNumber = f.SerialNumber,
+ Factor = f.Factor,
+ FactorValues = OmsDocHelper.DeserializeJson>(f.FactorValues) ?? []
+ }).ToList()
+ : [],
+ Details = detailMap.TryGetValue(price.Id, out var details)
+ ? details.Select(d => new ChannelPriceDetailOutput
+ {
+ Id = d.Id,
+ PriceId = d.PriceId,
+ FactorValuesJson = OmsDocHelper.DeserializeJsonObject(d.FactorValuesJson),
+ CalculationFormula = d.CalculationFormula,
+ CalculatedValue = d.CalculatedValue,
+ CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(d.CustomerGroupValues)
+ }).ToList()
+ : []
+ }).ToList();
+ }
+
+ private static List NormalizeSizes(List? list)
+ {
+ return (list ?? [])
+ .Select(x => new PickupPackageSizeOutput
+ {
+ Length = x.Length,
+ Width = x.Width,
+ Height = x.Height,
+ Count = x.Count <= 0 ? 1 : x.Count
+ })
+ .Where(x => x.Length > 0 && x.Width > 0 && x.Height > 0)
+ .ToList();
+ }
+
+ private static PickupFeeLineOutput ToFeeLine(OrderFee fee) =>
+ new()
+ {
+ CostItem = fee.CostItem,
+ Amount = fee.Amount,
+ Currency = fee.Currency ?? string.Empty
+ };
+}
diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs
index 9020543..4ee5162 100644
--- a/src/Oms.Application/Services/Orders/PickupOrderService.cs
+++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs
@@ -1156,6 +1156,15 @@ public class PickupOrderService : ApplicationService
ParcelCount = entity.ParcelCount,
TotalActualWeight = entity.TotalActualWeight,
ChargeableWeight = entity.ChargeableWeight,
+ VolumetricWeight = entity.VolumetricWeight,
+ PackageSizes = OmsDocHelper.DeserializeJson>(entity.PackageSizesJson) ?? [],
+ CarrierFreightWeight = entity.CarrierFreightWeight,
+ CarrierQuotedFreightAmount = entity.CarrierQuotedFreightAmount,
+ CarrierQuotedReceivableAmount = entity.CarrierQuotedReceivableAmount,
+ CarrierQuote = OmsDocHelper.DeserializeJson(entity.CarrierQuoteJson),
+ ReceivableFees = OmsDocHelper.DeserializeJson>(entity.ReceivableFeeJson) ?? [],
+ PayableFees = OmsDocHelper.DeserializeJson>(entity.PayableFeeJson) ?? [],
+ FeePushedAt = entity.FeePushedAt,
Currency = entity.Currency,
FreightAmount = entity.FreightAmount,
OtherFeeAmount = entity.OtherFeeAmount,
diff --git a/src/Oms.Application/Services/System/SystemOtherConfigService.cs b/src/Oms.Application/Services/System/SystemOtherConfigService.cs
index 2747909..c25a817 100644
--- a/src/Oms.Application/Services/System/SystemOtherConfigService.cs
+++ b/src/Oms.Application/Services/System/SystemOtherConfigService.cs
@@ -188,6 +188,13 @@ public class SystemOtherConfigService : ApplicationService
throw new BusinessException("API 中心应用 AppSecret 不能为空");
}
}
+ else if (string.Equals(configCode, SystemOtherConfigCodes.PickupOpenApi, StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrWhiteSpace(input.AppKey))
+ {
+ throw new BusinessException("揽收开放接口 AppKey 不能为空");
+ }
+ }
else if (string.Equals(configCode, SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(input.AppKey))
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs b/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs
index b8de6ce..96acab6 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs
@@ -105,6 +105,54 @@ public class PickupOrder : OmsAuditedAggregateRoot
[SugarColumn(DecimalDigits = 3, IsNullable = true)]
public decimal? ChargeableWeight { get; set; }
+ [SugarColumn(DecimalDigits = 3, IsNullable = true)]
+ public decimal? VolumetricWeight { get; set; }
+
+ ///
+ /// 承运回传尺寸 JSON(goodsSizeList)。
+ ///
+ [SugarColumn(ColumnDataType = "text", IsNullable = true)]
+ public string? PackageSizesJson { get; set; }
+
+ ///
+ /// 承运计费重(仅对照,不算账)。
+ ///
+ [SugarColumn(DecimalDigits = 3, IsNullable = true)]
+ public decimal? CarrierFreightWeight { get; set; }
+
+ ///
+ /// 承运报价运费合计(仅对照)。
+ ///
+ [SugarColumn(DecimalDigits = 2, IsNullable = true)]
+ public decimal? CarrierQuotedFreightAmount { get; set; }
+
+ ///
+ /// 承运报文应收(仅对照,不是客户应收)。
+ ///
+ [SugarColumn(DecimalDigits = 2, IsNullable = true)]
+ public decimal? CarrierQuotedReceivableAmount { get; set; }
+
+ ///
+ /// 承运报价原文(附加费、服务方式等)。
+ ///
+ [SugarColumn(ColumnDataType = "text", IsNullable = true)]
+ public string? CarrierQuoteJson { get; set; }
+
+ ///
+ /// 系统卖价费用明细 JSON。
+ ///
+ [SugarColumn(ColumnDataType = "text", IsNullable = true)]
+ public string? ReceivableFeeJson { get; set; }
+
+ ///
+ /// 系统买价费用明细 JSON。
+ ///
+ [SugarColumn(ColumnDataType = "text", IsNullable = true)]
+ public string? PayableFeeJson { get; set; }
+
+ [SugarColumn(IsNullable = true)]
+ public DateTime? FeePushedAt { get; set; }
+
[SugarColumn(Length = 8)]
public string Currency { get; set; } = "CNY";
diff --git a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs
index b11706e..81ad7c1 100644
--- a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs
+++ b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs
@@ -50,8 +50,13 @@ public static class SystemOtherConfigCodes
///
public const string LabelDefaultTemplate = "label_default_template";
-///
-/// API 中心(接口通 / itfx)网关:BaseUrl=itfx 根地址,AppKey/AppSecret=默认应用凭证,TimeoutSeconds=超时。
-///
-public const string ApiCenter = "api_center";
+ ///
+ /// API 中心(接口通 / itfx)网关:BaseUrl=itfx 根地址,AppKey/AppSecret=默认应用凭证,TimeoutSeconds=超时。
+ ///
+ public const string ApiCenter = "api_center";
+
+ ///
+ /// 国内揽收服务商推送件重尺/费用:仅校验 AppKey(暂不验签)。
+ ///
+ public const string PickupOpenApi = "pickup_open_api";
}
--
2.45.2
From 955a3c8c6d420de3b2a93f1719b3946723632307 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Tue, 29 Sep 2026 19:56:40 +0800
Subject: [PATCH 12/41] =?UTF-8?q?fix:=20=E6=8F=BD=E6=94=B6=E6=8E=A8?=
=?UTF-8?q?=E8=B4=B9=E6=8C=89=E6=89=BF=E8=BF=90=E8=BF=90=E5=8D=95=E5=8F=B7?=
=?UTF-8?q?=20CarrierWaybillNo=20=E5=8C=B9=E9=85=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs | 3 +++
.../Services/Orders/PickupOrderFeePushService.cs | 10 +++++-----
.../Repositories/IPickupOrderRepository.cs | 2 ++
src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs | 6 ++++++
4 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
index b046a71..8828139 100644
--- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
@@ -388,6 +388,9 @@ public class PickupOpenApiPushFeeResult
public class PickupOpenApiPushFeeInput
{
+ ///
+ /// 承运运单号,对应库字段 CarrierWaybillNo。
+ ///
public string? WaybillNumber { get; set; }
public decimal ActualWeight { get; set; }
diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
index 73dbafa..b1442c0 100644
--- a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
+++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
@@ -70,10 +70,10 @@ public class PickupOrderFeePushService : IScopedDependency
throw new BusinessException("请求体不能为空");
}
- var pickupNo = (input.WaybillNumber ?? string.Empty).Trim();
- if (string.IsNullOrWhiteSpace(pickupNo))
+ var carrierWaybillNo = (input.WaybillNumber ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(carrierWaybillNo))
{
- throw new BusinessException("waybillNumber 不能为空");
+ throw new BusinessException("waybillNumber(承运运单号 CarrierWaybillNo)不能为空");
}
if (input.ActualWeight <= 0)
@@ -87,8 +87,8 @@ public class PickupOrderFeePushService : IScopedDependency
throw new BusinessException("goodsSizeList 不能为空");
}
- var pickup = await _pickupOrderRepository.GetByPickupNoAsync(pickupNo)
- ?? throw new BusinessException("揽收单不存在");
+ var pickup = await _pickupOrderRepository.GetByCarrierWaybillNoAsync(carrierWaybillNo)
+ ?? throw new BusinessException($"揽收单不存在:承运运单号 {carrierWaybillNo}");
await EnsureAppKeyAsync(key, pickup);
diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IPickupOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IPickupOrderRepository.cs
index 55b9cc8..92ef693 100644
--- a/src/Oms.Domain/OrdersAggregate/Repositories/IPickupOrderRepository.cs
+++ b/src/Oms.Domain/OrdersAggregate/Repositories/IPickupOrderRepository.cs
@@ -7,6 +7,8 @@ public interface IPickupOrderRepository : IRepository
{
Task GetByPickupNoAsync(string pickupNo, CancellationToken cancellationToken = default);
+ Task GetByCarrierWaybillNoAsync(string carrierWaybillNo, CancellationToken cancellationToken = default);
+
Task GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default);
Task GetWorkplaceCountsAsync(
diff --git a/src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs b/src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs
index a898e0b..c9a19ca 100644
--- a/src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs
+++ b/src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs
@@ -14,6 +14,12 @@ public class PickupOrderRepository : OmsSqlSugarRepository, IPickup
return FirstOrDefaultAsync(x => x.PickupNo == no, cancellationToken);
}
+ public Task GetByCarrierWaybillNoAsync(string carrierWaybillNo, CancellationToken cancellationToken = default)
+ {
+ var no = carrierWaybillNo.Trim();
+ return FirstOrDefaultAsync(x => x.CarrierWaybillNo == no, cancellationToken);
+ }
+
public Task GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken);
--
2.45.2
From 2688058cb3e8f05305069822fa5ea1edcfb836ac Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Wed, 30 Sep 2026 10:19:38 +0800
Subject: [PATCH 13/41] =?UTF-8?q?feat:=20=E5=AE=A2=E6=88=B7=20OpenAPI=20?=
=?UTF-8?q?=E6=9C=BA=E6=9C=BA=E5=87=AD=E8=AF=81=E4=B8=8E=20whoami=20?=
=?UTF-8?q?=E6=8E=A2=E9=92=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
一主客户一对 AppKey/AppSecret,支持门户自助与后台代管启停重置,供应商回调路径不动。
Co-authored-by: Cursor
---
sql/migrate_customer_openapi_credential.sql | 41 +++
.../CustomerOpenApiCredentialDtos.cs | 66 +++++
.../CustomerOpenApiCredentialService.cs | 255 ++++++++++++++++++
.../Customers/CustomerOpenApiService.cs | 63 +++++
.../Shared/CustomerOpenApiAuthHelper.cs | 82 ++++++
.../Entities/CustomerOpenApiCredential.cs | 30 +++
.../ICustomerOpenApiCredentialRepository.cs | 15 ++
.../CustomerOpenApiCredentialRepository.cs | 26 ++
8 files changed, 578 insertions(+)
create mode 100644 sql/migrate_customer_openapi_credential.sql
create mode 100644 src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs
create mode 100644 src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs
create mode 100644 src/Oms.Application/Services/Customers/CustomerOpenApiService.cs
create mode 100644 src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
create mode 100644 src/Oms.Domain/CustomersAggregate/Entities/CustomerOpenApiCredential.cs
create mode 100644 src/Oms.Domain/CustomersAggregate/Repositories/ICustomerOpenApiCredentialRepository.cs
create mode 100644 src/Oms.SqlSugarCore/Customers/CustomerOpenApiCredentialRepository.cs
diff --git a/sql/migrate_customer_openapi_credential.sql b/sql/migrate_customer_openapi_credential.sql
new file mode 100644
index 0000000..da9e138
--- /dev/null
+++ b/sql/migrate_customer_openapi_credential.sql
@@ -0,0 +1,41 @@
+-- 客户 OpenAPI 机机凭证(一客户一对 AppKey/AppSecret,独立于 itfx)
+-- 可重复执行。
+-- 目标表:t_customer_openapi_credential
+
+SET @db := DATABASE();
+
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_openapi_credential'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'CREATE TABLE `t_customer_openapi_credential` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+ `CustomerId` INT NOT NULL COMMENT ''归属主客户 t_customer.Id'',
+ `AppKey` VARCHAR(64) NOT NULL COMMENT ''开放接口 AppKey'',
+ `AppSecret` VARCHAR(128) NOT NULL COMMENT ''开放接口 AppSecret(明文,创建/重置时返回)'',
+ `Enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:0-否,1-是'',
+ `LastUsedAt` DATETIME NULL COMMENT ''最近鉴权成功时间'',
+ `LastUsedIp` VARCHAR(64) NULL COMMENT ''最近鉴权成功 IP'',
+ `Remark` VARCHAR(512) NULL COMMENT ''备注'',
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
+ `UpdateUserId` INT NULL COMMENT ''更新人'',
+ `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
+ PRIMARY KEY (`Id`),
+ UNIQUE KEY `uk_coc_customer` (`CustomerId`),
+ UNIQUE KEY `uk_coc_app_key` (`AppKey`),
+ KEY `idx_coc_enabled` (`Enabled`, `IsDeleted`)
+) COMMENT=''客户 OpenAPI 机机凭证''',
+ 'SELECT ''t_customer_openapi_credential already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs
new file mode 100644
index 0000000..e9b8d70
--- /dev/null
+++ b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs
@@ -0,0 +1,66 @@
+namespace Oms.Application.Dtos.Customers;
+
+public class CustomerOpenApiCredentialOutput
+{
+ public int Id { get; set; }
+
+ public int CustomerId { get; set; }
+
+ public string AppKey { get; set; } = string.Empty;
+
+ /// 默认掩码;仅 ensure(新建)/ reset-secret 返回完整明文。
+ public string AppSecret { get; set; } = string.Empty;
+
+ public bool Enabled { get; set; }
+
+ public bool HasCredential { get; set; }
+
+ /// 本次响应是否包含完整 AppSecret(前端可提示用户复制保存)。
+ public bool SecretRevealed { get; set; }
+
+ public DateTime? LastUsedAt { get; set; }
+
+ public string? LastUsedIp { get; set; }
+
+ public DateTime? CreateTime { get; set; }
+
+ public DateTime? UpdateTime { get; set; }
+}
+
+public class CustomerOpenApiCredentialAdminInput
+{
+ public int CustomerId { get; set; }
+}
+
+public class CustomerOpenApiCredentialSetEnabledInput
+{
+ public bool Enabled { get; set; }
+}
+
+public class CustomerOpenApiCredentialAdminSetEnabledInput : CustomerOpenApiCredentialAdminInput
+{
+ public bool Enabled { get; set; }
+}
+
+public class CustomerOpenApiWhoAmIInput
+{
+ public string? AppSecret { get; set; }
+}
+
+public class CustomerOpenApiWhoAmIResult
+{
+ public int CustomerId { get; set; }
+
+ public string CustomerCode { get; set; } = string.Empty;
+
+ public string CustomerName { get; set; } = string.Empty;
+}
+
+public class CustomerOpenApiEnvelope
+{
+ public string Code { get; set; } = "200";
+
+ public string Msg { get; set; } = "成功";
+
+ public CustomerOpenApiWhoAmIResult? Data { get; set; }
+}
diff --git a/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs
new file mode 100644
index 0000000..1381ab6
--- /dev/null
+++ b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs
@@ -0,0 +1,255 @@
+using System.Security.Cryptography;
+using Fake.Application;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Customers;
+using Oms.Application.Shared;
+using Oms.Domain.CustomersAggregate.Entities;
+using Oms.Domain.CustomersAggregate.Repositories;
+
+namespace Oms.Application.Services.Customers;
+
+///
+/// 客户 OpenAPI 凭证管理(门户当前用户 + 后台代管)。
+///
+[ApiExplorerSettings(GroupName = "OMS-Customer")]
+public class CustomerOpenApiCredentialService : ApplicationService
+{
+ private const string SecretMask = "********";
+
+ private ICustomerRepository CustomerRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICustomerOpenApiCredentialRepository CredentialRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ /// 当前登录主客户查看凭证(Secret 掩码)。
+ [HttpGet("/customer/openapi-credential")]
+ public async Task GetMineAsync()
+ {
+ var customer = await GetCurrentMainCustomerOrThrowAsync();
+ return await MapGetAsync(customer.Id);
+ }
+
+ /// 当前登录主客户:无则生成并返回完整 Secret;已有则掩码返回。
+ [HttpPost("/customer/openapi-credential/ensure")]
+ public async Task EnsureMineAsync()
+ {
+ var customer = await GetCurrentMainCustomerOrThrowAsync();
+ return await EnsureAsync(customer.Id);
+ }
+
+ /// 当前登录主客户重置 Secret。
+ [HttpPost("/customer/openapi-credential/reset-secret")]
+ public async Task ResetMineSecretAsync()
+ {
+ var customer = await GetCurrentMainCustomerOrThrowAsync();
+ return await ResetSecretAsync(customer.Id);
+ }
+
+ /// 当前登录主客户启停。
+ [HttpPost("/customer/openapi-credential/set-enabled")]
+ public async Task SetMineEnabledAsync(
+ [FromBody] CustomerOpenApiCredentialSetEnabledInput input)
+ {
+ var customer = await GetCurrentMainCustomerOrThrowAsync();
+ return await SetEnabledAsync(customer.Id, input.Enabled);
+ }
+
+ /// 后台按客户查看凭证。
+ [HttpGet("/customer/openapi-credential/admin/get")]
+ public async Task AdminGetAsync([FromQuery] int customerId)
+ {
+ EnsureAdminSession();
+ await EnsureMainCustomerAsync(customerId);
+ return await MapGetAsync(customerId);
+ }
+
+ /// 后台确保生成凭证。
+ [HttpPost("/customer/openapi-credential/admin/ensure")]
+ public async Task AdminEnsureAsync(
+ [FromBody] CustomerOpenApiCredentialAdminInput input)
+ {
+ EnsureAdminSession();
+ await EnsureMainCustomerAsync(input.CustomerId);
+ return await EnsureAsync(input.CustomerId);
+ }
+
+ /// 后台重置 Secret。
+ [HttpPost("/customer/openapi-credential/admin/reset-secret")]
+ public async Task AdminResetSecretAsync(
+ [FromBody] CustomerOpenApiCredentialAdminInput input)
+ {
+ EnsureAdminSession();
+ await EnsureMainCustomerAsync(input.CustomerId);
+ return await ResetSecretAsync(input.CustomerId);
+ }
+
+ /// 后台启停。
+ [HttpPost("/customer/openapi-credential/admin/set-enabled")]
+ public async Task AdminSetEnabledAsync(
+ [FromBody] CustomerOpenApiCredentialAdminSetEnabledInput input)
+ {
+ EnsureAdminSession();
+ await EnsureMainCustomerAsync(input.CustomerId);
+ return await SetEnabledAsync(input.CustomerId, input.Enabled);
+ }
+
+ private async Task MapGetAsync(int customerId)
+ {
+ var entity = await CredentialRepository.GetByCustomerIdAsync(customerId);
+ if (entity == null)
+ {
+ return new CustomerOpenApiCredentialOutput
+ {
+ CustomerId = customerId,
+ HasCredential = false,
+ Enabled = false,
+ AppKey = string.Empty,
+ AppSecret = string.Empty,
+ SecretRevealed = false
+ };
+ }
+
+ return MapOutput(entity, revealSecret: false);
+ }
+
+ private async Task EnsureAsync(int customerId)
+ {
+ var existing = await CredentialRepository.GetByCustomerIdAsync(customerId);
+ if (existing != null)
+ {
+ return MapOutput(existing, revealSecret: false);
+ }
+
+ var entity = new CustomerOpenApiCredential
+ {
+ CustomerId = customerId,
+ AppKey = await GenerateUniqueAppKeyAsync(),
+ AppSecret = GenerateAppSecret(),
+ Enabled = true
+ };
+ entity = await CredentialRepository.InsertAsync(entity);
+ return MapOutput(entity, revealSecret: true);
+ }
+
+ private async Task ResetSecretAsync(int customerId)
+ {
+ var entity = await CredentialRepository.GetByCustomerIdAsync(customerId);
+ if (entity == null)
+ {
+ throw new BusinessException("尚未生成开放接口凭证,请先生成");
+ }
+
+ entity.AppSecret = GenerateAppSecret();
+ await CredentialRepository.UpdateAsync(entity);
+ return MapOutput(entity, revealSecret: true);
+ }
+
+ private async Task SetEnabledAsync(int customerId, bool enabled)
+ {
+ var entity = await CredentialRepository.GetByCustomerIdAsync(customerId);
+ if (entity == null)
+ {
+ throw new BusinessException("尚未生成开放接口凭证,请先生成");
+ }
+
+ entity.Enabled = enabled;
+ await CredentialRepository.UpdateAsync(entity);
+ return MapOutput(entity, revealSecret: false);
+ }
+
+ private async Task EnsureMainCustomerAsync(int customerId)
+ {
+ if (customerId <= 0)
+ {
+ throw new BusinessException("客户ID不能为空");
+ }
+
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId);
+ if (customer == null)
+ {
+ throw new BusinessException("客户不存在");
+ }
+
+ if (customer.ParentCustomerId.HasValue)
+ {
+ throw new BusinessException("仅主客户可配置开放接口凭证");
+ }
+ }
+
+ private async Task GetCurrentMainCustomerOrThrowAsync()
+ {
+ if (!OmsSessionHelper.IsCustomerLogin(CurrentUser))
+ {
+ throw new BusinessException("仅客户门户可调用此接口");
+ }
+
+ var mapId = OmsSessionHelper.TryGetCustomerMapId(CurrentUser);
+ if (!mapId.HasValue || mapId.Value <= 0)
+ {
+ throw new BusinessException("客户未登录");
+ }
+
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == mapId.Value);
+ if (customer == null)
+ {
+ throw new BusinessException("客户不存在");
+ }
+
+ if (customer.ParentCustomerId.HasValue)
+ {
+ throw new BusinessException("仅主客户可管理开放接口凭证");
+ }
+
+ return customer;
+ }
+
+ private void EnsureAdminSession()
+ {
+ if (OmsSessionHelper.IsCustomerLogin(CurrentUser))
+ {
+ throw new BusinessException("无权限");
+ }
+ }
+
+ private async Task GenerateUniqueAppKeyAsync()
+ {
+ for (var i = 0; i < 8; i++)
+ {
+ var key = $"oms_{Guid.NewGuid():N}"[..20];
+ var existed = await CredentialRepository.GetByAppKeyAsync(key);
+ if (existed == null)
+ {
+ return key;
+ }
+ }
+
+ throw new BusinessException("生成 AppKey 失败,请重试");
+ }
+
+ private static string GenerateAppSecret()
+ {
+ var bytes = RandomNumberGenerator.GetBytes(24);
+ return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', 'x').Replace('/', 'y');
+ }
+
+ private static CustomerOpenApiCredentialOutput MapOutput(
+ CustomerOpenApiCredential entity,
+ bool revealSecret)
+ {
+ return new CustomerOpenApiCredentialOutput
+ {
+ Id = entity.Id,
+ CustomerId = entity.CustomerId,
+ AppKey = entity.AppKey,
+ AppSecret = revealSecret ? entity.AppSecret : SecretMask,
+ Enabled = entity.Enabled,
+ HasCredential = true,
+ SecretRevealed = revealSecret,
+ LastUsedAt = entity.LastUsedAt,
+ LastUsedIp = entity.LastUsedIp,
+ CreateTime = entity.CreateTime,
+ UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
+ };
+ }
+}
diff --git a/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs b/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs
new file mode 100644
index 0000000..3b44e75
--- /dev/null
+++ b/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs
@@ -0,0 +1,63 @@
+using Fake.Application;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Customers;
+using Oms.Application.Shared;
+
+namespace Oms.Application.Services.Customers;
+
+///
+/// 客户 OpenAPI 机机入口(AllowAnonymous,AppKey + AppSecret)。
+///
+[ApiExplorerSettings(GroupName = "OMS-Customer")]
+public class CustomerOpenApiService : ApplicationService
+{
+ private CustomerOpenApiAuthHelper AuthHelper =>
+ LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 探针:校验凭证并返回客户身份。
+ ///
+ [AllowAnonymous]
+ [HttpPost("/openapi/customer/whoami")]
+ public async Task WhoAmIAsync(
+ [FromQuery] string? appKey,
+ [FromHeader(Name = "appSecret")] string? appSecretHeader,
+ [FromBody] CustomerOpenApiWhoAmIInput? body)
+ {
+ try
+ {
+ var appSecret = !string.IsNullOrWhiteSpace(appSecretHeader)
+ ? appSecretHeader
+ : body?.AppSecret;
+ var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret);
+ return new CustomerOpenApiEnvelope
+ {
+ Code = "200",
+ Msg = "成功",
+ Data = new CustomerOpenApiWhoAmIResult
+ {
+ CustomerId = customer.Id,
+ CustomerCode = customer.CustomerCode,
+ CustomerName = customer.CustomerName
+ }
+ };
+ }
+ catch (BusinessException ex)
+ {
+ return Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ return Fail(string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message, "500");
+ }
+ }
+
+ private static CustomerOpenApiEnvelope Fail(string msg, string code = "401") =>
+ new()
+ {
+ Code = code,
+ Msg = msg,
+ Data = null
+ };
+}
diff --git a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
new file mode 100644
index 0000000..94e7458
--- /dev/null
+++ b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
@@ -0,0 +1,82 @@
+using Fake.Application;
+using Fake.DependencyInjection;
+using Microsoft.AspNetCore.Http;
+using Oms.Domain.CustomersAggregate.Entities;
+using Oms.Domain.CustomersAggregate.Enums;
+using Oms.Domain.CustomersAggregate.Repositories;
+
+namespace Oms.Application.Shared;
+
+///
+/// 客户 OpenAPI 机机鉴权:按 AppKey+AppSecret 解析主客户,供 whoami 及后续业务接口复用。
+///
+public class CustomerOpenApiAuthHelper : IScopedDependency
+{
+ private readonly ICustomerOpenApiCredentialRepository _credentialRepository;
+ private readonly ICustomerRepository _customerRepository;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ public CustomerOpenApiAuthHelper(
+ ICustomerOpenApiCredentialRepository credentialRepository,
+ ICustomerRepository customerRepository,
+ IHttpContextAccessor httpContextAccessor)
+ {
+ _credentialRepository = credentialRepository;
+ _customerRepository = customerRepository;
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveAsync(
+ string? appKey,
+ string? appSecret,
+ CancellationToken cancellationToken = default)
+ {
+ var key = appKey?.Trim();
+ var secret = appSecret?.Trim();
+ if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret))
+ {
+ throw new BusinessException("凭证无效");
+ }
+
+ var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken);
+ if (credential == null
+ || !credential.Enabled
+ || !string.Equals(credential.AppSecret, secret, StringComparison.Ordinal))
+ {
+ throw new BusinessException("凭证无效");
+ }
+
+ var customer = await _customerRepository.FirstOrDefaultAsync(
+ x => x.Id == credential.CustomerId,
+ cancellationToken);
+ if (customer == null
+ || customer.ParentCustomerId.HasValue
+ || customer.Status != CustomerStatus.Normal)
+ {
+ throw new BusinessException("凭证无效");
+ }
+
+ credential.LastUsedAt = DateTime.Now;
+ credential.LastUsedIp = ResolveClientIp();
+ await _credentialRepository.UpdateAsync(credential);
+
+ return (customer, credential);
+ }
+
+ private string? ResolveClientIp()
+ {
+ var http = _httpContextAccessor.HttpContext;
+ if (http == null)
+ {
+ return null;
+ }
+
+ var forwarded = http.Request.Headers["X-Forwarded-For"].FirstOrDefault();
+ if (!string.IsNullOrWhiteSpace(forwarded))
+ {
+ return forwarded.Split(',')[0].Trim();
+ }
+
+ return http.Connection.RemoteIpAddress?.ToString();
+ }
+}
diff --git a/src/Oms.Domain/CustomersAggregate/Entities/CustomerOpenApiCredential.cs b/src/Oms.Domain/CustomersAggregate/Entities/CustomerOpenApiCredential.cs
new file mode 100644
index 0000000..551c61f
--- /dev/null
+++ b/src/Oms.Domain/CustomersAggregate/Entities/CustomerOpenApiCredential.cs
@@ -0,0 +1,30 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.CustomersAggregate.Entities;
+
+///
+/// 客户 OpenAPI 机机凭证(一主客户一对密钥,独立于 itfx)。
+///
+[SugarTable("t_customer_openapi_credential")]
+public class CustomerOpenApiCredential : OmsAuditedAggregateRoot
+{
+ public int CustomerId { get; set; }
+
+ [SugarColumn(Length = 64)]
+ public string AppKey { get; set; } = string.Empty;
+
+ [SugarColumn(Length = 128)]
+ public string AppSecret { get; set; } = string.Empty;
+
+ public bool Enabled { get; set; } = true;
+
+ [SugarColumn(IsNullable = true)]
+ public DateTime? LastUsedAt { get; set; }
+
+ [SugarColumn(Length = 64, IsNullable = true)]
+ public string? LastUsedIp { get; set; }
+
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? Remark { get; set; }
+}
diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerOpenApiCredentialRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerOpenApiCredentialRepository.cs
new file mode 100644
index 0000000..aefc686
--- /dev/null
+++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerOpenApiCredentialRepository.cs
@@ -0,0 +1,15 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.CustomersAggregate.Entities;
+
+namespace Oms.Domain.CustomersAggregate.Repositories;
+
+public interface ICustomerOpenApiCredentialRepository : IRepository
+{
+ Task GetByCustomerIdAsync(
+ int customerId,
+ CancellationToken cancellationToken = default);
+
+ Task GetByAppKeyAsync(
+ string appKey,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.SqlSugarCore/Customers/CustomerOpenApiCredentialRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerOpenApiCredentialRepository.cs
new file mode 100644
index 0000000..a002c28
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Customers/CustomerOpenApiCredentialRepository.cs
@@ -0,0 +1,26 @@
+using Fake.DependencyInjection;
+using Oms.Domain.CustomersAggregate.Entities;
+using Oms.Domain.CustomersAggregate.Repositories;
+using Oms.SqlSugarCore.Repositories;
+
+namespace Oms.SqlSugarCore.Customers;
+
+public class CustomerOpenApiCredentialRepository
+ : OmsSqlSugarRepository,
+ ICustomerOpenApiCredentialRepository,
+ IScopedDependency
+{
+ public Task GetByCustomerIdAsync(
+ int customerId,
+ CancellationToken cancellationToken = default)
+ {
+ return FirstOrDefaultAsync(x => x.CustomerId == customerId, cancellationToken);
+ }
+
+ public Task GetByAppKeyAsync(
+ string appKey,
+ CancellationToken cancellationToken = default)
+ {
+ return FirstOrDefaultAsync(x => x.AppKey == appKey, cancellationToken);
+ }
+}
--
2.45.2
From a3b54b6b07e02d0fe03ca2ffd9954a250016c768 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Wed, 30 Sep 2026 10:36:24 +0800
Subject: [PATCH 14/41] =?UTF-8?q?feat:=20openApi=20=E5=88=86=E7=BB=84?=
=?UTF-8?q?=E6=9A=B4=E9=9C=B2=E6=8E=A8=E8=B4=B9=E4=B8=8E=E4=B8=8B=E5=8D=95?=
=?UTF-8?q?=EF=BC=8C=E5=8E=BB=E6=8E=89=20OMS=20=E4=BE=A7=20appKey=20?=
=?UTF-8?q?=E6=A0=A1=E9=AA=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Swagger openApi 提供 /openapi/pickup-order/push-fee 与 /openapi/parcel/create;删除 method 路由与 whoami。
Co-authored-by: Cursor
---
.../CustomerOpenApiCredentialDtos.cs | 23 -----
.../Dtos/Orders/OpenApiParcelCreateInput.cs | 17 ++++
.../Dtos/Orders/PickupOrderDtos.cs | 9 --
.../Customers/CustomerOpenApiService.cs | 63 --------------
.../Services/OpenApi/OpenApiService.cs | 52 +++++++++++
.../Services/Orders/PickupOpenApiService.cs | 76 ----------------
.../Orders/PickupOrderFeePushService.cs | 86 +------------------
.../System/SystemOtherConfigService.cs | 5 +-
.../Consts/SystemOtherConfigCodes.cs | 2 +-
9 files changed, 73 insertions(+), 260 deletions(-)
create mode 100644 src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs
delete mode 100644 src/Oms.Application/Services/Customers/CustomerOpenApiService.cs
create mode 100644 src/Oms.Application/Services/OpenApi/OpenApiService.cs
delete mode 100644 src/Oms.Application/Services/Orders/PickupOpenApiService.cs
diff --git a/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs
index e9b8d70..e5b11c3 100644
--- a/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs
+++ b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs
@@ -41,26 +41,3 @@ public class CustomerOpenApiCredentialAdminSetEnabledInput : CustomerOpenApiCred
{
public bool Enabled { get; set; }
}
-
-public class CustomerOpenApiWhoAmIInput
-{
- public string? AppSecret { get; set; }
-}
-
-public class CustomerOpenApiWhoAmIResult
-{
- public int CustomerId { get; set; }
-
- public string CustomerCode { get; set; } = string.Empty;
-
- public string CustomerName { get; set; } = string.Empty;
-}
-
-public class CustomerOpenApiEnvelope
-{
- public string Code { get; set; } = "200";
-
- public string Msg { get; set; } = "成功";
-
- public CustomerOpenApiWhoAmIResult? Data { get; set; }
-}
diff --git a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs
new file mode 100644
index 0000000..c48882c
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs
@@ -0,0 +1,17 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 开放接口创建订单包裹:在标准下单入参上增加 AppKey/AppSecret(Body,非自定义 Header)。
+///
+public class OpenApiParcelCreateInput : OrderParcelCreateInput
+{
+ ///
+ /// 客户开放接口 AppKey。
+ ///
+ public string? AppKey { get; set; }
+
+ ///
+ /// 客户开放接口 AppSecret。
+ ///
+ public string? AppSecret { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
index 8828139..ab046bc 100644
--- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
@@ -360,15 +360,6 @@ public class PickupCarrierValueFeeOutput
public decimal? FeeAmount { get; set; }
}
-public class PickupOpenApiEnvelope
-{
- public string Code { get; set; } = "200";
-
- public string Msg { get; set; } = "成功";
-
- public PickupOpenApiPushFeeResult? Data { get; set; }
-}
-
public class PickupOpenApiPushFeeResult
{
public string PickupNo { get; set; } = string.Empty;
diff --git a/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs b/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs
deleted file mode 100644
index 3b44e75..0000000
--- a/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using Fake.Application;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Oms.Application.Dtos.Customers;
-using Oms.Application.Shared;
-
-namespace Oms.Application.Services.Customers;
-
-///
-/// 客户 OpenAPI 机机入口(AllowAnonymous,AppKey + AppSecret)。
-///
-[ApiExplorerSettings(GroupName = "OMS-Customer")]
-public class CustomerOpenApiService : ApplicationService
-{
- private CustomerOpenApiAuthHelper AuthHelper =>
- LazyServiceProvider.GetRequiredService();
-
- ///
- /// 探针:校验凭证并返回客户身份。
- ///
- [AllowAnonymous]
- [HttpPost("/openapi/customer/whoami")]
- public async Task WhoAmIAsync(
- [FromQuery] string? appKey,
- [FromHeader(Name = "appSecret")] string? appSecretHeader,
- [FromBody] CustomerOpenApiWhoAmIInput? body)
- {
- try
- {
- var appSecret = !string.IsNullOrWhiteSpace(appSecretHeader)
- ? appSecretHeader
- : body?.AppSecret;
- var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret);
- return new CustomerOpenApiEnvelope
- {
- Code = "200",
- Msg = "成功",
- Data = new CustomerOpenApiWhoAmIResult
- {
- CustomerId = customer.Id,
- CustomerCode = customer.CustomerCode,
- CustomerName = customer.CustomerName
- }
- };
- }
- catch (BusinessException ex)
- {
- return Fail(ex.Message);
- }
- catch (Exception ex)
- {
- return Fail(string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message, "500");
- }
- }
-
- private static CustomerOpenApiEnvelope Fail(string msg, string code = "401") =>
- new()
- {
- Code = code,
- Msg = msg,
- Data = null
- };
-}
diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
new file mode 100644
index 0000000..189b4ef
--- /dev/null
+++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
@@ -0,0 +1,52 @@
+using Fake.Application;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Services.Orders;
+using Oms.Application.Shared;
+
+namespace Oms.Application.Services.OpenApi;
+
+///
+/// 对外开放 API(Swagger 文档分组:openApi)。
+/// 客户机机:AppKey/AppSecret 放在 JSON Body;供应商推费由上游 itfx 鉴权,OMS 不再验 key。
+///
+[ApiExplorerSettings(GroupName = "openApi")]
+public class OpenApiService : ApplicationService
+{
+ private CustomerOpenApiAuthHelper AuthHelper =>
+ LazyServiceProvider.GetRequiredService();
+
+ private PickupOrderFeePushService FeePush =>
+ LazyServiceProvider.GetRequiredService();
+
+ private OrderService OrderService =>
+ LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需 AppKey)。
+ ///
+ [AllowAnonymous]
+ [HttpPost("/openapi/pickup-order/push-fee")]
+ public Task PushFeeAsync([FromBody] PickupOpenApiPushFeeInput input) =>
+ FeePush.PushFeeAsync(input);
+
+ ///
+ /// 创建订单包裹(客户机机:Body 传 AppKey、AppSecret)。
+ ///
+ [AllowAnonymous]
+ [HttpPost("/openapi/parcel/create")]
+ public async Task CreateParcelAsync([FromBody] OpenApiParcelCreateInput input)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+
+ var (customer, _) = await AuthHelper.ResolveAsync(input.AppKey, input.AppSecret);
+ if (input.CustomerId is > 0 && input.CustomerId.Value != customer.Id)
+ {
+ throw new BusinessException("凭证无效");
+ }
+
+ input.CustomerId = customer.Id;
+ return await OrderService.CreateParcelAsync(input);
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/PickupOpenApiService.cs b/src/Oms.Application/Services/Orders/PickupOpenApiService.cs
deleted file mode 100644
index a5a2dd4..0000000
--- a/src/Oms.Application/Services/Orders/PickupOpenApiService.cs
+++ /dev/null
@@ -1,76 +0,0 @@
-using Fake.Application;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Oms.Application.Dtos.Orders;
-
-namespace Oms.Application.Services.Orders;
-
-///
-/// 国内揽收开放接口(承运商推送件重尺)。
-///
-[ApiExplorerSettings(GroupName = "OMS-Order")]
-public class PickupOpenApiService : ApplicationService
-{
- private PickupOrderFeePushService FeePush =>
- LazyServiceProvider.GetRequiredService();
-
- ///
- /// 开放平台路由:method=open.api.openCommon.pushFee,仅校验 appKey。
- ///
- [AllowAnonymous]
- [HttpPost("/openapi")]
- public Task InvokeAsync(
- [FromQuery] string? appKey,
- [FromQuery] string? method,
- [FromBody] PickupOpenApiPushFeeInput? body) =>
- HandlePushFeeAsync(appKey, method, body);
-
- ///
- /// 推送揽收重量/尺寸并按当前产品计算应收应付。
- ///
- [AllowAnonymous]
- [HttpPost("/openapi/openCommon/pushFee")]
- public Task PushFeeAsync(
- [FromQuery] string? appKey,
- [FromBody] PickupOpenApiPushFeeInput? body) =>
- HandlePushFeeAsync(appKey, PickupOrderFeePushService.PushFeeMethod, body);
-
- private async Task HandlePushFeeAsync(
- string? appKey,
- string? method,
- PickupOpenApiPushFeeInput? body)
- {
- try
- {
- if (!string.IsNullOrWhiteSpace(method)
- && !string.Equals(method.Trim(), PickupOrderFeePushService.PushFeeMethod, StringComparison.OrdinalIgnoreCase))
- {
- return Fail("400", $"不支持的 method:{method}");
- }
-
- var data = await FeePush.PushFeeAsync(appKey, body);
- return new PickupOpenApiEnvelope
- {
- Code = "200",
- Msg = "成功",
- Data = data
- };
- }
- catch (BusinessException ex)
- {
- return Fail("400", ex.Message);
- }
- catch (Exception ex)
- {
- return Fail("500", string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message);
- }
- }
-
- private static PickupOpenApiEnvelope Fail(string code, string msg) =>
- new()
- {
- Code = code,
- Msg = msg,
- Data = null
- };
-}
diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
index b1442c0..37fcafc 100644
--- a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
+++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
@@ -2,7 +2,6 @@ using Fake.Application;
using Fake.DependencyInjection;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
-using Oms.Application.Integrations.Itfx;
using Oms.Application.Services.Orders.Issues;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
@@ -10,7 +9,6 @@ using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
-using Oms.Domain.SystemAggregate.Consts;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.Orders;
@@ -20,51 +18,34 @@ namespace Oms.Application.Services.Orders;
///
public class PickupOrderFeePushService : IScopedDependency
{
- public const string PushFeeMethod = "open.api.openCommon.pushFee";
-
private readonly IPickupOrderRepository _pickupOrderRepository;
- private readonly ISystemOtherConfigRepository _otherConfigRepository;
private readonly ISalesProductRepository _salesProductRepository;
private readonly IServiceChannelRepository _serviceChannelRepository;
- private readonly IServiceChannelGroupRepository _serviceChannelGroupRepository;
private readonly IWeightRuleRepository _weightRuleRepository;
private readonly IWeightRoundingRepository _weightRoundingRepository;
private readonly IKpiAreaRepository _kpiAreaRepository;
private readonly IWarehouseAddressRepository _warehouseAddressRepository;
- private readonly IItfxMetadataClient _itfxMetadataClient;
public PickupOrderFeePushService(
IPickupOrderRepository pickupOrderRepository,
- ISystemOtherConfigRepository otherConfigRepository,
ISalesProductRepository salesProductRepository,
IServiceChannelRepository serviceChannelRepository,
- IServiceChannelGroupRepository serviceChannelGroupRepository,
IWeightRuleRepository weightRuleRepository,
IWeightRoundingRepository weightRoundingRepository,
IKpiAreaRepository kpiAreaRepository,
- IWarehouseAddressRepository warehouseAddressRepository,
- IItfxMetadataClient itfxMetadataClient)
+ IWarehouseAddressRepository warehouseAddressRepository)
{
_pickupOrderRepository = pickupOrderRepository;
- _otherConfigRepository = otherConfigRepository;
_salesProductRepository = salesProductRepository;
_serviceChannelRepository = serviceChannelRepository;
- _serviceChannelGroupRepository = serviceChannelGroupRepository;
_weightRuleRepository = weightRuleRepository;
_weightRoundingRepository = weightRoundingRepository;
_kpiAreaRepository = kpiAreaRepository;
_warehouseAddressRepository = warehouseAddressRepository;
- _itfxMetadataClient = itfxMetadataClient;
}
- public async Task PushFeeAsync(string? appKey, PickupOpenApiPushFeeInput? input)
+ public async Task PushFeeAsync(PickupOpenApiPushFeeInput? input)
{
- var key = (appKey ?? string.Empty).Trim();
- if (string.IsNullOrWhiteSpace(key))
- {
- throw new BusinessException("appKey 不能为空");
- }
-
if (input == null)
{
throw new BusinessException("请求体不能为空");
@@ -90,8 +71,6 @@ public class PickupOrderFeePushService : IScopedDependency
var pickup = await _pickupOrderRepository.GetByCarrierWaybillNoAsync(carrierWaybillNo)
?? throw new BusinessException($"揽收单不存在:承运运单号 {carrierWaybillNo}");
- await EnsureAppKeyAsync(key, pickup);
-
if (pickup.PayStatus == PickupPayStatusCodes.Paid)
{
throw new BusinessException("揽收单已支付,拒绝修改件重尺与费用");
@@ -240,67 +219,6 @@ public class PickupOrderFeePushService : IScopedDependency
};
}
- private async Task EnsureAppKeyAsync(string appKey, PickupOrder pickup)
- {
- var config = await _otherConfigRepository.GetByConfigCodeAsync(SystemOtherConfigCodes.PickupOpenApi);
- if (config != null && config.Enabled && !string.IsNullOrWhiteSpace(config.AppKey))
- {
- if (!string.Equals(config.AppKey.Trim(), appKey, StringComparison.Ordinal))
- {
- throw new BusinessException("appKey 不正确");
- }
-
- return;
- }
-
- var expected = await ResolvePickupAppKeyAsync(pickup);
- if (string.IsNullOrWhiteSpace(expected)
- || !string.Equals(expected, appKey, StringComparison.Ordinal))
- {
- throw new BusinessException("appKey 不正确");
- }
- }
-
- private async Task ResolvePickupAppKeyAsync(PickupOrder pickup)
- {
- if (string.IsNullOrWhiteSpace(pickup.CarrierService))
- {
- return null;
- }
-
- var product = await _salesProductRepository.GetByProductCodeAsync(pickup.CarrierService.Trim());
- if (product == null)
- {
- return null;
- }
-
- var providers = await _salesProductRepository.GetAllocationProvidersAsync(product.Id);
- var channelId = providers
- .Where(x => x.ServiceChannelId is > 0)
- .OrderBy(x => x.SerialNumber)
- .Select(x => x.ServiceChannelId!.Value)
- .FirstOrDefault();
- if (channelId <= 0)
- {
- return null;
- }
-
- var channel = await _serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId);
- if (channel?.GroupId is not > 0)
- {
- return null;
- }
-
- var group = await _serviceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == channel.GroupId.Value);
- if (group == null || group.AppId <= 0)
- {
- return null;
- }
-
- var app = await _itfxMetadataClient.GetAppAsync(group.AppId);
- return app?.AppKey?.Trim();
- }
-
private async Task<(decimal VolumetricWeight, decimal ChargeableWeight)> CalculateChargeableAsync(
SalesProduct product,
OrderWeight weight)
diff --git a/src/Oms.Application/Services/System/SystemOtherConfigService.cs b/src/Oms.Application/Services/System/SystemOtherConfigService.cs
index c25a817..b213f87 100644
--- a/src/Oms.Application/Services/System/SystemOtherConfigService.cs
+++ b/src/Oms.Application/Services/System/SystemOtherConfigService.cs
@@ -190,10 +190,7 @@ public class SystemOtherConfigService : ApplicationService
}
else if (string.Equals(configCode, SystemOtherConfigCodes.PickupOpenApi, StringComparison.OrdinalIgnoreCase))
{
- if (string.IsNullOrWhiteSpace(input.AppKey))
- {
- throw new BusinessException("揽收开放接口 AppKey 不能为空");
- }
+ // 鉴权已改由 itfx 上游完成;本配置不再强制 AppKey
}
else if (string.Equals(configCode, SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase))
{
diff --git a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs
index 81ad7c1..02f0bd1 100644
--- a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs
+++ b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs
@@ -56,7 +56,7 @@ public static class SystemOtherConfigCodes
public const string ApiCenter = "api_center";
///
- /// 国内揽收服务商推送件重尺/费用:仅校验 AppKey(暂不验签)。
+ /// 国内揽收服务商推送件重尺/费用(历史配置;鉴权已改由 itfx 上游应用完成,OMS 不再校验 AppKey)。
///
public const string PickupOpenApi = "pickup_open_api";
}
--
2.45.2
From 87f4cebc9b38f1f4d358028ae41ef7d8ffc088ca Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Wed, 30 Sep 2026 12:17:50 +0800
Subject: [PATCH 15/41] =?UTF-8?q?feat:=20openApi=20=E7=BB=9F=E4=B8=80?=
=?UTF-8?q?=E4=BF=A1=E5=B0=81=E4=B8=8E=E6=8E=A8=E8=B4=B9=E5=AD=97=E6=AE=B5?=
=?UTF-8?q?=E5=AF=B9=E9=BD=90=EF=BC=8C=E4=B8=8B=E5=8D=95=E9=89=B4=E6=9D=83?=
=?UTF-8?q?=E6=94=B9=E8=AF=B7=E6=B1=82=E5=A4=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
推费入参对齐表字段;创建订单凭证走 appKey/appSecret Header,去掉上门揽收与 Body 内 customerId。
Co-authored-by: Cursor
---
.../Dtos/OpenApi/OpenApiEnvelope.cs | 29 ++++
.../Dtos/Orders/OpenApiParcelCreateInput.cs | 148 +++++++++++++++++-
.../Dtos/Orders/PickupOrderDtos.cs | 37 +++--
.../Services/OpenApi/OpenApiService.cs | 59 +++++--
.../Orders/PickupOrderFeePushService.cs | 30 ++--
.../Shared/CustomerOpenApiAuthHelper.cs | 35 ++++-
6 files changed, 279 insertions(+), 59 deletions(-)
create mode 100644 src/Oms.Application/Dtos/OpenApi/OpenApiEnvelope.cs
diff --git a/src/Oms.Application/Dtos/OpenApi/OpenApiEnvelope.cs b/src/Oms.Application/Dtos/OpenApi/OpenApiEnvelope.cs
new file mode 100644
index 0000000..4d724f0
--- /dev/null
+++ b/src/Oms.Application/Dtos/OpenApi/OpenApiEnvelope.cs
@@ -0,0 +1,29 @@
+namespace Oms.Application.Dtos.OpenApi;
+
+///
+/// 对外开放接口统一响应。
+///
+public class OpenApiEnvelope
+{
+ public string Code { get; set; } = "200";
+
+ public string Msg { get; set; } = "success";
+
+ public T? Data { get; set; }
+
+ public static OpenApiEnvelope Ok(T data) =>
+ new()
+ {
+ Code = "200",
+ Msg = "success",
+ Data = data
+ };
+
+ public static OpenApiEnvelope Fail(string msg, string code = "400") =>
+ new()
+ {
+ Code = code,
+ Msg = msg,
+ Data = default
+ };
+}
diff --git a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs
index c48882c..ccf50e9 100644
--- a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs
@@ -1,17 +1,149 @@
+using Oms.Domain.OrdersAggregate.Consts;
+
namespace Oms.Application.Dtos.Orders;
///
-/// 开放接口创建订单包裹:在标准下单入参上增加 AppKey/AppSecret(Body,非自定义 Header)。
+/// 开放接口创建订单包裹入参:与单票字段对齐(不含上门揽收)。
+/// 鉴权凭证放请求头 appKey / appSecret,勿放入 Body。
///
-public class OpenApiParcelCreateInput : OrderParcelCreateInput
+public class OpenApiParcelCreateInput
{
- ///
- /// 客户开放接口 AppKey。
- ///
- public string? AppKey { get; set; }
+ /// 请求标识(幂等,可选)。
+ public string? RequestId { get; set; }
+
+ /// 客户单号。
+ public string? ClientNo { get; set; }
+
+ /// 跟踪号(可选,系统通常忽略由下游回写)。
+ public string? TrackNo { get; set; }
+
+ /// 服务单号(可选,不传时系统自动生成)。
+ public string? ServiceNo { get; set; }
+
+ /// 产品编码(运输方式)。
+ public string? ProductCode { get; set; }
+
+ /// 渠道编码(可选)。
+ public string? ChannelCode { get; set; }
+
+ /// 货物属性(字典 cargo_attribute:general / electronics / sensitive)。
+ public string? CargoAttribute { get; set; }
+
+ /// 出口报关方式(字典 export_customs_mode)。
+ public string? ExportCustomsMode { get; set; }
+
+ /// 出口资质备案 Id(9610 汇总申报时)。
+ public int? ExportQualificationId { get; set; }
+
+ /// 电商平台(9610 汇总申报时)。
+ public string? PlatformCode { get; set; }
+
+ /// 仓库主键。
+ public int? WarehouseId { get; set; }
+
+ /// 长(cm)。
+ public decimal? Length { get; set; }
+
+ /// 宽(cm)。
+ public decimal? Width { get; set; }
+
+ /// 高(cm)。
+ public decimal? Height { get; set; }
+
+ /// 实重(kg)。
+ public decimal? ActualWeight { get; set; }
+
+ /// 体积重(kg,可选)。
+ public decimal? VolumetricWeight { get; set; }
+
+ /// 计费重(kg,可选)。
+ public decimal? ChargeableWeight { get; set; }
+
+ /// 体积(cm³,可选)。
+ public decimal? Cubic { get; set; }
+
+ /// 是否 COD。
+ public bool IsCod { get; set; }
+
+ /// COD 币种。
+ public string? CodCurrency { get; set; }
+
+ /// COD 金额。
+ public decimal? CodSum { get; set; }
+
+ /// 申报币种(字典 currency)。
+ public string? DeclaredCurrency { get; set; }
+
+ /// 申报总金额(可选,不填可由明细汇总)。
+ public decimal? DeclaredSum { get; set; }
+
+ /// 附加服务字典 code 列表。
+ public List? AdditionalServices { get; set; }
+
+ /// 是否偏远(可选预传)。
+ public byte? IsRemote { get; set; }
+
+ /// 包裹数量。
+ public int? Quantity { get; set; }
+
+ /// 收件人。
+ public OrderParcelReceiverInput? Receiver { get; set; }
+
+ /// 寄件人。
+ public OrderParcelSenderInput? Sender { get; set; }
+
+ /// 申报明细。
+ public List Items { get; set; } = [];
+
+ /// 优惠券实例 Id(可选)。
+ public int? CouponInstanceId { get; set; }
+
+ /// 仅保存草稿,不预报计费。
+ public bool SaveAsDraft { get; set; }
///
- /// 客户开放接口 AppSecret。
+ /// 映射为内部单票创建入参。开放接口固定自发货(selfShip),客户 Id 由凭证解析后写入。
///
- public string? AppSecret { get; set; }
+ public OrderParcelCreateInput ToOrderParcelCreateInput(int customerId) =>
+ new()
+ {
+ RequestId = RequestId,
+ ClientNo = ClientNo,
+ TrackNo = TrackNo,
+ ServiceNo = ServiceNo,
+ ProductCode = ProductCode,
+ ChannelCode = ChannelCode,
+ CargoAttribute = CargoAttribute,
+ ExportCustomsMode = ExportCustomsMode,
+ ExportQualificationId = ExportQualificationId,
+ PlatformCode = PlatformCode,
+ WarehouseId = WarehouseId,
+ CustomerId = customerId,
+ Length = Length,
+ Width = Width,
+ Height = Height,
+ ActualWeight = ActualWeight,
+ VolumetricWeight = VolumetricWeight,
+ ChargeableWeight = ChargeableWeight,
+ Cubic = Cubic,
+ IsCod = IsCod,
+ CodCurrency = CodCurrency,
+ CodSum = CodSum,
+ DeclaredCurrency = DeclaredCurrency,
+ DeclaredSum = DeclaredSum,
+ AdditionalServices = AdditionalServices,
+ IsRemote = IsRemote,
+ Quantity = Quantity,
+ Receiver = Receiver,
+ Sender = Sender,
+ Items = Items ?? [],
+ CouponInstanceId = CouponInstanceId,
+ SaveAsDraft = SaveAsDraft,
+ InboundMethod = OrderInboundMethodCodes.SelfShip,
+ SenderAddressId = null,
+ ExpectPickupTime = null,
+ PickupDirection = null,
+ CarrierCode = null,
+ CarrierService = null
+ };
}
diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
index ab046bc..d9fd2fd 100644
--- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
@@ -344,10 +344,6 @@ public class PickupFeeLineOutput
public class PickupCarrierQuoteOutput
{
- public string? ServiceMode { get; set; }
-
- public string? PayMode { get; set; }
-
public List ValueInfoList { get; set; } = [];
}
@@ -364,7 +360,7 @@ public class PickupOpenApiPushFeeResult
{
public string PickupNo { get; set; } = string.Empty;
- public decimal ActualWeight { get; set; }
+ public decimal TotalActualWeight { get; set; }
public decimal ChargeableWeight { get; set; }
@@ -372,31 +368,34 @@ public class PickupOpenApiPushFeeResult
public decimal ReceivableAmount { get; set; }
- public decimal PayableAmount { get; set; }
+ public decimal? CarrierPayableAmount { get; set; }
public decimal? CarrierQuotedFreightAmount { get; set; }
}
+///
+/// 承运推送件重尺入参(字段名对齐 t_pickup_order)。
+///
public class PickupOpenApiPushFeeInput
{
- ///
- /// 承运运单号,对应库字段 CarrierWaybillNo。
- ///
- public string? WaybillNumber { get; set; }
+ /// 承运运单号。
+ public string? CarrierWaybillNo { get; set; }
- public decimal ActualWeight { get; set; }
+ /// 实重合计。
+ public decimal TotalActualWeight { get; set; }
- public decimal? FreightWeight { get; set; }
+ /// 承运计费重(对照)。
+ public decimal? CarrierFreightWeight { get; set; }
- public decimal? TotalFreightAmount { get; set; }
+ /// 承运报价运费合计(对照)。
+ public decimal? CarrierQuotedFreightAmount { get; set; }
- public decimal? ReceivableAmount { get; set; }
+ /// 承运报文应收(对照)。
+ public decimal? CarrierQuotedReceivableAmount { get; set; }
+ /// 承运附加费明细(写入 CarrierQuoteJson)。
public List? ValueInfoList { get; set; }
- public List? GoodsSizeList { get; set; }
-
- public string? ServiceMode { get; set; }
-
- public string? PayMode { get; set; }
+ /// 尺寸明细(写入 PackageSizesJson)。
+ public List? PackageSizes { get; set; }
}
diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
index 189b4ef..2b5c66f 100644
--- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs
+++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
@@ -1,6 +1,7 @@
using Fake.Application;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.OpenApi;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders;
using Oms.Application.Shared;
@@ -9,7 +10,8 @@ namespace Oms.Application.Services.OpenApi;
///
/// 对外开放 API(Swagger 文档分组:openApi)。
-/// 客户机机:AppKey/AppSecret 放在 JSON Body;供应商推费由上游 itfx 鉴权,OMS 不再验 key。
+/// 统一响应:{ code, msg, data }。
+/// 客户机机鉴权:请求头 appKey + appSecret(勿放 Body,避免落日志)。
///
[ApiExplorerSettings(GroupName = "openApi")]
public class OpenApiService : ApplicationService
@@ -24,29 +26,58 @@ public class OpenApiService : ApplicationService
LazyServiceProvider.GetRequiredService();
///
- /// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需 AppKey)。
+ /// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需客户凭证)。
///
[AllowAnonymous]
[HttpPost("/openapi/pickup-order/push-fee")]
- public Task PushFeeAsync([FromBody] PickupOpenApiPushFeeInput input) =>
- FeePush.PushFeeAsync(input);
+ public async Task> PushFeeAsync(
+ [FromBody] PickupOpenApiPushFeeInput input)
+ {
+ try
+ {
+ var data = await FeePush.PushFeeAsync(input);
+ return OpenApiEnvelope.Ok(data);
+ }
+ catch (BusinessException ex)
+ {
+ return OpenApiEnvelope.Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ return OpenApiEnvelope.Fail(
+ string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message,
+ "500");
+ }
+ }
///
- /// 创建订单包裹(客户机机:Body 传 AppKey、AppSecret)。
+ /// 创建订单包裹(仅自发货)。
+ /// 鉴权:Header appKey、appSecret;客户归属强制取自凭证,Body 不可指定 customerId。
///
[AllowAnonymous]
[HttpPost("/openapi/parcel/create")]
- public async Task CreateParcelAsync([FromBody] OpenApiParcelCreateInput input)
+ public async Task> CreateParcelAsync(
+ [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.AppSecretHeaderName)] string? appSecret,
+ [FromBody] OpenApiParcelCreateInput input)
{
- ArgumentNullException.ThrowIfNull(input);
-
- var (customer, _) = await AuthHelper.ResolveAsync(input.AppKey, input.AppSecret);
- if (input.CustomerId is > 0 && input.CustomerId.Value != customer.Id)
+ try
{
- throw new BusinessException("凭证无效");
- }
+ ArgumentNullException.ThrowIfNull(input);
- input.CustomerId = customer.Id;
- return await OrderService.CreateParcelAsync(input);
+ var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret);
+ var data = await OrderService.CreateParcelAsync(input.ToOrderParcelCreateInput(customer.Id));
+ return OpenApiEnvelope.Ok(data);
+ }
+ catch (BusinessException ex)
+ {
+ return OpenApiEnvelope.Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ return OpenApiEnvelope.Fail(
+ string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message,
+ "500");
+ }
}
}
diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
index 37fcafc..7cb00fd 100644
--- a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
+++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
@@ -51,21 +51,21 @@ public class PickupOrderFeePushService : IScopedDependency
throw new BusinessException("请求体不能为空");
}
- var carrierWaybillNo = (input.WaybillNumber ?? string.Empty).Trim();
+ var carrierWaybillNo = (input.CarrierWaybillNo ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(carrierWaybillNo))
{
- throw new BusinessException("waybillNumber(承运运单号 CarrierWaybillNo)不能为空");
+ throw new BusinessException("carrierWaybillNo(承运运单号)不能为空");
}
- if (input.ActualWeight <= 0)
+ if (input.TotalActualWeight <= 0)
{
- throw new BusinessException("actualWeight 必须大于 0");
+ throw new BusinessException("totalActualWeight 必须大于 0");
}
- var sizes = NormalizeSizes(input.GoodsSizeList);
+ var sizes = NormalizeSizes(input.PackageSizes);
if (sizes.Count == 0)
{
- throw new BusinessException("goodsSizeList 不能为空");
+ throw new BusinessException("packageSizes 不能为空");
}
var pickup = await _pickupOrderRepository.GetByCarrierWaybillNoAsync(carrierWaybillNo)
@@ -97,7 +97,7 @@ public class PickupOrderFeePushService : IScopedDependency
Length = totalVolume,
Width = 1,
Height = 1,
- ActualWeight = input.ActualWeight,
+ ActualWeight = input.TotalActualWeight,
Cubic = ParcelBillingCalculator.CalculateCubic(totalVolume, 1, 1)
};
@@ -184,17 +184,15 @@ public class PickupOrderFeePushService : IScopedDependency
var freight = receivableFees.FirstOrDefault()?.Amount ?? receivableTotal;
var other = Math.Max(0, receivableTotal - freight);
- pickup.TotalActualWeight = input.ActualWeight;
+ pickup.TotalActualWeight = input.TotalActualWeight;
pickup.VolumetricWeight = volumetricWeight;
pickup.ChargeableWeight = chargeableWeight;
pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes);
- pickup.CarrierFreightWeight = input.FreightWeight;
- pickup.CarrierQuotedFreightAmount = input.TotalFreightAmount;
- pickup.CarrierQuotedReceivableAmount = input.ReceivableAmount;
+ pickup.CarrierFreightWeight = input.CarrierFreightWeight;
+ pickup.CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount;
+ pickup.CarrierQuotedReceivableAmount = input.CarrierQuotedReceivableAmount;
pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput
{
- ServiceMode = input.ServiceMode,
- PayMode = input.PayMode,
ValueInfoList = input.ValueInfoList ?? []
});
pickup.ReceivableFeeJson = OmsDocHelper.SerializeJson(receivableFees);
@@ -210,12 +208,12 @@ public class PickupOrderFeePushService : IScopedDependency
return new PickupOpenApiPushFeeResult
{
PickupNo = pickup.PickupNo,
- ActualWeight = input.ActualWeight,
+ TotalActualWeight = input.TotalActualWeight,
ChargeableWeight = chargeableWeight,
VolumetricWeight = volumetricWeight,
ReceivableAmount = receivableTotal,
- PayableAmount = payableTotal,
- CarrierQuotedFreightAmount = input.TotalFreightAmount
+ CarrierPayableAmount = payableTotal,
+ CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount
};
}
diff --git a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
index 94e7458..b65658c 100644
--- a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
+++ b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
@@ -1,3 +1,5 @@
+using System.Security.Cryptography;
+using System.Text;
using Fake.Application;
using Fake.DependencyInjection;
using Microsoft.AspNetCore.Http;
@@ -8,10 +10,13 @@ using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared;
///
-/// 客户 OpenAPI 机机鉴权:按 AppKey+AppSecret 解析主客户,供 whoami 及后续业务接口复用。
+/// 客户 OpenAPI 机机鉴权:按请求头 AppKey + AppSecret 解析主客户。
///
public class CustomerOpenApiAuthHelper : IScopedDependency
{
+ public const string AppKeyHeaderName = "appKey";
+ public const string AppSecretHeaderName = "appSecret";
+
private readonly ICustomerOpenApiCredentialRepository _credentialRepository;
private readonly ICustomerRepository _customerRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
@@ -26,6 +31,19 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
_httpContextAccessor = httpContextAccessor;
}
+ ///
+ /// 从当前请求头读取 appKey / appSecret 并鉴权。
+ ///
+ public Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveFromHeadersAsync(
+ CancellationToken cancellationToken = default)
+ {
+ var http = _httpContextAccessor.HttpContext
+ ?? throw new BusinessException("凭证无效");
+ var appKey = http.Request.Headers[AppKeyHeaderName].FirstOrDefault();
+ var appSecret = http.Request.Headers[AppSecretHeaderName].FirstOrDefault();
+ return ResolveAsync(appKey, appSecret, cancellationToken);
+ }
+
public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveAsync(
string? appKey,
string? appSecret,
@@ -41,7 +59,7 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken);
if (credential == null
|| !credential.Enabled
- || !string.Equals(credential.AppSecret, secret, StringComparison.Ordinal))
+ || !FixedTimeEqualsUtf8(credential.AppSecret, secret))
{
throw new BusinessException("凭证无效");
}
@@ -63,6 +81,19 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
return (customer, credential);
}
+ private static bool FixedTimeEqualsUtf8(string? expected, string actual)
+ {
+ if (string.IsNullOrEmpty(expected))
+ {
+ return false;
+ }
+
+ var left = Encoding.UTF8.GetBytes(expected);
+ var right = Encoding.UTF8.GetBytes(actual);
+ return left.Length == right.Length
+ && CryptographicOperations.FixedTimeEquals(left, right);
+ }
+
private string? ResolveClientIp()
{
var http = _httpContextAccessor.HttpContext;
--
2.45.2
From 1b6e91b09d0502d6354a05fbbdb77c5da5299404 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Wed, 30 Sep 2026 17:33:46 +0800
Subject: [PATCH 16/41] =?UTF-8?q?feat:=20OpenAPI=20=E9=89=B4=E6=9D=83?=
=?UTF-8?q?=E6=94=B9=20HMAC=20=E7=AD=BE=E5=90=8D=E6=A0=A1=E9=AA=8C?=
=?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84=E4=B8=8B=E5=8D=95=E6=8A=A5?=
=?UTF-8?q?=E6=96=87=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
.../Dtos/Orders/OpenApiParcelCreateInput.cs | 5 +-
.../Services/OpenApi/OpenApiService.cs | 55 +++++++++++--
.../Shared/CustomerOpenApiAuthHelper.cs | 78 ++++++++++++++-----
src/SimpleAdmin.Api/SimpleAdminApiModule.cs | 11 +++
4 files changed, 121 insertions(+), 28 deletions(-)
diff --git a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs
index ccf50e9..dcfb695 100644
--- a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs
@@ -8,9 +8,6 @@ namespace Oms.Application.Dtos.Orders;
///
public class OpenApiParcelCreateInput
{
- /// 请求标识(幂等,可选)。
- public string? RequestId { get; set; }
-
/// 客户单号。
public string? ClientNo { get; set; }
@@ -107,7 +104,7 @@ public class OpenApiParcelCreateInput
public OrderParcelCreateInput ToOrderParcelCreateInput(int customerId) =>
new()
{
- RequestId = RequestId,
+ RequestId = null,
ClientNo = ClientNo,
TrackNo = TrackNo,
ServiceNo = ServiceNo,
diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
index 2b5c66f..0076fbb 100644
--- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs
+++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
@@ -1,5 +1,8 @@
+using System.Text;
+using System.Text.Json;
using Fake.Application;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.OpenApi;
using Oms.Application.Dtos.Orders;
@@ -11,11 +14,16 @@ namespace Oms.Application.Services.OpenApi;
///
/// 对外开放 API(Swagger 文档分组:openApi)。
/// 统一响应:{ code, msg, data }。
-/// 客户机机鉴权:请求头 appKey + appSecret(勿放 Body,避免落日志)。
+/// 客户下单鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。
///
[ApiExplorerSettings(GroupName = "openApi")]
public class OpenApiService : ApplicationService
{
+ private static readonly JsonSerializerOptions BodyJsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
private CustomerOpenApiAuthHelper AuthHelper =>
LazyServiceProvider.GetRequiredService();
@@ -25,6 +33,9 @@ public class OpenApiService : ApplicationService
private OrderService OrderService =>
LazyServiceProvider.GetRequiredService();
+ private IHttpContextAccessor HttpContextAccessor =>
+ LazyServiceProvider.GetRequiredService();
+
///
/// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需客户凭证)。
///
@@ -52,20 +63,27 @@ public class OpenApiService : ApplicationService
///
/// 创建订单包裹(仅自发货)。
- /// 鉴权:Header appKey、appSecret;客户归属强制取自凭证,Body 不可指定 customerId。
+ /// Header:appKey、timestamp(Unix 秒)、sign。
+ /// sign = hex(HMAC-SHA256(appSecret, appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)))。
///
[AllowAnonymous]
[HttpPost("/openapi/parcel/create")]
public async Task> CreateParcelAsync(
[FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
- [FromHeader(Name = CustomerOpenApiAuthHelper.AppSecretHeaderName)] string? appSecret,
- [FromBody] OpenApiParcelCreateInput input)
+ [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign)
{
try
{
- ArgumentNullException.ThrowIfNull(input);
+ var rawBody = await ReadRawBodyAsync();
+ var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody);
+
+ var input = JsonSerializer.Deserialize(rawBody, BodyJsonOptions);
+ if (input == null)
+ {
+ throw new BusinessException("请求体不能为空");
+ }
- var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret);
var data = await OrderService.CreateParcelAsync(input.ToOrderParcelCreateInput(customer.Id));
return OpenApiEnvelope.Ok(data);
}
@@ -73,6 +91,10 @@ public class OpenApiService : ApplicationService
{
return OpenApiEnvelope.Fail(ex.Message);
}
+ catch (JsonException)
+ {
+ return OpenApiEnvelope.Fail("请求体格式错误");
+ }
catch (Exception ex)
{
return OpenApiEnvelope.Fail(
@@ -80,4 +102,25 @@ public class OpenApiService : ApplicationService
"500");
}
}
+
+ private async Task ReadRawBodyAsync()
+ {
+ var http = HttpContextAccessor.HttpContext
+ ?? throw new BusinessException("凭证无效");
+ var request = http.Request;
+ request.EnableBuffering();
+ if (request.Body.CanSeek)
+ {
+ request.Body.Position = 0;
+ }
+
+ using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
+ var raw = await reader.ReadToEndAsync();
+ if (request.Body.CanSeek)
+ {
+ request.Body.Position = 0;
+ }
+
+ return raw;
+ }
}
diff --git a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
index b65658c..0306371 100644
--- a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
+++ b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
@@ -10,12 +10,16 @@ using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared;
///
-/// 客户 OpenAPI 机机鉴权:按请求头 AppKey + AppSecret 解析主客户。
+/// 客户 OpenAPI 机机鉴权:appKey + timestamp + HMAC-SHA256(sign),Secret 仅本地参与签名。
///
public class CustomerOpenApiAuthHelper : IScopedDependency
{
public const string AppKeyHeaderName = "appKey";
- public const string AppSecretHeaderName = "appSecret";
+ public const string TimestampHeaderName = "timestamp";
+ public const string SignHeaderName = "sign";
+
+ /// 签名时间窗(秒)。
+ public const int TimestampSkewSeconds = 300;
private readonly ICustomerOpenApiCredentialRepository _credentialRepository;
private readonly ICustomerRepository _customerRepository;
@@ -32,38 +36,52 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
}
///
- /// 从当前请求头读取 appKey / appSecret 并鉴权。
+ /// 校验 Header 签名并解析主客户。
+ /// stringToSign = appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)
+ /// sign = hex(HMAC-SHA256(appSecret, stringToSign))
///
- public Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveFromHeadersAsync(
- CancellationToken cancellationToken = default)
- {
- var http = _httpContextAccessor.HttpContext
- ?? throw new BusinessException("凭证无效");
- var appKey = http.Request.Headers[AppKeyHeaderName].FirstOrDefault();
- var appSecret = http.Request.Headers[AppSecretHeaderName].FirstOrDefault();
- return ResolveAsync(appKey, appSecret, cancellationToken);
- }
-
- public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveAsync(
+ public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveSignedAsync(
string? appKey,
- string? appSecret,
+ string? timestamp,
+ string? sign,
+ string? rawBody,
CancellationToken cancellationToken = default)
{
var key = appKey?.Trim();
- var secret = appSecret?.Trim();
- if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret))
+ var tsRaw = timestamp?.Trim();
+ var signRaw = sign?.Trim();
+ if (string.IsNullOrWhiteSpace(key)
+ || string.IsNullOrWhiteSpace(tsRaw)
+ || string.IsNullOrWhiteSpace(signRaw))
{
throw new BusinessException("凭证无效");
}
+ if (!long.TryParse(tsRaw, out var unixSeconds))
+ {
+ throw new BusinessException("请求已过期");
+ }
+
+ var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ if (Math.Abs(now - unixSeconds) > TimestampSkewSeconds)
+ {
+ throw new BusinessException("请求已过期");
+ }
+
var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken);
if (credential == null
|| !credential.Enabled
- || !FixedTimeEqualsUtf8(credential.AppSecret, secret))
+ || string.IsNullOrWhiteSpace(credential.AppSecret))
{
throw new BusinessException("凭证无效");
}
+ var expectedSign = ComputeSign(key, tsRaw, rawBody ?? string.Empty, credential.AppSecret);
+ if (!FixedTimeEqualsUtf8(expectedSign, signRaw.ToLowerInvariant()))
+ {
+ throw new BusinessException("签名无效");
+ }
+
var customer = await _customerRepository.FirstOrDefaultAsync(
x => x.Id == credential.CustomerId,
cancellationToken);
@@ -81,6 +99,30 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
return (customer, credential);
}
+ ///
+ /// 客户端可复用:计算 sign(小写 hex)。
+ ///
+ public static string ComputeSign(string appKey, string timestamp, string rawBody, string appSecret)
+ {
+ var bodyHash = Sha256Hex(rawBody ?? string.Empty);
+ var stringToSign = $"{appKey}\n{timestamp}\n{bodyHash}";
+ return HmacSha256Hex(appSecret, stringToSign);
+ }
+
+ private static string Sha256Hex(string text)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(text));
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+
+ private static string HmacSha256Hex(string secret, string content)
+ {
+ var keyBytes = Encoding.UTF8.GetBytes(secret);
+ var contentBytes = Encoding.UTF8.GetBytes(content);
+ var hash = HMACSHA256.HashData(keyBytes, contentBytes);
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+
private static bool FixedTimeEqualsUtf8(string? expected, string actual)
{
if (string.IsNullOrEmpty(expected))
diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
index 0d0e2a1..b24c7ff 100644
--- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
+++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
@@ -167,6 +167,17 @@ public class SimpleAdminApiModule : FakeModule
// 路由 - 必须在认证和授权之前
app.UseRouting();
+ // OpenAPI 验签需读取原始 Body,允许二次读流
+ app.Use(async (ctx, next) =>
+ {
+ if (ctx.Request.Path.StartsWithSegments("/openapi"))
+ {
+ ctx.Request.EnableBuffering();
+ }
+
+ await next();
+ });
+
// 认证和授权
app.UseAuthentication();
app.UseAuthorization();
--
2.45.2
From 79099776a52eaa0a6cb2310de4d132d024348d69 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Mon, 5 Oct 2026 11:23:38 +0800
Subject: [PATCH 17/41] =?UTF-8?q?feat:=20=E5=90=8C=E4=B8=80=E4=BA=A7?=
=?UTF-8?q?=E5=93=81/=E6=B8=A0=E9=81=93=E4=B8=8B=E5=8D=96=E4=BB=B7?=
=?UTF-8?q?=E4=B9=B0=E4=BB=B7=E8=B4=B9=E7=94=A8=E9=A1=B9=E5=94=AF=E4=B8=80?=
=?UTF-8?q?=EF=BC=8C=E7=A6=81=E6=AD=A2=E9=87=8D=E5=A4=8D=E6=96=B0=E5=A2=9E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
.../Channels/SalesProductService.Helpers.cs | 41 ++++++++++++++++++-
.../Services/Channels/SalesProductService.cs | 2 +
.../Channels/ServiceChannelService.Helpers.cs | 41 ++++++++++++++++++-
.../Channels/ServiceChannelService.cs | 2 +
4 files changed, 84 insertions(+), 2 deletions(-)
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
index 344f1ab..5cf568d 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
@@ -388,10 +388,11 @@ public partial class SalesProductService
};
///
- /// 保险/偏远费等附加费用项的计费条件校验。
+ /// 保险/偏远费等附加费用项的计费条件校验;同一提交批次内费用项不可重复。
///
private static void ValidateBillingPriceItemRules(IReadOnlyList prices)
{
+ EnsureUniqueCostItemsInBatch(prices.Select(x => x.CostItem));
foreach (var price in prices)
{
var costItem = price.CostItem?.Trim() ?? string.Empty;
@@ -417,6 +418,44 @@ public partial class SalesProductService
}
}
+ ///
+ /// 同一销售产品下每种费用项只能有一条卖价配置。
+ ///
+ private async Task EnsureCostItemUniqueAsync(int salesProductId, string? costItem, int? excludePriceId)
+ {
+ var trimmed = costItem?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(trimmed))
+ {
+ throw new Fake.Application.BusinessException("费用项不能为空");
+ }
+
+ var existing = await SalesProductRepository.GetPricesAsync(salesProductId);
+ var duplicated = existing.Any(p =>
+ (!excludePriceId.HasValue || p.Id != excludePriceId.Value)
+ && string.Equals(p.CostItem?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase));
+ if (duplicated)
+ {
+ throw new Fake.Application.BusinessException(
+ $"该产品已存在费用项「{trimmed}」,同一产品下每种费用项只能有一条");
+ }
+ }
+
+ private static void EnsureUniqueCostItemsInBatch(IEnumerable costItems)
+ {
+ var duplicates = costItems
+ .Select(x => x?.Trim() ?? string.Empty)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .GroupBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .Where(g => g.Count() > 1)
+ .Select(g => g.Key)
+ .ToList();
+ if (duplicates.Count > 0)
+ {
+ throw new Fake.Application.BusinessException(
+ $"同一产品下费用项不能重复:{string.Join("、", duplicates)}");
+ }
+ }
+
private static string? NormalizeAdditionalServices(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs
index 3b86f15..29ddaf2 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.cs
@@ -159,6 +159,7 @@ public partial class SalesProductService : ApplicationService
{
await GetProductOrThrowAsync(input.SalesProductId);
ValidateBillingPriceItemRules([input]);
+ await EnsureCostItemUniqueAsync(input.SalesProductId, input.CostItem, excludePriceId: null);
await SalesProductRepository.InsertPriceAsync(
new SalesProductPrice
{
@@ -190,6 +191,7 @@ public partial class SalesProductService : ApplicationService
}
ValidateBillingPriceItemRules([input]);
+ await EnsureCostItemUniqueAsync(input.SalesProductId, input.CostItem, excludePriceId: price.Id);
price.SerialNumber = input.SerialNumber;
price.Status = input.Status;
price.PriceName = input.PriceName;
diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
index 2a00598..0f8c182 100644
--- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
@@ -329,10 +329,11 @@ public partial class ServiceChannelService
}
///
- /// 买价偏远费等附加费用项的计费条件校验。
+ /// 买价偏远费等附加费用项的计费条件校验;同一提交批次内费用项不可重复。
///
private static void ValidateBillingPriceItemRules(IReadOnlyList prices)
{
+ EnsureUniqueCostItemsInBatch(prices.Select(x => x.CostItem));
foreach (var price in prices)
{
var costItem = price.CostItem?.Trim() ?? string.Empty;
@@ -349,6 +350,44 @@ public partial class ServiceChannelService
}
}
+ ///
+ /// 同一服务渠道下每种费用项只能有一条买价配置。
+ ///
+ private async Task EnsureCostItemUniqueAsync(int serviceChannelId, string? costItem, int? excludePriceId)
+ {
+ var trimmed = costItem?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(trimmed))
+ {
+ throw new Fake.Application.BusinessException("费用项不能为空");
+ }
+
+ var existing = await ServiceChannelRepository.GetPricesAsync(serviceChannelId);
+ var duplicated = existing.Any(p =>
+ (!excludePriceId.HasValue || p.Id != excludePriceId.Value)
+ && string.Equals(p.CostItem?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase));
+ if (duplicated)
+ {
+ throw new Fake.Application.BusinessException(
+ $"该渠道已存在费用项「{trimmed}」,同一渠道下每种费用项只能有一条");
+ }
+ }
+
+ private static void EnsureUniqueCostItemsInBatch(IEnumerable costItems)
+ {
+ var duplicates = costItems
+ .Select(x => x?.Trim() ?? string.Empty)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .GroupBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .Where(g => g.Count() > 1)
+ .Select(g => g.Key)
+ .ToList();
+ if (duplicates.Count > 0)
+ {
+ throw new Fake.Application.BusinessException(
+ $"同一渠道下费用项不能重复:{string.Join("、", duplicates)}");
+ }
+ }
+
private static ServiceChannelGroupListOutput MapGroupListOutput(ServiceChannelGroup entity)
{
return new ServiceChannelGroupListOutput
diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs
index 7188468..137cd20 100644
--- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs
+++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs
@@ -223,6 +223,7 @@ public partial class ServiceChannelService : ApplicationService
{
await GetEntityOrThrowAsync(input.ServiceChannelId);
ValidateBillingPriceItemRules([input]);
+ await EnsureCostItemUniqueAsync(input.ServiceChannelId, input.CostItem, excludePriceId: null);
await ServiceChannelRepository.InsertPriceAsync(
new ServiceChannelPrice
{
@@ -254,6 +255,7 @@ public partial class ServiceChannelService : ApplicationService
}
ValidateBillingPriceItemRules([input]);
+ await EnsureCostItemUniqueAsync(input.ServiceChannelId, input.CostItem, excludePriceId: price.Id);
price.SerialNumber = input.SerialNumber;
price.Status = input.Status;
price.PriceName = input.PriceName;
--
2.45.2
From b17e6639a4c7bde54ad9d287f2f7d2b107bfeb14 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Mon, 5 Oct 2026 11:53:08 +0800
Subject: [PATCH 18/41] =?UTF-8?q?fix:=20=E4=B8=BB=E6=95=B0=E6=8D=AE?=
=?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=85=BC=E5=AE=B9=E8=BD=AF=E5=88=A0=E5=94=AF?=
=?UTF-8?q?=E4=B8=80=E9=94=AE=EF=BC=8C=E9=81=BF=E5=85=8D=20Duplicate=20ent?=
=?UTF-8?q?ry?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
.../Services/Ai/AiSkillService.cs | 9 ++++
.../Channels/SalesProductService.Helpers.cs | 15 ++++++
.../Services/Channels/SalesProductService.cs | 11 ++++
.../Channels/ServiceChannelService.Helpers.cs | 15 ++++++
.../Channels/ServiceChannelService.cs | 11 ++++
.../Services/Customers/CustomerService.cs | 52 +++++++++++++++++++
.../Services/Kb/KbCategoryService.cs | 17 +++++-
.../Services/System/DictService.cs | 26 +++++++---
.../System/SystemOtherConfigService.cs | 29 ++++++++++-
.../Services/System/WarehouseService.cs | 30 +++++++++--
.../Warehouse/WarehouseService.BinLocation.cs | 22 +++++++-
.../Warehouse/WarehouseService.Sku.cs | 18 ++++++-
.../Repositories/IAiSkillRepository.cs | 4 ++
.../Repositories/ISalesProductRepository.cs | 4 ++
.../Repositories/IServiceChannelRepository.cs | 4 ++
.../Repositories/ICustomerRepository.cs | 8 +++
.../Repositories/IKbCategoryRepository.cs | 2 +
.../Repositories/IDictTypeRepository.cs | 7 +++
.../ISystemOtherConfigRepository.cs | 4 ++
.../IWarehouseAddressRepository.cs | 4 ++
.../Repositories/IWarehouseRepositories.cs | 16 +++++-
src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs | 11 ++++
.../Channels/SalesProductRepository.cs | 11 ++++
.../Channels/ServiceChannelRepository.cs | 11 ++++
.../Customers/CustomerRepository.cs | 33 ++++++++++++
.../Kb/KbCategoryRepository.cs | 11 ++++
.../Repositories/SoftDeleteQuery.cs | 24 +++++++++
.../System/DictTypeRepository.cs | 13 +++++
.../System/SystemOtherConfigRepository.cs | 11 ++++
.../System/WarehouseAddressRepository.cs | 11 ++++
.../Warehouse/WarehouseRepositories.cs | 30 ++++++++++-
31 files changed, 451 insertions(+), 23 deletions(-)
create mode 100644 src/Oms.SqlSugarCore/Repositories/SoftDeleteQuery.cs
diff --git a/src/Oms.Application/Services/Ai/AiSkillService.cs b/src/Oms.Application/Services/Ai/AiSkillService.cs
index 775d83f..ce85056 100644
--- a/src/Oms.Application/Services/Ai/AiSkillService.cs
+++ b/src/Oms.Application/Services/Ai/AiSkillService.cs
@@ -93,6 +93,7 @@ public class AiSkillService : ApplicationService
}
entity ??= await AiSkillRepository.GetBySkillKeyAsync(skillKey);
+ entity ??= await AiSkillRepository.GetBySkillKeyIncludingDeletedAsync(skillKey);
if (entity == null)
{
@@ -106,9 +107,17 @@ public class AiSkillService : ApplicationService
throw new BusinessException($"SkillKey 已存在:{skillKey}");
}
+ var softDeleted = await AiSkillRepository.GetBySkillKeyIncludingDeletedAsync(skillKey);
+ if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id)
+ {
+ softDeleted.SkillKey = $"{skillKey}__deleted_{softDeleted.Id}";
+ await AiSkillRepository.UpdateAsync(softDeleted);
+ }
+
entity.SkillKey = skillKey;
}
+ entity.IsDeleted = false;
entity.Scene = scene;
entity.Name = input.Name.Trim();
entity.Description = NormalizeOptional(input.Description);
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
index 5cf568d..5c3203d 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
@@ -23,6 +23,21 @@ public partial class SalesProductService
}
}
+ ///
+ /// 更新时若目标编码被软删记录占用,改名释放 uk_sp_code。
+ ///
+ private async Task FreeSoftDeletedProductCodeIfNeededAsync(string productCode, int currentId)
+ {
+ var conflict = await SalesProductRepository.GetByProductCodeIncludingDeletedAsync(productCode);
+ if (conflict == null || !conflict.IsDeleted || conflict.Id == currentId)
+ {
+ return;
+ }
+
+ conflict.ProductCode = $"{productCode}__deleted_{conflict.Id}";
+ await SalesProductRepository.UpdateAsync(conflict);
+ }
+
///
/// 获取销售产品,不存在时直接抛异常。
///
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs
index 29ddaf2..9ebf67f 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.cs
@@ -75,6 +75,16 @@ public partial class SalesProductService : ApplicationService
public async Task CreateAsync(SalesProductSaveInput input)
{
await ValidateProductCodeAsync(input.ProductCode, null);
+ var code = input.ProductCode.Trim();
+ var deleted = await SalesProductRepository.GetByProductCodeIncludingDeletedAsync(code);
+ if (deleted != null && deleted.IsDeleted)
+ {
+ deleted.IsDeleted = false;
+ ApplyProductInput(deleted, input);
+ await SalesProductRepository.UpdateAsync(deleted);
+ return;
+ }
+
var entity = new SalesProduct();
ApplyProductInput(entity, input);
await SalesProductRepository.InsertAsync(entity);
@@ -90,6 +100,7 @@ public partial class SalesProductService : ApplicationService
{
var entity = await GetProductOrThrowAsync(input.Id);
await ValidateProductCodeAsync(input.ProductCode, entity.Id);
+ await FreeSoftDeletedProductCodeIfNeededAsync(input.ProductCode.Trim(), entity.Id);
ApplyProductInput(entity, input);
await SalesProductRepository.UpdateAsync(entity);
}
diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
index 0f8c182..eb303e8 100644
--- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
@@ -91,6 +91,21 @@ public partial class ServiceChannelService
}
}
+ ///
+ /// 更新时若目标编码被软删记录占用,改名释放 uk_sc_code。
+ ///
+ private async Task FreeSoftDeletedChannelCodeIfNeededAsync(string channelCode, int currentId)
+ {
+ var conflict = await ServiceChannelRepository.GetByChannelCodeIncludingDeletedAsync(channelCode);
+ if (conflict == null || !conflict.IsDeleted || conflict.Id == currentId)
+ {
+ return;
+ }
+
+ conflict.ChannelCode = $"{channelCode}__deleted_{conflict.Id}";
+ await ServiceChannelRepository.UpdateAsync(conflict);
+ }
+
///
/// 获取服务渠道,不存在时直接抛异常。
///
diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs
index 137cd20..061ab28 100644
--- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs
+++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs
@@ -73,6 +73,16 @@ public partial class ServiceChannelService : ApplicationService
public async Task CreateAsync(ServiceChannelSaveInput input)
{
await ValidateChannelCodeAsync(input.ChannelCode, null);
+ var code = input.ChannelCode.Trim();
+ var deleted = await ServiceChannelRepository.GetByChannelCodeIncludingDeletedAsync(code);
+ if (deleted != null && deleted.IsDeleted)
+ {
+ deleted.IsDeleted = false;
+ ApplyInput(deleted, input);
+ await ServiceChannelRepository.UpdateAsync(deleted);
+ return;
+ }
+
var entity = new ServiceChannel();
ApplyInput(entity, input);
await ServiceChannelRepository.InsertAsync(entity);
@@ -88,6 +98,7 @@ public partial class ServiceChannelService : ApplicationService
{
var entity = await GetEntityOrThrowAsync(input.Id);
await ValidateChannelCodeAsync(input.ChannelCode, entity.Id);
+ await FreeSoftDeletedChannelCodeIfNeededAsync(input.ChannelCode.Trim(), entity.Id);
ApplyInput(entity, input);
await ServiceChannelRepository.UpdateAsync(entity);
}
diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs
index 21550cd..8e6bed7 100644
--- a/src/Oms.Application/Services/Customers/CustomerService.cs
+++ b/src/Oms.Application/Services/Customers/CustomerService.cs
@@ -178,6 +178,12 @@ public class CustomerService : ApplicationService
NormalizeRequiredText(input.Email, "邮箱"),
WeChatRegistrationFieldNormalizer.EmailMaxLength);
+ await FreeSoftDeletedCustomerIdentityIfNeededAsync(
+ customer.CustomerCode,
+ phone,
+ email,
+ excludeId: customer.Id);
+
var duplicatedPhone = await CustomerRepository.GetByPhoneAsync(phone, cancellationToken);
if (duplicatedPhone != null && duplicatedPhone.Id != customer.Id)
{
@@ -216,6 +222,10 @@ public class CustomerService : ApplicationService
{
var normalized = await NormalizeCreateCustomerInputAsync(input, "客户编号", "客户名称");
+ await FreeSoftDeletedCustomerIdentityIfNeededAsync(
+ normalized.CustomerCode,
+ normalized.Phone,
+ normalized.Email);
await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email);
var customerRoleId = await GetCustomerRoleIdOrThrowAsync();
@@ -268,6 +278,10 @@ public class CustomerService : ApplicationService
var normalized = await NormalizeCreateCustomerInputAsync(input, "子客户编号", "子客户名称");
var permissions = await NormalizeAndValidateSubCustomerPermissionsAsync(input.Permissions);
+ await FreeSoftDeletedCustomerIdentityIfNeededAsync(
+ normalized.CustomerCode,
+ normalized.Phone,
+ normalized.Email);
await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email);
var user = await CreateRbacUserAsync(input, normalized);
@@ -550,8 +564,46 @@ public class CustomerService : ApplicationService
}
}
+ ///
+ /// 客户创建会新建 RBAC 用户,不适合直接恢复软删行;释放软删占用的唯一键即可。
+ ///
+ private async Task FreeSoftDeletedCustomerIdentityIfNeededAsync(
+ string customerCode,
+ string phone,
+ string? email,
+ int? excludeId = null)
+ {
+ var byCode = await CustomerRepository.GetByCustomerCodeIncludingDeletedAsync(customerCode);
+ if (byCode != null && byCode.IsDeleted && byCode.Id != excludeId)
+ {
+ byCode.CustomerCode = $"{customerCode}__deleted_{byCode.Id}";
+ await CustomerRepository.UpdateAsync(byCode);
+ }
+
+ var byPhone = await CustomerRepository.GetByPhoneIncludingDeletedAsync(phone);
+ if (byPhone != null && byPhone.IsDeleted && byPhone.Id != excludeId)
+ {
+ byPhone.Phone = $"{phone}__deleted_{byPhone.Id}";
+ await CustomerRepository.UpdateAsync(byPhone);
+ }
+
+ if (string.IsNullOrWhiteSpace(email))
+ {
+ return;
+ }
+
+ var byEmail = await CustomerRepository.GetByEmailIncludingDeletedAsync(email);
+ if (byEmail != null && byEmail.IsDeleted && byEmail.Id != excludeId)
+ {
+ byEmail.Email = $"{email}__deleted_{byEmail.Id}";
+ await CustomerRepository.UpdateAsync(byEmail);
+ }
+ }
+
private async Task EnsureCustomerIdentityUniqueForUpdateAsync(int id, string customerCode, string phone, string? email)
{
+ await FreeSoftDeletedCustomerIdentityIfNeededAsync(customerCode, phone, email, excludeId: id);
+
var duplicatedCode = await CustomerRepository.GetByCustomerCodeAsync(customerCode);
if (duplicatedCode != null && duplicatedCode.Id != id)
{
diff --git a/src/Oms.Application/Services/Kb/KbCategoryService.cs b/src/Oms.Application/Services/Kb/KbCategoryService.cs
index a42186e..492845c 100644
--- a/src/Oms.Application/Services/Kb/KbCategoryService.cs
+++ b/src/Oms.Application/Services/Kb/KbCategoryService.cs
@@ -64,7 +64,22 @@ public class KbCategoryService : ApplicationService
throw new BusinessException($"分类编码已存在:{code}");
}
- entity ??= new KbCategory();
+ if (entity == null)
+ {
+ var deleted = await CategoryRepository.GetByCodeIncludingDeletedAsync(code);
+ entity = deleted is { IsDeleted: true } ? deleted : new KbCategory();
+ }
+ else
+ {
+ var softDeleted = await CategoryRepository.GetByCodeIncludingDeletedAsync(code);
+ if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id)
+ {
+ softDeleted.Code = $"{code}__deleted_{softDeleted.Id}";
+ await CategoryRepository.UpdateAsync(softDeleted);
+ }
+ }
+
+ entity.IsDeleted = false;
entity.Code = code;
entity.Name = name;
entity.SortOrder = input.SortOrder;
diff --git a/src/Oms.Application/Services/System/DictService.cs b/src/Oms.Application/Services/System/DictService.cs
index 2bd2737..7329353 100644
--- a/src/Oms.Application/Services/System/DictService.cs
+++ b/src/Oms.Application/Services/System/DictService.cs
@@ -132,14 +132,10 @@ public class DictService : ApplicationService
}
entity ??= await DictTypeRepository.GetByDictTypeAsync(dictType);
+ // 软删后列表查不到,但 uk_dict_type_code 仍占坑;重建时恢复原行
+ entity ??= await DictTypeRepository.GetByDictTypeIncludingDeletedAsync(dictType);
if (entity == null)
{
- var existedType = await DictTypeRepository.GetByDictTypeAsync(dictType);
- if (existedType != null)
- {
- throw new BusinessException("字典类型已存在");
- }
-
await DictTypeRepository.InsertAsync(new DictTypeMeta
{
DictType = dictType,
@@ -151,13 +147,19 @@ public class DictService : ApplicationService
if (!string.Equals(entity.DictType, dictType, StringComparison.OrdinalIgnoreCase))
{
- var duplicate = await DictTypeRepository.GetByDictTypeAsync(dictType);
- if (duplicate != null && duplicate.Id != entity.Id)
+ var duplicate = await DictTypeRepository.GetByDictTypeIncludingDeletedAsync(dictType);
+ if (duplicate != null && duplicate.Id != entity.Id && !duplicate.IsDeleted)
{
throw new BusinessException("字典类型已存在");
}
+
+ if (duplicate != null && duplicate.Id != entity.Id && duplicate.IsDeleted)
+ {
+ throw new BusinessException("字典类型编码与已删除记录冲突,请换编码或联系管理员恢复");
+ }
}
+ entity.IsDeleted = false;
entity.DictType = dictType;
entity.Remark = remark;
entity.TypeSort = input.TypeSort;
@@ -317,6 +319,14 @@ public class DictService : ApplicationService
return;
}
+ var deleted = await DictTypeRepository.GetByDictTypeIncludingDeletedAsync(dictType);
+ if (deleted != null)
+ {
+ deleted.IsDeleted = false;
+ await DictTypeRepository.UpdateAsync(deleted);
+ return;
+ }
+
await DictTypeRepository.InsertAsync(new DictTypeMeta
{
DictType = dictType,
diff --git a/src/Oms.Application/Services/System/SystemOtherConfigService.cs b/src/Oms.Application/Services/System/SystemOtherConfigService.cs
index b213f87..7c52138 100644
--- a/src/Oms.Application/Services/System/SystemOtherConfigService.cs
+++ b/src/Oms.Application/Services/System/SystemOtherConfigService.cs
@@ -61,9 +61,26 @@ public class SystemOtherConfigService : ApplicationService
public async Task CreateAsync(SystemOtherConfigCreateInput input)
{
await ValidateInputAsync(input, null);
+ var configCode = input.ConfigCode.Trim();
+ var deleted = await SystemOtherConfigRepository.GetByConfigCodeIncludingDeletedAsync(configCode);
+ if (deleted != null && deleted.IsDeleted)
+ {
+ deleted.IsDeleted = false;
+ deleted.ConfigCode = configCode;
+ deleted.ConfigName = input.ConfigName.Trim();
+ deleted.Enabled = input.Enabled;
+ deleted.BaseUrl = NormalizeOptional(input.BaseUrl);
+ deleted.AppKey = NormalizeOptional(input.AppKey);
+ deleted.AppSecret = NormalizeOptional(input.AppSecret);
+ deleted.TimeoutSeconds = NormalizeTimeoutSeconds(input.TimeoutSeconds);
+ deleted.Remark = NormalizeOptional(input.Remark);
+ await SystemOtherConfigRepository.UpdateAsync(deleted);
+ return;
+ }
+
await SystemOtherConfigRepository.InsertAsync(new SystemOtherConfig
{
- ConfigCode = input.ConfigCode.Trim(),
+ ConfigCode = configCode,
ConfigName = input.ConfigName.Trim(),
Enabled = input.Enabled,
BaseUrl = NormalizeOptional(input.BaseUrl),
@@ -82,8 +99,16 @@ public class SystemOtherConfigService : ApplicationService
{
var entity = await GetConfigOrThrowAsync(input.Id);
await ValidateInputAsync(input, entity.Id);
+ var configCode = input.ConfigCode.Trim();
+ var softDeleted = await SystemOtherConfigRepository.GetByConfigCodeIncludingDeletedAsync(configCode);
+ if (softDeleted != null && softDeleted.IsDeleted && softDeleted.Id != entity.Id)
+ {
+ softDeleted.ConfigCode = $"{configCode}__deleted_{softDeleted.Id}";
+ await SystemOtherConfigRepository.UpdateAsync(softDeleted);
+ }
+
var clearWeChatAccessToken = ShouldClearWeChatAccessToken(entity, input);
- entity.ConfigCode = input.ConfigCode.Trim();
+ entity.ConfigCode = configCode;
entity.ConfigName = input.ConfigName.Trim();
entity.Enabled = input.Enabled;
entity.BaseUrl = NormalizeOptional(input.BaseUrl);
diff --git a/src/Oms.Application/Services/System/WarehouseService.cs b/src/Oms.Application/Services/System/WarehouseService.cs
index 2e27f6c..8532769 100644
--- a/src/Oms.Application/Services/System/WarehouseService.cs
+++ b/src/Oms.Application/Services/System/WarehouseService.cs
@@ -81,15 +81,29 @@ public class WarehouseService : ApplicationService
[HttpPost("/system/warehouse/create")]
public async Task CreateAsync(CreateWarehouseInput input)
{
- var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(input.WarehouseName);
+ var warehouseName = input.WarehouseName.Trim();
+ var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(warehouseName);
if (duplicatedWarehouse != null)
{
throw new BusinessException("仓库名称已存在");
}
+ var deleted = await WarehouseAddressRepository.GetByWarehouseNameIncludingDeletedAsync(warehouseName);
+ if (deleted != null && deleted.IsDeleted)
+ {
+ deleted.IsDeleted = false;
+ deleted.WarehouseName = warehouseName;
+ deleted.WarehouseType = input.WarehouseType;
+ deleted.Recipient = input.Recipient;
+ deleted.ContactPhone = input.ContactPhone;
+ deleted.ShippingAddress = input.ShippingAddress;
+ await WarehouseAddressRepository.UpdateAsync(deleted);
+ return;
+ }
+
var entity = new WarehouseAddress
{
- WarehouseName = input.WarehouseName,
+ WarehouseName = warehouseName,
WarehouseType = input.WarehouseType,
Recipient = input.Recipient,
ContactPhone = input.ContactPhone,
@@ -108,13 +122,21 @@ public class WarehouseService : ApplicationService
public async Task UpdateAsync(UpdateWarehouseInput input)
{
var entity = await GetWarehouseOrThrowAsync(input.Id);
- var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(input.WarehouseName);
+ var warehouseName = input.WarehouseName.Trim();
+ var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(warehouseName);
if (duplicatedWarehouse != null && duplicatedWarehouse.Id != entity.Id)
{
throw new BusinessException("仓库名称已存在");
}
- entity.WarehouseName = input.WarehouseName;
+ var softDeleted = await WarehouseAddressRepository.GetByWarehouseNameIncludingDeletedAsync(warehouseName);
+ if (softDeleted != null && softDeleted.IsDeleted && softDeleted.Id != entity.Id)
+ {
+ softDeleted.WarehouseName = $"{warehouseName}__deleted_{softDeleted.Id}";
+ await WarehouseAddressRepository.UpdateAsync(softDeleted);
+ }
+
+ entity.WarehouseName = warehouseName;
entity.WarehouseType = input.WarehouseType;
entity.Recipient = input.Recipient;
entity.ContactPhone = input.ContactPhone;
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs
index 0c1327e..3fe21b9 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs
@@ -63,7 +63,12 @@ public partial class WarehouseService
}
else
{
- entity = new CBinLocation { WarehouseId = input.WarehouseId };
+ var deleted = await BinLocationRepository.GetByWarehouseAndBinCodeIncludingDeletedAsync(
+ input.WarehouseId,
+ binCode);
+ entity = deleted is { IsDeleted: true }
+ ? deleted
+ : new CBinLocation { WarehouseId = input.WarehouseId };
}
var duplicate = await BinLocationRepository.FirstOrDefaultAsync(x =>
@@ -73,13 +78,26 @@ public partial class WarehouseService
throw new BusinessException("库位编码已存在");
}
+ if (input.Id > 0)
+ {
+ var softDeleted = await BinLocationRepository.GetByWarehouseAndBinCodeIncludingDeletedAsync(
+ input.WarehouseId,
+ binCode);
+ if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id)
+ {
+ softDeleted.BinCode = $"{binCode}__deleted_{softDeleted.Id}";
+ await BinLocationRepository.UpdateAsync(softDeleted);
+ }
+ }
+
+ entity.IsDeleted = false;
entity.BinCode = binCode;
entity.BinName = string.IsNullOrWhiteSpace(input.BinName) ? null : input.BinName.Trim();
entity.ZoneCode = string.IsNullOrWhiteSpace(input.ZoneCode) ? null : input.ZoneCode.Trim();
entity.IsEnabled = input.IsEnabled;
entity.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim();
- if (input.Id > 0)
+ if (entity.Id > 0)
{
await BinLocationRepository.UpdateAsync(entity);
}
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
index 646e35f..c0b0eaa 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
@@ -105,7 +105,10 @@ public partial class WarehouseService
}
else
{
- entity = new Domain.WarehouseAggregate.Entities.CSku { CustomerId = customerId };
+ var deleted = await SkuRepository.GetByCustomerAndSkuCodeIncludingDeletedAsync(customerId, skuCode);
+ entity = deleted is { IsDeleted: true }
+ ? deleted
+ : new Domain.WarehouseAggregate.Entities.CSku { CustomerId = customerId };
}
var duplicate = await SkuRepository.FirstOrDefaultAsync(x =>
@@ -115,6 +118,17 @@ public partial class WarehouseService
throw new BusinessException("SKU 编码已存在");
}
+ if (input.Id > 0)
+ {
+ var softDeleted = await SkuRepository.GetByCustomerAndSkuCodeIncludingDeletedAsync(customerId, skuCode);
+ if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id)
+ {
+ softDeleted.SkuCode = $"{skuCode}__deleted_{softDeleted.Id}";
+ await SkuRepository.UpdateAsync(softDeleted);
+ }
+ }
+
+ entity.IsDeleted = false;
entity.SkuCode = skuCode;
entity.SkuName = skuName;
entity.SkuNameEn = string.IsNullOrWhiteSpace(input.SkuNameEn) ? null : input.SkuNameEn.Trim();
@@ -129,7 +143,7 @@ public partial class WarehouseService
entity.HeightCm = input.HeightCm;
entity.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim();
- if (input.Id > 0)
+ if (entity.Id > 0)
{
await SkuRepository.UpdateAsync(entity);
}
diff --git a/src/Oms.Domain/AiAggregate/Repositories/IAiSkillRepository.cs b/src/Oms.Domain/AiAggregate/Repositories/IAiSkillRepository.cs
index 8dfae78..bf79a04 100644
--- a/src/Oms.Domain/AiAggregate/Repositories/IAiSkillRepository.cs
+++ b/src/Oms.Domain/AiAggregate/Repositories/IAiSkillRepository.cs
@@ -6,4 +6,8 @@ namespace Oms.Domain.AiAggregate.Repositories;
public interface IAiSkillRepository : IRepository
{
Task GetBySkillKeyAsync(string skillKey, CancellationToken cancellationToken = default);
+
+ Task GetBySkillKeyIncludingDeletedAsync(
+ string skillKey,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs
index ea41dad..5ba942a 100644
--- a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs
+++ b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs
@@ -7,6 +7,10 @@ public interface ISalesProductRepository : IRepository
{
Task GetByProductCodeAsync(string productCode, CancellationToken cancellationToken = default);
+ Task GetByProductCodeIncludingDeletedAsync(
+ string productCode,
+ CancellationToken cancellationToken = default);
+
Task HasProductCodeAsync(string productCode, CancellationToken cancellationToken = default);
Task> GetPricesAsync(int salesProductId, CancellationToken cancellationToken = default);
diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs
index 0ae8a9f..bb090e5 100644
--- a/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs
+++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs
@@ -7,6 +7,10 @@ public interface IServiceChannelRepository : IRepository
{
Task GetByChannelCodeAsync(string channelCode, CancellationToken cancellationToken = default);
+ Task GetByChannelCodeIncludingDeletedAsync(
+ string channelCode,
+ CancellationToken cancellationToken = default);
+
Task HasChannelCodeAsync(string channelCode, CancellationToken cancellationToken = default);
Task> GetGroupsAsync(CancellationToken cancellationToken = default);
diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs
index 7292f30..a46ba16 100644
--- a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs
+++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs
@@ -7,10 +7,18 @@ public interface ICustomerRepository : IRepository
{
Task GetByCustomerCodeAsync(string customerCode, CancellationToken cancellationToken = default);
+ Task GetByCustomerCodeIncludingDeletedAsync(
+ string customerCode,
+ CancellationToken cancellationToken = default);
+
Task GetByPhoneAsync(string phone, CancellationToken cancellationToken = default);
+ Task GetByPhoneIncludingDeletedAsync(string phone, CancellationToken cancellationToken = default);
+
Task GetByEmailAsync(string email, CancellationToken cancellationToken = default);
+ Task GetByEmailIncludingDeletedAsync(string email, CancellationToken cancellationToken = default);
+
Task GetByWechatOpenidAsync(string wechatOpenid, CancellationToken cancellationToken = default);
Task ExistsPhoneAsync(string phone, CancellationToken cancellationToken = default);
diff --git a/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs b/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs
index 4fe86d9..f10c44f 100644
--- a/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs
+++ b/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs
@@ -7,5 +7,7 @@ public interface IKbCategoryRepository : IRepository
{
Task GetByCodeAsync(string code, CancellationToken cancellationToken = default);
+ Task GetByCodeIncludingDeletedAsync(string code, CancellationToken cancellationToken = default);
+
Task ExistsCodeAsync(string code, int? excludeId = null, CancellationToken cancellationToken = default);
}
diff --git a/src/Oms.Domain/SystemAggregate/Repositories/IDictTypeRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/IDictTypeRepository.cs
index fb0e07f..3b1a4aa 100644
--- a/src/Oms.Domain/SystemAggregate/Repositories/IDictTypeRepository.cs
+++ b/src/Oms.Domain/SystemAggregate/Repositories/IDictTypeRepository.cs
@@ -6,4 +6,11 @@ namespace Oms.Domain.SystemAggregate.Repositories;
public interface IDictTypeRepository : IRepository
{
Task GetByDictTypeAsync(string dictType, CancellationToken cancellationToken = default);
+
+ ///
+ /// 按类型编码查询(含软删除),用于唯一键冲突时恢复。
+ ///
+ Task GetByDictTypeIncludingDeletedAsync(
+ string dictType,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/Oms.Domain/SystemAggregate/Repositories/ISystemOtherConfigRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/ISystemOtherConfigRepository.cs
index b1b3ffe..3b92cd8 100644
--- a/src/Oms.Domain/SystemAggregate/Repositories/ISystemOtherConfigRepository.cs
+++ b/src/Oms.Domain/SystemAggregate/Repositories/ISystemOtherConfigRepository.cs
@@ -6,4 +6,8 @@ namespace Oms.Domain.SystemAggregate.Repositories;
public interface ISystemOtherConfigRepository : IRepository
{
Task GetByConfigCodeAsync(string configCode, CancellationToken cancellationToken = default);
+
+ Task GetByConfigCodeIncludingDeletedAsync(
+ string configCode,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/Oms.Domain/SystemAggregate/Repositories/IWarehouseAddressRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/IWarehouseAddressRepository.cs
index 16bd587..131abb9 100644
--- a/src/Oms.Domain/SystemAggregate/Repositories/IWarehouseAddressRepository.cs
+++ b/src/Oms.Domain/SystemAggregate/Repositories/IWarehouseAddressRepository.cs
@@ -7,5 +7,9 @@ public interface IWarehouseAddressRepository : IRepository
{
Task GetByWarehouseNameAsync(string warehouseName, CancellationToken cancellationToken = default);
+ Task GetByWarehouseNameIncludingDeletedAsync(
+ string warehouseName,
+ CancellationToken cancellationToken = default);
+
Task ExistsWarehouseNameAsync(string warehouseName, CancellationToken cancellationToken = default);
}
diff --git a/src/Oms.Domain/WarehouseAggregate/Repositories/IWarehouseRepositories.cs b/src/Oms.Domain/WarehouseAggregate/Repositories/IWarehouseRepositories.cs
index f2c0fb8..26c7073 100644
--- a/src/Oms.Domain/WarehouseAggregate/Repositories/IWarehouseRepositories.cs
+++ b/src/Oms.Domain/WarehouseAggregate/Repositories/IWarehouseRepositories.cs
@@ -3,9 +3,21 @@ using Oms.Domain.WarehouseAggregate.Entities;
namespace Oms.Domain.WarehouseAggregate.Repositories;
-public interface ICSkuRepository : IRepository;
+public interface ICSkuRepository : IRepository
+{
+ Task GetByCustomerAndSkuCodeIncludingDeletedAsync(
+ int customerId,
+ string skuCode,
+ CancellationToken cancellationToken = default);
+}
-public interface ICBinLocationRepository : IRepository;
+public interface ICBinLocationRepository : IRepository
+{
+ Task GetByWarehouseAndBinCodeIncludingDeletedAsync(
+ int warehouseId,
+ string binCode,
+ CancellationToken cancellationToken = default);
+}
public interface ICInventoryRepository : IRepository;
diff --git a/src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs b/src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs
index cac80c7..4204742 100644
--- a/src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs
+++ b/src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs
@@ -12,4 +12,15 @@ public class AiSkillRepository
string skillKey,
CancellationToken cancellationToken = default) =>
FirstOrDefaultAsync(x => x.SkillKey == skillKey, cancellationToken);
+
+ public async Task GetBySkillKeyIncludingDeletedAsync(
+ string skillKey,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.SkillKey == skillKey,
+ cancellationToken);
+ }
}
diff --git a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs
index b6a1aab..fdc3af9 100644
--- a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs
+++ b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs
@@ -13,6 +13,17 @@ public class SalesProductRepository : OmsSqlSugarRepository, ISale
return FirstOrDefaultAsync(x => x.ProductCode == productCode, cancellationToken);
}
+ public async Task GetByProductCodeIncludingDeletedAsync(
+ string productCode,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.ProductCode == productCode,
+ cancellationToken);
+ }
+
public Task HasProductCodeAsync(string productCode, CancellationToken cancellationToken = default)
{
return AnyAsync(x => x.ProductCode == productCode, cancellationToken);
diff --git a/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs b/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs
index e7e80f2..c0c6e49 100644
--- a/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs
+++ b/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs
@@ -13,6 +13,17 @@ public class ServiceChannelRepository : OmsSqlSugarRepository, I
return FirstOrDefaultAsync(x => x.ChannelCode == channelCode, cancellationToken);
}
+ public async Task GetByChannelCodeIncludingDeletedAsync(
+ string channelCode,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.ChannelCode == channelCode,
+ cancellationToken);
+ }
+
public Task HasChannelCodeAsync(string channelCode, CancellationToken cancellationToken = default)
{
return AnyAsync(x => x.ChannelCode == channelCode, cancellationToken);
diff --git a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs
index 22ed7bc..6215bd9 100644
--- a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs
+++ b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs
@@ -12,16 +12,49 @@ public class CustomerRepository : OmsSqlSugarRepository, ICustomerRepo
return FirstOrDefaultAsync(x => x.CustomerCode == customerCode, cancellationToken);
}
+ public async Task GetByCustomerCodeIncludingDeletedAsync(
+ string customerCode,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.CustomerCode == customerCode,
+ cancellationToken);
+ }
+
public Task GetByPhoneAsync(string phone, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.Phone == phone, cancellationToken);
}
+ public async Task GetByPhoneIncludingDeletedAsync(
+ string phone,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.Phone == phone,
+ cancellationToken);
+ }
+
public Task GetByEmailAsync(string email, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.Email == email, cancellationToken);
}
+ public async Task GetByEmailIncludingDeletedAsync(
+ string email,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.Email == email,
+ cancellationToken);
+ }
+
public Task GetByWechatOpenidAsync(string wechatOpenid, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.WechatOpenid == wechatOpenid, cancellationToken);
diff --git a/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs b/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs
index f6fdc90..0bd8fce 100644
--- a/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs
+++ b/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs
@@ -11,6 +11,17 @@ public class KbCategoryRepository
public Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) =>
FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
+ public async Task GetByCodeIncludingDeletedAsync(
+ string code,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.Code == code,
+ cancellationToken);
+ }
+
public async Task ExistsCodeAsync(
string code,
int? excludeId = null,
diff --git a/src/Oms.SqlSugarCore/Repositories/SoftDeleteQuery.cs b/src/Oms.SqlSugarCore/Repositories/SoftDeleteQuery.cs
new file mode 100644
index 0000000..f77ab9a
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Repositories/SoftDeleteQuery.cs
@@ -0,0 +1,24 @@
+using System.Linq.Expressions;
+using SqlSugar;
+
+namespace Oms.SqlSugarCore.Repositories;
+
+///
+/// 软删场景下按业务唯一键查询(ClearFilter),供新增恢复 / 释放唯一键使用。
+///
+public static class SoftDeleteQuery
+{
+ public static async Task FirstIncludingDeletedAsync(
+ ISqlSugarClient db,
+ Expression> predicate,
+ CancellationToken cancellationToken = default)
+ where TEntity : class, new()
+ {
+ var rows = await db.Queryable()
+ .ClearFilter()
+ .Where(predicate)
+ .Take(1)
+ .ToListAsync(cancellationToken);
+ return rows.FirstOrDefault();
+ }
+}
diff --git a/src/Oms.SqlSugarCore/System/DictTypeRepository.cs b/src/Oms.SqlSugarCore/System/DictTypeRepository.cs
index b52693e..e76441f 100644
--- a/src/Oms.SqlSugarCore/System/DictTypeRepository.cs
+++ b/src/Oms.SqlSugarCore/System/DictTypeRepository.cs
@@ -11,4 +11,17 @@ public class DictTypeRepository : OmsSqlSugarRepository, IDictType
{
return FirstOrDefaultAsync(x => x.DictType == dictType, cancellationToken);
}
+
+ public async Task GetByDictTypeIncludingDeletedAsync(
+ string dictType,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ var rows = await db.Queryable()
+ .ClearFilter()
+ .Where(x => x.DictType == dictType)
+ .Take(1)
+ .ToListAsync(cancellationToken);
+ return rows.FirstOrDefault();
+ }
}
diff --git a/src/Oms.SqlSugarCore/System/SystemOtherConfigRepository.cs b/src/Oms.SqlSugarCore/System/SystemOtherConfigRepository.cs
index e71892b..40bcfeb 100644
--- a/src/Oms.SqlSugarCore/System/SystemOtherConfigRepository.cs
+++ b/src/Oms.SqlSugarCore/System/SystemOtherConfigRepository.cs
@@ -11,4 +11,15 @@ public class SystemOtherConfigRepository : OmsSqlSugarRepository x.ConfigCode == configCode, cancellationToken);
}
+
+ public async Task GetByConfigCodeIncludingDeletedAsync(
+ string configCode,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.ConfigCode == configCode,
+ cancellationToken);
+ }
}
diff --git a/src/Oms.SqlSugarCore/System/WarehouseAddressRepository.cs b/src/Oms.SqlSugarCore/System/WarehouseAddressRepository.cs
index e6233c5..40a7eb1 100644
--- a/src/Oms.SqlSugarCore/System/WarehouseAddressRepository.cs
+++ b/src/Oms.SqlSugarCore/System/WarehouseAddressRepository.cs
@@ -12,6 +12,17 @@ public class WarehouseAddressRepository : OmsSqlSugarRepository x.WarehouseName == warehouseName, cancellationToken);
}
+ public async Task GetByWarehouseNameIncludingDeletedAsync(
+ string warehouseName,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.WarehouseName == warehouseName,
+ cancellationToken);
+ }
+
public Task ExistsWarehouseNameAsync(string warehouseName, CancellationToken cancellationToken = default)
{
return AnyAsync(x => x.WarehouseName == warehouseName, cancellationToken);
diff --git a/src/Oms.SqlSugarCore/Warehouse/WarehouseRepositories.cs b/src/Oms.SqlSugarCore/Warehouse/WarehouseRepositories.cs
index c9e6193..08ecd97 100644
--- a/src/Oms.SqlSugarCore/Warehouse/WarehouseRepositories.cs
+++ b/src/Oms.SqlSugarCore/Warehouse/WarehouseRepositories.cs
@@ -5,9 +5,35 @@ using Oms.SqlSugarCore.Repositories;
namespace Oms.SqlSugarCore.Warehouse;
-public class CSkuRepository : OmsSqlSugarRepository, ICSkuRepository, IScopedDependency;
+public class CSkuRepository : OmsSqlSugarRepository, ICSkuRepository, IScopedDependency
+{
+ public async Task GetByCustomerAndSkuCodeIncludingDeletedAsync(
+ int customerId,
+ string skuCode,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.CustomerId == customerId && x.SkuCode == skuCode,
+ cancellationToken);
+ }
+}
-public class CBinLocationRepository : OmsSqlSugarRepository, ICBinLocationRepository, IScopedDependency;
+public class CBinLocationRepository : OmsSqlSugarRepository, ICBinLocationRepository, IScopedDependency
+{
+ public async Task GetByWarehouseAndBinCodeIncludingDeletedAsync(
+ int warehouseId,
+ string binCode,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await SoftDeleteQuery.FirstIncludingDeletedAsync(
+ db,
+ x => x.WarehouseId == warehouseId && x.BinCode == binCode,
+ cancellationToken);
+ }
+}
public class CInventoryRepository : OmsSqlSugarRepository, ICInventoryRepository, IScopedDependency;
--
2.45.2
From 74a71991b28fcde2db7abbd033a53f655e1f5f6b Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Mon, 5 Oct 2026 16:12:09 +0800
Subject: [PATCH 19/41] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E4=BB=B7?=
=?UTF-8?q?=E6=A0=BC/=E9=BB=91=E5=90=8D=E5=8D=95/=E5=81=8F=E8=BF=9C?=
=?UTF-8?q?=E5=88=86=E5=8C=BA=E5=8C=B9=E9=85=8D=EF=BC=8C=E5=B9=B6=E8=90=BD?=
=?UTF-8?q?=E5=BA=93=20MatchDim?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
db.sql | 6 +-
sql/admin.sql | 361 +++++++++
sql/migrate_kpi_area_info_match.sql | 66 ++
.../Dtos/Channels/KpiAreaInfoOutput.cs | 15 +
.../Dtos/Channels/KpiAreaInfoSaveInput.cs | 15 +
.../Dtos/Orders/OrderPriceCheckInput.cs | 5 +
.../Dtos/Orders/ProductBillingSnapshotDtos.cs | 6 +-
.../Dtos/Orders/PublishSnapshotRuleDtos.cs | 10 +-
.../Services/Channels/KpiAreaService.cs | 31 +-
.../SalesProductService.BillingSimulate.cs | 9 +-
.../Orders/OrderService.ParcelBilling.cs | 31 +-
.../Orders/OrderService.ParcelQuote.cs | 10 +-
.../Orders/ParcelBillingCalculator.cs | 63 +-
.../Services/Orders/PriceCheckHintBuilder.cs | 6 +-
.../Services/Orders/PriceCheckInputProfile.cs | 6 +
.../Shared/GeoPartitionMatcher.cs | 751 ++++++++++++++++++
.../Shared/KpiAreaInfoImportHelper.cs | 49 +-
.../Shared/KpiAreaSnapshotMapper.cs | 87 +-
.../Shared/RulesSnapshotHelper.cs | 7 +-
.../ChannelsAggregate/Entities/KpiAreaInfo.cs | 8 +
src/Oms.Tests/GeoPartitionMatcherTests.cs | 145 ++++
src/Oms.Tests/KpiAreaInfoImportHelperTests.cs | 9 +-
src/Oms.Tests/KpiAreaSnapshotMapperTests.cs | 20 +-
23 files changed, 1526 insertions(+), 190 deletions(-)
create mode 100644 sql/admin.sql
create mode 100644 sql/migrate_kpi_area_info_match.sql
create mode 100644 src/Oms.Application/Shared/GeoPartitionMatcher.cs
create mode 100644 src/Oms.Tests/GeoPartitionMatcherTests.cs
diff --git a/db.sql b/db.sql
index 876be41..009c706 100644
--- a/db.sql
+++ b/db.sql
@@ -793,6 +793,9 @@ CREATE TABLE `t_kpi_area_info` (
`City` VARCHAR(128) NULL COMMENT '城市',
`LeftPostcode` VARCHAR(32) NULL COMMENT '左侧邮编',
`RightPostcode` VARCHAR(32) NULL COMMENT '右侧邮编',
+ `MatchDim` VARCHAR(16) NOT NULL DEFAULT 'country' COMMENT '匹配维度:zip/state/city/country',
+ `MatchMode` VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT '匹配方式:prefix/exact/range/all',
+ `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT '邮编前缀长度(zip+prefix)',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -800,7 +803,8 @@ CREATE TABLE `t_kpi_area_info` (
`UpdateTime` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`Id`),
KEY `idx_kpi_info_area` (`AreaId`),
- KEY `idx_kpi_info_area_country` (`AreaId`, `CountryCode`)
+ KEY `idx_kpi_info_area_country` (`AreaId`, `CountryCode`),
+ KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`)
) COMMENT='分区明细表';
CREATE TABLE `t_weight_rule` (
diff --git a/sql/admin.sql b/sql/admin.sql
new file mode 100644
index 0000000..07f3ba7
--- /dev/null
+++ b/sql/admin.sql
@@ -0,0 +1,361 @@
+-- =============================================================================
+-- 存量库增量迁移(合并自 sql/migrate_*.sql)
+-- 可按整文件执行;多数段可重复执行(information_schema 判断)。
+-- 注意:揽收 Direction / FeePush 两段为裸 ALTER,已执行过的库请跳过或手工注释。
+-- =============================================================================
+
+SET @db := DATABASE();
+
+-- -----------------------------------------------------------------------------
+-- 1) 排货规则服务商表:MatchCondition
+-- 原:migrate_allocation_match_condition.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_sales_product_allocation_provider'
+ AND COLUMN_NAME = 'MatchCondition'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_sales_product_allocation_provider` ADD COLUMN `MatchCondition` TEXT NULL COMMENT ''排货判断条件(中文公式),空表示全部订单可用'' AFTER `CustomerIds`',
+ 'SELECT ''MatchCondition already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 2) 客户出口资质备案表(含 IsDefault / Remark)
+-- 原:migrate_customer_export_qualification.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'CREATE TABLE `t_customer_export_qualification` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+ `CustomerId` INT NOT NULL COMMENT ''归属客户 t_customer.Id'',
+ `CompanyName` VARCHAR(200) NOT NULL COMMENT ''企业名称'',
+ `SocialCreditCode` VARCHAR(64) NOT NULL COMMENT ''企业社会信用代码'',
+ `DomesticConsignor` VARCHAR(200) NOT NULL COMMENT ''境内发货人'',
+ `DomesticConsignorCustomsCode` VARCHAR(64) NULL COMMENT ''境内发货人海关代码'',
+ `ProducerName` VARCHAR(200) NOT NULL COMMENT ''生产销售单位'',
+ `ProducerCustomsCode` VARCHAR(64) NULL COMMENT ''生产销售单位海关代码'',
+ `CustomsPort` VARCHAR(100) NULL COMMENT ''备案海关港口地'',
+ `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'',
+ `Remark` VARCHAR(512) NULL COMMENT ''备注'',
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
+ `UpdateUserId` INT NULL COMMENT ''更新人'',
+ `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
+ PRIMARY KEY (`Id`),
+ KEY `idx_ceq_customer` (`CustomerId`, `IsDeleted`, `Id`),
+ KEY `idx_ceq_credit` (`SocialCreditCode`, `IsDeleted`),
+ KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)
+) COMMENT=''客户出口资质备案''',
+ 'SELECT ''t_customer_export_qualification already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+ AND COLUMN_NAME = 'IsDefault'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_customer_export_qualification`
+ ADD COLUMN `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'' AFTER `CustomsPort`',
+ 'SELECT ''IsDefault already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+ AND COLUMN_NAME = 'Remark'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_customer_export_qualification`
+ ADD COLUMN `Remark` VARCHAR(512) NULL COMMENT ''备注'' AFTER `IsDefault`',
+ 'SELECT ''Remark already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @idx := (
+ SELECT COUNT(1)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+ AND INDEX_NAME = 'idx_ceq_customer_default'
+);
+SET @sql := IF(
+ @idx = 0,
+ 'ALTER TABLE `t_customer_export_qualification` ADD KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)',
+ 'SELECT ''idx_ceq_customer_default already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 3) t_order:出口资质备案 Id
+-- 原:migrate_order_export_qualification_id.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order'
+ AND COLUMN_NAME = 'ExportQualificationId'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order`
+ ADD COLUMN `ExportQualificationId` INT NULL COMMENT ''出口资质备案 Id(t_customer_export_qualification.Id)'' AFTER `ExportCustomsMode`,
+ ADD KEY `idx_order_export_qualification` (`ExportQualificationId`)',
+ 'SELECT ''t_order.ExportQualificationId already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 4) SKU / 订单申报:DeclareElement
+-- 原:migrate_declare_element.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 'c_sku'
+ AND COLUMN_NAME = 'DeclareElement'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `c_sku` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ImHsCode`',
+ 'SELECT ''c_sku.DeclareElement already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order_declaration'
+ AND COLUMN_NAME = 'DeclareElement'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order_declaration` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ExHsCode`',
+ 'SELECT ''t_order_declaration.DeclareElement already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 5) 问题模板:处理人字段
+-- 原:migrate_issue_template_handler.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_issue_template'
+ AND COLUMN_NAME = 'HandlerType'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerType` VARCHAR(16) NULL COMMENT ''处理人类型:customer=订单所属客户,user=指定用户'' AFTER `NotifyMethod`',
+ 'SELECT ''HandlerType already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_issue_template'
+ AND COLUMN_NAME = 'HandlerUserId'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerUserId` INT NULL COMMENT ''指定处理人用户Id(HandlerType=user 时必填)'' AFTER `HandlerType`',
+ 'SELECT ''HandlerUserId already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_issue_template`
+SET `HandlerType` = 'user'
+WHERE (`HandlerType` IS NULL OR `HandlerType` = '')
+ AND `IsDeleted` = 0;
+
+-- -----------------------------------------------------------------------------
+-- 6) 揽收方向 + 客户地址类型
+-- 原:migrate_pickup_direction_and_address_type.sql
+-- 注意:裸 ALTER,已执行过勿再跑本段
+-- -----------------------------------------------------------------------------
+ALTER TABLE `t_pickup_order`
+ ADD COLUMN `Direction` VARCHAR(16) NOT NULL DEFAULT 'forward'
+ COMMENT 'forward正向(客户→仓)/reverse逆向(仓→客户)'
+ AFTER `SenderAddressId`;
+
+ALTER TABLE `t_pickup_order`
+ ADD KEY `idx_pickup_direction_status` (`Direction`, `Status`, `CreateTime`);
+
+ALTER TABLE `t_customer_sender_address`
+ MODIFY COLUMN `AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender'
+ COMMENT 'sender国内发件/return国内退件/pickup国内揽收/receiver海外收件';
+
+-- -----------------------------------------------------------------------------
+-- 7) 揽收单:承运推送件重尺 + 费用快照
+-- 原:migrate_pickup_fee_push.sql
+-- 注意:裸 ALTER,已执行过勿再跑本段
+-- -----------------------------------------------------------------------------
+ALTER TABLE `t_pickup_order`
+ ADD COLUMN `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重' AFTER `ChargeableWeight`,
+ ADD COLUMN `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON' AFTER `VolumetricWeight`,
+ ADD COLUMN `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)' AFTER `PackageSizesJson`,
+ ADD COLUMN `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)' AFTER `CarrierFreightWeight`,
+ ADD COLUMN `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)' AFTER `CarrierQuotedFreightAmount`,
+ ADD COLUMN `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文' AFTER `CarrierQuotedReceivableAmount`,
+ ADD COLUMN `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON' AFTER `CarrierQuoteJson`,
+ ADD COLUMN `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON' AFTER `ReceivableFeeJson`,
+ ADD COLUMN `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间' AFTER `PayableFeeJson`;
+
+-- -----------------------------------------------------------------------------
+-- 8) 客户 OpenAPI 机机凭证
+-- 原:migrate_customer_openapi_credential.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_openapi_credential'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'CREATE TABLE `t_customer_openapi_credential` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+ `CustomerId` INT NOT NULL COMMENT ''归属主客户 t_customer.Id'',
+ `AppKey` VARCHAR(64) NOT NULL COMMENT ''开放接口 AppKey'',
+ `AppSecret` VARCHAR(128) NOT NULL COMMENT ''开放接口 AppSecret(明文,创建/重置时返回)'',
+ `Enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:0-否,1-是'',
+ `LastUsedAt` DATETIME NULL COMMENT ''最近鉴权成功时间'',
+ `LastUsedIp` VARCHAR(64) NULL COMMENT ''最近鉴权成功 IP'',
+ `Remark` VARCHAR(512) NULL COMMENT ''备注'',
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
+ `UpdateUserId` INT NULL COMMENT ''更新人'',
+ `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
+ PRIMARY KEY (`Id`),
+ UNIQUE KEY `uk_coc_customer` (`CustomerId`),
+ UNIQUE KEY `uk_coc_app_key` (`AppKey`),
+ KEY `idx_coc_enabled` (`Enabled`, `IsDeleted`)
+) COMMENT=''客户 OpenAPI 机机凭证''',
+ 'SELECT ''t_customer_openapi_credential already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 9) 分区明细:匹配维度 / 方式 / 前缀长度
+-- -----------------------------------------------------------------------------
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND COLUMN_NAME = 'MatchDim'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_kpi_area_info`
+ ADD COLUMN `MatchDim` VARCHAR(16) NOT NULL DEFAULT ''country'' COMMENT ''匹配维度:zip/state/city/country'' AFTER `RightPostcode`,
+ ADD COLUMN `MatchMode` VARCHAR(16) NOT NULL DEFAULT ''all'' COMMENT ''匹配方式:prefix/exact/range/all'' AFTER `MatchDim`,
+ ADD COLUMN `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT ''邮编前缀长度(zip+prefix)'' AFTER `MatchMode`',
+ 'SELECT ''kpi area match columns already exist'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @idx := (
+ SELECT COUNT(1)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND INDEX_NAME = 'idx_kpi_info_area_dim'
+);
+SET @sql := IF(
+ @idx = 0,
+ 'ALTER TABLE `t_kpi_area_info` ADD KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`)',
+ 'SELECT ''idx_kpi_info_area_dim already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_kpi_area_info`
+SET
+ `MatchDim` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' OR IFNULL(`RightPostcode`, '') <> '' THEN 'zip'
+ WHEN IFNULL(`City`, '') <> '' THEN 'city'
+ WHEN IFNULL(`Province`, '') <> '' THEN 'state'
+ ELSE 'country'
+ END,
+ `MatchMode` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN 'prefix'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN 'exact'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN 'range'
+ WHEN IFNULL(`City`, '') <> '' OR IFNULL(`Province`, '') <> '' THEN 'exact'
+ ELSE 'all'
+ END,
+ `PrefixLen` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN LEAST(CHAR_LENGTH(`LeftPostcode`), CHAR_LENGTH(`RightPostcode`))
+ ELSE 0
+ END
+WHERE IFNULL(`MatchDim`, '') IN ('', 'country')
+ AND (
+ IFNULL(`LeftPostcode`, '') <> ''
+ OR IFNULL(`RightPostcode`, '') <> ''
+ OR IFNULL(`Province`, '') <> ''
+ OR IFNULL(`City`, '') <> ''
+ );
diff --git a/sql/migrate_kpi_area_info_match.sql b/sql/migrate_kpi_area_info_match.sql
new file mode 100644
index 0000000..1096fae
--- /dev/null
+++ b/sql/migrate_kpi_area_info_match.sql
@@ -0,0 +1,66 @@
+-- 存量库:分区明细增加匹配维度/方式/前缀长度(可重复执行)
+SET @db := DATABASE();
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND COLUMN_NAME = 'MatchDim'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_kpi_area_info`
+ ADD COLUMN `MatchDim` VARCHAR(16) NOT NULL DEFAULT ''country'' COMMENT ''匹配维度:zip/state/city/country'' AFTER `RightPostcode`,
+ ADD COLUMN `MatchMode` VARCHAR(16) NOT NULL DEFAULT ''all'' COMMENT ''匹配方式:prefix/exact/range/all'' AFTER `MatchDim`,
+ ADD COLUMN `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT ''邮编前缀长度(zip+prefix)'' AFTER `MatchMode`',
+ 'SELECT ''kpi area match columns already exist'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @idx := (
+ SELECT COUNT(1)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND INDEX_NAME = 'idx_kpi_info_area_dim'
+);
+SET @sql := IF(
+ @idx = 0,
+ 'ALTER TABLE `t_kpi_area_info` ADD KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`)',
+ 'SELECT ''idx_kpi_info_area_dim already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_kpi_area_info`
+SET
+ `MatchDim` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' OR IFNULL(`RightPostcode`, '') <> '' THEN 'zip'
+ WHEN IFNULL(`City`, '') <> '' THEN 'city'
+ WHEN IFNULL(`Province`, '') <> '' THEN 'state'
+ ELSE 'country'
+ END,
+ `MatchMode` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN 'prefix'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN 'exact'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN 'range'
+ WHEN IFNULL(`City`, '') <> '' OR IFNULL(`Province`, '') <> '' THEN 'exact'
+ ELSE 'all'
+ END,
+ `PrefixLen` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN LEAST(CHAR_LENGTH(`LeftPostcode`), CHAR_LENGTH(`RightPostcode`))
+ ELSE 0
+ END
+WHERE IFNULL(`MatchDim`, '') IN ('', 'country')
+ AND (
+ IFNULL(`LeftPostcode`, '') <> ''
+ OR IFNULL(`RightPostcode`, '') <> ''
+ OR IFNULL(`Province`, '') <> ''
+ OR IFNULL(`City`, '') <> ''
+ );
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs
index 2536231..efea04f 100644
--- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs
@@ -45,6 +45,21 @@ public class KpiAreaInfoOutput
///
public string? RightPostcode { get; set; }
+ ///
+ /// 匹配维度:zip / state / city / country。
+ ///
+ public string? MatchDim { get; set; }
+
+ ///
+ /// 匹配方式:prefix / exact / range / all。
+ ///
+ public string? MatchMode { get; set; }
+
+ ///
+ /// 邮编前缀长度(zip+prefix)。
+ ///
+ public int PrefixLen { get; set; }
+
///
/// 创建时间。
///
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs
index aa8caf8..e754747 100644
--- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs
@@ -44,4 +44,19 @@ public class KpiAreaInfoSaveInput
/// 结束邮编。
///
public string? RightPostcode { get; set; }
+
+ ///
+ /// 匹配维度:zip / state / city / country。
+ ///
+ public string? MatchDim { get; set; }
+
+ ///
+ /// 匹配方式:prefix / exact / range / all。
+ ///
+ public string? MatchMode { get; set; }
+
+ ///
+ /// 邮编前缀长度。
+ ///
+ public int? PrefixLen { get; set; }
}
diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs
index c45bee6..7c6b0a5 100644
--- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs
@@ -55,6 +55,11 @@ public class OrderPriceCheckInput
///
public string? DestinationProvince { get; set; }
+ ///
+ /// 目的城市。
+ ///
+ public string? DestinationCity { get; set; }
+
///
/// 邮编。
///
diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
index feb4d90..d3c698b 100644
--- a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
@@ -23,7 +23,7 @@ public class SalesProductPricePublishSnapshotDto
public string? PriceAreaName { get; set; }
///
- /// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
+ /// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为闭区间。
///
public List PriceAreaInfos { get; set; } = [];
@@ -59,7 +59,7 @@ public class SalesProductRulesPublishSnapshotDto
public string? BlacklistAreaName { get; set; }
///
- /// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
+ /// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。
///
public List BlacklistAreaInfos { get; set; } = [];
@@ -74,7 +74,7 @@ public class SalesProductRulesPublishSnapshotDto
public string? RemoteAreaName { get; set; }
///
- /// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
+ /// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。
///
public List RemoteAreaInfos { get; set; } = [];
diff --git a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
index bd9ba46..eb347f2 100644
--- a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
@@ -17,8 +17,16 @@ public class KpiAreaSnapshotRuleDto
public string? City { get; set; }
+ /// zip / state / city / country,发布时固化。
+ public string? MatchDim { get; set; }
+
+ /// prefix / exact / range / all,发布时固化。
+ public string? MatchMode { get; set; }
+
+ public int PrefixLen { get; set; }
+
///
- /// 邮编区间,左开右闭,如 (50000,90000]。
+ /// 邮编匹配编码:prefix:900 / exact:90001 / [90000,90199];旧快照 (left,right] 按闭区间解析。
///
public string? PostcodeRange { get; set; }
diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs
index 8313e88..7c76aa1 100644
--- a/src/Oms.Application/Services/Channels/KpiAreaService.cs
+++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs
@@ -122,7 +122,7 @@ public class KpiAreaService : ApplicationService
public async Task AddInfoAsync(KpiAreaInfoSaveInput input)
{
await GetAreaOrThrowAsync(input.AreaId);
- await KpiAreaRepository.InsertInfoAsync(new KpiAreaInfo
+ var entity = new KpiAreaInfo
{
AreaId = input.AreaId,
CountryCode = input.CountryCode,
@@ -130,8 +130,13 @@ public class KpiAreaService : ApplicationService
Province = input.Province,
City = input.City,
LeftPostcode = input.LeftPostcode,
- RightPostcode = input.RightPostcode
- });
+ RightPostcode = input.RightPostcode,
+ MatchDim = input.MatchDim,
+ MatchMode = input.MatchMode,
+ PrefixLen = input.PrefixLen ?? 0
+ };
+ PrepareAreaInfo(entity);
+ await KpiAreaRepository.InsertInfoAsync(entity);
}
///
@@ -151,6 +156,10 @@ public class KpiAreaService : ApplicationService
entity.City = input.City;
entity.LeftPostcode = input.LeftPostcode;
entity.RightPostcode = input.RightPostcode;
+ entity.MatchDim = input.MatchDim;
+ entity.MatchMode = input.MatchMode;
+ entity.PrefixLen = input.PrefixLen ?? 0;
+ PrepareAreaInfo(entity);
await KpiAreaRepository.UpdateInfoAsync(entity);
}
@@ -293,8 +302,21 @@ public class KpiAreaService : ApplicationService
};
}
+ private static void PrepareAreaInfo(KpiAreaInfo entity)
+ {
+ try
+ {
+ GeoPartitionMatcher.PrepareEntity(entity);
+ }
+ catch (InvalidOperationException ex)
+ {
+ throw new BusinessException(ex.Message);
+ }
+ }
+
private static KpiAreaInfoOutput MapInfoOutput(KpiAreaInfo entity)
{
+ var (matchDim, matchMode, prefixLen) = GeoPartitionMatcher.Describe(entity);
return new KpiAreaInfoOutput
{
Id = entity.Id,
@@ -305,6 +327,9 @@ public class KpiAreaService : ApplicationService
City = entity.City,
LeftPostcode = entity.LeftPostcode,
RightPostcode = entity.RightPostcode,
+ MatchDim = matchDim,
+ MatchMode = matchMode,
+ PrefixLen = prefixLen,
CreateTime = entity.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
index 28ca2c5..ed2bb5a 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
@@ -277,7 +277,7 @@ public partial class SalesProductService
if (rulesSnapshot.RemoteAreaInfos is { Count: > 0 })
{
- return RulesSnapshotHelper.IsAreaHitFromSnapshot(
+ return GeoPartitionMatcher.IsHit(
rulesSnapshot.RemoteAreaInfos,
countryCode,
province,
@@ -286,12 +286,7 @@ public partial class SalesProductService
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
}
private async Task ResolveSimulateWarehouseNameAsync(int? warehouseId)
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 993df8f..f3c00a6 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -1500,7 +1500,7 @@ public partial class OrderService
if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 })
{
- return RulesSnapshotHelper.IsAreaHitFromSnapshot(
+ return GeoPartitionMatcher.IsHit(
rulesSnapshot.BlacklistAreaInfos,
countryCode,
province,
@@ -1509,12 +1509,7 @@ public partial class OrderService
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
}
private async Task IsRemoteHitAsync(
@@ -1533,7 +1528,7 @@ public partial class OrderService
if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 })
{
- return RulesSnapshotHelper.IsAreaHitFromSnapshot(
+ return GeoPartitionMatcher.IsHit(
rulesSnapshot.RemoteAreaInfos,
countryCode,
province,
@@ -1542,12 +1537,7 @@ public partial class OrderService
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
}
private static int? ResolveEffectiveBlacklistAreaId(
@@ -1649,12 +1639,12 @@ public partial class OrderService
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value);
- var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
+ var matched = GeoPartitionMatcher.MatchEntity(
+ rules,
countryCode,
province,
city,
- zipCode));
+ zipCode);
if (matched == null)
{
return null;
@@ -2998,11 +2988,6 @@ public partial class OrderService
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(channel.RemoteAreaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
}
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
index a2b290a..7220889 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
@@ -135,6 +135,7 @@ public partial class OrderService
{
CountryCode = countryCode,
Province = input.DestinationProvince,
+ City = input.DestinationCity,
PostalCode = input.PostalCode,
Length = dimensionLength,
Width = dimensionWidth,
@@ -229,6 +230,7 @@ public partial class OrderService
{
CountryCode = baseContext.CountryCode,
Province = baseContext.Province,
+ City = baseContext.City,
PostalCode = baseContext.PostalCode,
CargoAttribute = cargo.CargoAttribute,
CargoTypeCode = cargo.Code,
@@ -531,8 +533,7 @@ public partial class OrderService
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
}
- if (profile.HasPostalCode
- && await IsBlacklistHitAsync(
+ if (await IsBlacklistHitAsync(
product,
snapshotBinding.Rules,
context.CountryCode,
@@ -588,8 +589,7 @@ public partial class OrderService
ParcelQuoteResult? bestQuote = null;
decimal bestTotal = decimal.MaxValue;
- var isRemote = profile.HasPostalCode
- && await IsRemoteHitAsync(
+ var isRemote = await IsRemoteHitAsync(
product,
snapshotBinding.Rules,
context.CountryCode,
@@ -705,7 +705,7 @@ public partial class OrderService
attempts.Add(new QuoteZoneAttempt(keys, isFallback, keys[0]));
}
- if (profile.HasPostalCode || profile.HasProvince)
+ if (profile.HasPostalCode || profile.HasProvince || profile.HasCity)
{
await TryAddAsync(
profile.HasProvince ? context.Province : null,
diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
index 331c327..0858b86 100644
--- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
+++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
@@ -87,41 +87,8 @@ internal static class ParcelBillingCalculator
string countryCode,
string? province,
string? city,
- string? zipCode)
- {
- if (!string.IsNullOrWhiteSpace(rule.CountryCode)
- && !string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.Province)
- && !string.Equals(rule.Province, province, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.City)
- && !string.Equals(rule.City, city, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.LeftPostcode) || !string.IsNullOrWhiteSpace(rule.RightPostcode))
- {
- if (string.IsNullOrWhiteSpace(zipCode))
- {
- return false;
- }
-
- if (!IsPostcodeInRange(zipCode, rule.LeftPostcode, rule.RightPostcode))
- {
- return false;
- }
- }
-
- return true;
- }
+ string? zipCode) =>
+ GeoPartitionMatcher.IsHit([rule], countryCode, province, city, zipCode);
public static bool IsAreaMatched(
KpiAreaInfoOutput rule,
@@ -146,8 +113,12 @@ internal static class ParcelBillingCalculator
return null;
}
- var matched = priceSnapshot.PriceAreaInfos.FirstOrDefault(rule =>
- KpiAreaSnapshotMapper.IsRuleMatched(rule, countryCode, province, city, zipCode));
+ var matched = GeoPartitionMatcher.MatchSnapshot(
+ priceSnapshot.PriceAreaInfos,
+ countryCode,
+ province,
+ city,
+ zipCode);
if (matched == null)
{
return null;
@@ -2203,24 +2174,6 @@ internal static class ParcelBillingCalculator
}
}
- private static bool IsPostcodeInRange(string zipCode, string? left, string? right)
- {
- if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right))
- {
- return true;
- }
-
- if (long.TryParse(zipCode, out var zip)
- && long.TryParse(left, out var leftValue)
- && long.TryParse(right, out var rightValue))
- {
- return zip >= leftValue && zip <= rightValue;
- }
-
- return string.Compare(zipCode, left, StringComparison.OrdinalIgnoreCase) >= 0
- && string.Compare(zipCode, right, StringComparison.OrdinalIgnoreCase) <= 0;
- }
-
private sealed class ContinuePricingConfig
{
public decimal FirstWeight { get; set; }
diff --git a/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs b/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
index 8d489db..1cc1540 100644
--- a/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
+++ b/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
@@ -56,9 +56,11 @@ internal static class PriceCheckHintBuilder
PriceCheckMissingFieldCodes.Dimensions =>
"未填写包裹尺寸:计费重按实重估算,入库实测尺寸后价格可能变化。",
PriceCheckMissingFieldCodes.PostalCode =>
- "未填写邮编:未校验黑名单/偏远/精确分区,报价仅供参考。",
+ "未填写邮编:优先按州/市或国家级分区匹配,精确邮编可能调整价格。",
PriceCheckMissingFieldCodes.Province =>
- "未填写省州:分区按国家级匹配,精确地址可能调整价格。",
+ "未填写省州:无邮编时无法按州/市降级匹配,分区可能按国家级兜底。",
+ PriceCheckMissingFieldCodes.City =>
+ "未填写城市:无邮编且州未命中时无法按城市匹配。",
PriceCheckMissingFieldCodes.CargoType =>
"未选择货物属性:已分别试算普货/带电/敏感货等,下单时以所选属性为准。",
PriceCheckMissingFieldCodes.Warehouse =>
diff --git a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
index fd72af7..895f8c3 100644
--- a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
+++ b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
@@ -13,6 +13,8 @@ internal sealed class PriceCheckInputProfile
public bool HasProvince { get; init; }
+ public bool HasCity { get; init; }
+
public bool HasCargoTypeFilter { get; init; }
public bool HasWarehouseFilter { get; init; }
@@ -26,6 +28,7 @@ internal sealed class PriceCheckInputProfile
var hasDimensions = input.Length is > 0 && input.Width is > 0 && input.Height is > 0;
var hasPostal = !string.IsNullOrWhiteSpace(input.PostalCode);
var hasProvince = !string.IsNullOrWhiteSpace(input.DestinationProvince);
+ var hasCity = !string.IsNullOrWhiteSpace(input.DestinationCity);
var cargoCodes = (input.CargoTypes ?? [])
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
@@ -69,6 +72,7 @@ internal sealed class PriceCheckInputProfile
HasDimensions = hasDimensions,
HasPostalCode = hasPostal,
HasProvince = hasProvince,
+ HasCity = hasCity,
HasCargoTypeFilter = hasCargoFilter,
HasWarehouseFilter = hasWarehouseFilter,
HasShippingMethodFilter = hasShippingFilter,
@@ -87,6 +91,8 @@ internal static class PriceCheckMissingFieldCodes
public const string Province = "province";
+ public const string City = "city";
+
public const string CargoType = "cargoType";
public const string Warehouse = "warehouse";
diff --git a/src/Oms.Application/Shared/GeoPartitionMatcher.cs b/src/Oms.Application/Shared/GeoPartitionMatcher.cs
new file mode 100644
index 0000000..1aaae50
--- /dev/null
+++ b/src/Oms.Application/Shared/GeoPartitionMatcher.cs
@@ -0,0 +1,751 @@
+using System.Text.RegularExpressions;
+using Oms.Application.Dtos.Orders;
+using Oms.Domain.ChannelsAggregate.Entities;
+
+namespace Oms.Application.Shared;
+
+///
+/// 地理分区规则(价格/黑名单/偏远共用)。
+///
+public sealed class GeoRule
+{
+ public int Ordinal { get; init; }
+
+ public object? Tag { get; init; }
+
+ public string CountryCode { get; init; } = string.Empty;
+
+ /// zip / state / city / country
+ public string MatchDim { get; init; } = string.Empty;
+
+ /// prefix / exact / range / all
+ public string MatchMode { get; init; } = string.Empty;
+
+ public int PrefixLen { get; init; }
+
+ public string MatchTextStd { get; init; } = string.Empty;
+
+ public string ZipStartStd { get; init; } = string.Empty;
+
+ public string ZipEndStd { get; init; } = string.Empty;
+
+ public string MatchStateStd { get; init; } = string.Empty;
+}
+
+///
+/// 地址清洗、规则编译、单套分区匹配。价格/黑名单/偏远分区共用。
+///
+public static class GeoPartitionMatcher
+{
+ public const string DimZip = "zip";
+ public const string DimState = "state";
+ public const string DimCity = "city";
+ public const string DimCountry = "country";
+
+ public const string ModePrefix = "prefix";
+ public const string ModeExact = "exact";
+ public const string ModeRange = "range";
+ public const string ModeAll = "all";
+
+ private static readonly Regex PostcodeRangePattern = new(
+ @"^[\[(]\s*([^,)\]]*)\s*,\s*([^,)\]]*)\s*[\])]$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ public static string NormalizeCountry(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
+
+ public static string NormalizeZip(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ var chars = value.Where(ch => !char.IsWhiteSpace(ch) && ch != '-').ToArray();
+ return chars.Length == 0 ? string.Empty : new string(chars).ToUpperInvariant();
+ }
+
+ public static string NormalizeRegion(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
+
+ ///
+ /// 执行单套地理分区匹配。调用方需传入已排序规则:zip > state > city > country,同维度 prefixLen 降序。
+ ///
+ public static GeoRule? MatchSinglePartition(
+ string countryCode,
+ string zipStd,
+ string stateStd,
+ string cityStd,
+ IReadOnlyList ruleList)
+ {
+ var country = NormalizeCountry(countryCode);
+ zipStd ??= string.Empty;
+ stateStd ??= string.Empty;
+ cityStd ??= string.Empty;
+ if (ruleList == null || ruleList.Count == 0 || string.IsNullOrEmpty(country))
+ {
+ return null;
+ }
+
+ foreach (var rule in ruleList)
+ {
+ if (!string.Equals(rule.CountryCode, country, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (rule.MatchDim == DimZip)
+ {
+ if (string.IsNullOrEmpty(zipStd))
+ {
+ continue;
+ }
+
+ var hit = false;
+ if (rule.MatchMode == ModePrefix)
+ {
+ if (rule.PrefixLen > 0 && zipStd.Length >= rule.PrefixLen)
+ {
+ var userPrefix = zipStd[..rule.PrefixLen];
+ hit = userPrefix == rule.MatchTextStd;
+ }
+ }
+ else if (rule.MatchMode == ModeExact)
+ {
+ hit = zipStd == rule.MatchTextStd;
+ }
+ else if (rule.MatchMode == ModeRange)
+ {
+ hit = zipStd.CompareTo(rule.ZipStartStd) >= 0
+ && zipStd.CompareTo(rule.ZipEndStd) <= 0;
+ }
+
+ if (hit)
+ {
+ return rule;
+ }
+ }
+ else if (rule.MatchDim == DimState)
+ {
+ if (!string.IsNullOrEmpty(zipStd))
+ {
+ continue;
+ }
+
+ if (rule.MatchMode == ModeExact && stateStd == rule.MatchTextStd && !string.IsNullOrEmpty(stateStd))
+ {
+ return rule;
+ }
+ }
+ else if (rule.MatchDim == DimCity)
+ {
+ if (!string.IsNullOrEmpty(zipStd))
+ {
+ continue;
+ }
+
+ if (rule.MatchMode == ModeExact
+ && rule.MatchStateStd == stateStd
+ && rule.MatchTextStd == cityStd
+ && !string.IsNullOrEmpty(stateStd)
+ && !string.IsNullOrEmpty(cityStd))
+ {
+ return rule;
+ }
+ }
+ else if (rule.MatchDim == DimCountry)
+ {
+ return rule;
+ }
+ }
+
+ return null;
+ }
+
+ public static IReadOnlyList SortRules(IEnumerable rules) =>
+ rules
+ .OrderBy(DimRank)
+ .ThenByDescending(r => r.PrefixLen)
+ .ThenByDescending(RangeSpecificity)
+ .ThenBy(r => r.Ordinal)
+ .ToList();
+
+ public static GeoRule? Compile(KpiAreaInfo entity, int ordinal = 0, bool attachTag = true)
+ {
+ if (entity == null)
+ {
+ return null;
+ }
+
+ return Compile(
+ entity.CountryCode,
+ entity.Province,
+ entity.City,
+ entity.LeftPostcode,
+ entity.RightPostcode,
+ null,
+ entity.MatchDim,
+ entity.MatchMode,
+ entity.PrefixLen,
+ ordinal,
+ attachTag ? entity : null);
+ }
+
+ public static GeoRule? Compile(KpiAreaSnapshotRuleDto rule, int ordinal = 0, bool attachTag = true)
+ {
+ if (rule == null)
+ {
+ return null;
+ }
+
+ var range = KpiAreaSnapshotMapper.ResolvePostcodeRange(rule);
+ return Compile(
+ rule.CountryCode,
+ rule.Province,
+ rule.City,
+ rule.LeftPostcode,
+ rule.RightPostcode,
+ range,
+ rule.MatchDim,
+ rule.MatchMode,
+ rule.PrefixLen,
+ ordinal,
+ attachTag ? rule : null);
+ }
+
+ public static KpiAreaSnapshotRuleDto? MatchSnapshot(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode)
+ {
+ var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode);
+ return hit?.Tag as KpiAreaSnapshotRuleDto;
+ }
+
+ public static KpiAreaInfo? MatchEntity(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode)
+ {
+ var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode);
+ return hit?.Tag as KpiAreaInfo;
+ }
+
+ public static bool IsHit(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode) =>
+ MatchEntity(infos, countryCode, province, city, zipCode) != null;
+
+ public static bool IsHit(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode) =>
+ MatchSnapshot(infos, countryCode, province, city, zipCode) != null;
+
+ public static GeoRule? Match(
+ IReadOnlyList compiled,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode)
+ {
+ var sorted = SortRules(compiled);
+ return MatchSinglePartition(
+ NormalizeCountry(countryCode),
+ NormalizeZip(zipCode),
+ NormalizeRegion(province),
+ NormalizeRegion(city),
+ sorted);
+ }
+
+ ///
+ /// 清洗并收口为一维规则,写入 MatchDim / MatchMode / PrefixLen。
+ /// 显式维度优先;未传则按字段推断。
+ ///
+ public static void PrepareEntity(KpiAreaInfo entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ entity.CountryCode = string.IsNullOrWhiteSpace(entity.CountryCode) ? null : entity.CountryCode.Trim();
+ entity.CountryName = string.IsNullOrWhiteSpace(entity.CountryName) ? null : entity.CountryName.Trim();
+ entity.Province = EmptyToNull(entity.Province);
+ entity.City = EmptyToNull(entity.City);
+ entity.LeftPostcode = EmptyToNull(entity.LeftPostcode);
+ entity.RightPostcode = EmptyToNull(entity.RightPostcode);
+
+ if (string.IsNullOrWhiteSpace(entity.CountryCode))
+ {
+ throw new InvalidOperationException("国家代码不能为空");
+ }
+
+ var dim = NormalizeDim(entity.MatchDim);
+ var mode = NormalizeMode(entity.MatchMode);
+ if (string.IsNullOrEmpty(dim))
+ {
+ dim = InferDim(entity.LeftPostcode, entity.RightPostcode, entity.Province, entity.City);
+ }
+
+ switch (dim)
+ {
+ case DimZip:
+ ApplyZipFields(entity, mode);
+ break;
+ case DimState:
+ if (string.IsNullOrWhiteSpace(entity.Province))
+ {
+ throw new InvalidOperationException("州/省匹配必须填写州/省");
+ }
+
+ entity.City = null;
+ entity.LeftPostcode = null;
+ entity.RightPostcode = null;
+ entity.MatchDim = DimState;
+ entity.MatchMode = ModeExact;
+ entity.PrefixLen = 0;
+ break;
+ case DimCity:
+ if (string.IsNullOrWhiteSpace(entity.Province) || string.IsNullOrWhiteSpace(entity.City))
+ {
+ throw new InvalidOperationException("城市匹配必须同时填写州/省");
+ }
+
+ entity.LeftPostcode = null;
+ entity.RightPostcode = null;
+ entity.MatchDim = DimCity;
+ entity.MatchMode = ModeExact;
+ entity.PrefixLen = 0;
+ break;
+ default:
+ entity.Province = null;
+ entity.City = null;
+ entity.LeftPostcode = null;
+ entity.RightPostcode = null;
+ entity.MatchDim = DimCountry;
+ entity.MatchMode = ModeAll;
+ entity.PrefixLen = 0;
+ break;
+ }
+ }
+
+ public static (string MatchDim, string MatchMode, int PrefixLen) Describe(KpiAreaInfo? entity)
+ {
+ if (entity == null)
+ {
+ return (DimCountry, ModeAll, 0);
+ }
+
+ if (!string.IsNullOrWhiteSpace(entity.MatchDim))
+ {
+ return (
+ NormalizeDim(entity.MatchDim) ?? DimCountry,
+ NormalizeMode(entity.MatchMode) ?? ModeAll,
+ entity.PrefixLen);
+ }
+
+ var rule = Compile(entity, attachTag: false);
+ if (rule == null)
+ {
+ return (DimCountry, ModeAll, 0);
+ }
+
+ return (rule.MatchDim, rule.MatchMode, rule.PrefixLen);
+ }
+
+ private static GeoRule? Compile(
+ string? countryCode,
+ string? province,
+ string? city,
+ string? leftPostcode,
+ string? rightPostcode,
+ string? postcodeRange,
+ string? matchDim,
+ string? matchMode,
+ int prefixLenHint,
+ int ordinal,
+ object? tag)
+ {
+ var country = NormalizeCountry(countryCode);
+ if (string.IsNullOrEmpty(country))
+ {
+ return null;
+ }
+
+ var dim = NormalizeDim(matchDim);
+ if (string.IsNullOrEmpty(dim))
+ {
+ dim = InferDim(leftPostcode, rightPostcode, province, city, postcodeRange);
+ }
+
+ var encoded = TryParseEncodedRange(postcodeRange);
+ var zipLeft = NormalizeZip(leftPostcode);
+ var zipRight = NormalizeZip(rightPostcode);
+ var zipMode = NormalizeMode(matchMode) ?? encoded?.Mode;
+ var zipText = encoded?.Text ?? string.Empty;
+ var zipStart = encoded?.Start ?? string.Empty;
+ var zipEnd = encoded?.End ?? string.Empty;
+ var prefixLen = prefixLenHint > 0 ? prefixLenHint : encoded?.PrefixLen ?? 0;
+
+ if (zipMode is not (ModePrefix or ModeExact or ModeRange))
+ {
+ zipMode = encoded?.Mode;
+ }
+
+ if (zipMode == null)
+ {
+ if (zipLeft.Length > 0 && zipRight.Length == 0)
+ {
+ zipMode = ModePrefix;
+ zipText = zipLeft;
+ prefixLen = zipLeft.Length;
+ }
+ else if (zipLeft.Length > 0 && zipLeft == zipRight)
+ {
+ zipMode = ModeExact;
+ zipText = zipLeft;
+ prefixLen = zipLeft.Length;
+ }
+ else if (zipLeft.Length > 0 && zipRight.Length > 0)
+ {
+ zipMode = ModeRange;
+ zipStart = zipLeft;
+ zipEnd = zipRight;
+ prefixLen = Math.Min(zipLeft.Length, zipRight.Length);
+ }
+ }
+ else if (dim == DimZip)
+ {
+ if (zipMode == ModePrefix)
+ {
+ zipText = zipLeft.Length > 0 ? zipLeft : zipText;
+ prefixLen = prefixLen > 0 ? prefixLen : zipText.Length;
+ }
+ else if (zipMode == ModeExact)
+ {
+ zipText = zipLeft.Length > 0 ? zipLeft : zipText;
+ prefixLen = zipText.Length;
+ }
+ else if (zipMode == ModeRange)
+ {
+ zipStart = zipLeft.Length > 0 ? zipLeft : zipStart;
+ zipEnd = zipRight.Length > 0 ? zipRight : zipEnd;
+ prefixLen = Math.Min(
+ zipStart.Length == 0 ? prefixLen : zipStart.Length,
+ zipEnd.Length == 0 ? prefixLen : zipEnd.Length);
+ }
+ }
+
+ if (dim == DimZip)
+ {
+ if (zipMode == null)
+ {
+ return null;
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimZip,
+ MatchMode = zipMode,
+ PrefixLen = prefixLen,
+ MatchTextStd = zipText,
+ ZipStartStd = zipStart,
+ ZipEndStd = zipEnd
+ };
+ }
+
+ var state = NormalizeRegion(province);
+ var cityStd = NormalizeRegion(city);
+ if (dim == DimCity)
+ {
+ if (string.IsNullOrEmpty(state) || string.IsNullOrEmpty(cityStd))
+ {
+ return null;
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimCity,
+ MatchMode = ModeExact,
+ MatchTextStd = cityStd,
+ MatchStateStd = state
+ };
+ }
+
+ if (dim == DimState)
+ {
+ if (string.IsNullOrEmpty(state))
+ {
+ return null;
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimState,
+ MatchMode = ModeExact,
+ MatchTextStd = state
+ };
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimCountry,
+ MatchMode = ModeAll
+ };
+ }
+
+ private static void ApplyZipFields(KpiAreaInfo entity, string? requestedMode)
+ {
+ entity.Province = null;
+ entity.City = null;
+ var zipLeft = NormalizeZip(entity.LeftPostcode);
+ var zipRight = NormalizeZip(entity.RightPostcode);
+ if (zipLeft.Length == 0 && zipRight.Length > 0)
+ {
+ zipLeft = zipRight;
+ zipRight = string.Empty;
+ }
+
+ if (zipLeft.Length == 0)
+ {
+ throw new InvalidOperationException("邮编匹配必须填写起始邮编");
+ }
+
+ var mode = requestedMode;
+ if (mode is not (ModePrefix or ModeExact or ModeRange))
+ {
+ if (zipRight.Length == 0)
+ {
+ mode = ModePrefix;
+ }
+ else if (zipLeft == zipRight)
+ {
+ mode = ModeExact;
+ }
+ else
+ {
+ mode = ModeRange;
+ }
+ }
+
+ if (mode == ModePrefix)
+ {
+ entity.LeftPostcode = zipLeft;
+ entity.RightPostcode = null;
+ entity.PrefixLen = zipLeft.Length;
+ }
+ else if (mode == ModeExact)
+ {
+ entity.LeftPostcode = zipLeft;
+ entity.RightPostcode = zipLeft;
+ entity.PrefixLen = zipLeft.Length;
+ }
+ else
+ {
+ if (zipRight.Length == 0)
+ {
+ throw new InvalidOperationException("邮编区间必须填写截止邮编");
+ }
+
+ if (zipLeft.CompareTo(zipRight) > 0)
+ {
+ throw new InvalidOperationException("起始邮编不能大于截止邮编");
+ }
+
+ entity.LeftPostcode = zipLeft;
+ entity.RightPostcode = zipRight;
+ entity.PrefixLen = Math.Min(zipLeft.Length, zipRight.Length);
+ }
+
+ entity.MatchDim = DimZip;
+ entity.MatchMode = mode;
+ }
+
+ private static string InferDim(
+ string? leftPostcode,
+ string? rightPostcode,
+ string? province,
+ string? city,
+ string? postcodeRange = null)
+ {
+ if (!string.IsNullOrWhiteSpace(postcodeRange)
+ || !string.IsNullOrWhiteSpace(leftPostcode)
+ || !string.IsNullOrWhiteSpace(rightPostcode))
+ {
+ return DimZip;
+ }
+
+ if (!string.IsNullOrWhiteSpace(city))
+ {
+ return DimCity;
+ }
+
+ if (!string.IsNullOrWhiteSpace(province))
+ {
+ return DimState;
+ }
+
+ return DimCountry;
+ }
+
+ private static string? NormalizeDim(string? value)
+ {
+ var raw = value?.Trim().ToLowerInvariant();
+ return raw switch
+ {
+ DimZip or "postcode" or "postal" or "邮编" => DimZip,
+ DimState or "province" or "州" or "州/省" or "省" => DimState,
+ DimCity or "城市" => DimCity,
+ DimCountry or "国家" or "仅国家" => DimCountry,
+ _ => null
+ };
+ }
+
+ private static string? NormalizeMode(string? value)
+ {
+ var raw = value?.Trim().ToLowerInvariant();
+ return raw switch
+ {
+ ModePrefix or "前缀" => ModePrefix,
+ ModeExact or "精确" or "exactmatch" => ModeExact,
+ ModeRange or "区间" => ModeRange,
+ ModeAll or "全部" => ModeAll,
+ _ => null
+ };
+ }
+
+ private static EncodedZip? TryParseEncodedRange(string? postcodeRange)
+ {
+ if (string.IsNullOrWhiteSpace(postcodeRange))
+ {
+ return null;
+ }
+
+ var raw = postcodeRange.Trim();
+ if (raw.StartsWith("prefix:", StringComparison.OrdinalIgnoreCase))
+ {
+ var text = NormalizeZip(raw["prefix:".Length..]);
+ if (text.Length == 0)
+ {
+ return null;
+ }
+
+ return new EncodedZip(ModePrefix, text, string.Empty, string.Empty, text.Length);
+ }
+
+ if (raw.StartsWith("exact:", StringComparison.OrdinalIgnoreCase))
+ {
+ var text = NormalizeZip(raw["exact:".Length..]);
+ if (text.Length == 0)
+ {
+ return null;
+ }
+
+ return new EncodedZip(ModeExact, text, string.Empty, string.Empty, text.Length);
+ }
+
+ var match = PostcodeRangePattern.Match(raw);
+ if (!match.Success)
+ {
+ return null;
+ }
+
+ var left = NormalizeZip(match.Groups[1].Value);
+ var right = NormalizeZip(match.Groups[2].Value);
+ if (left.Length == 0 && right.Length == 0)
+ {
+ return null;
+ }
+
+ if (left.Length == 0)
+ {
+ left = right;
+ }
+
+ if (right.Length == 0)
+ {
+ return new EncodedZip(ModePrefix, left, string.Empty, string.Empty, left.Length);
+ }
+
+ if (left == right)
+ {
+ return new EncodedZip(ModeExact, left, string.Empty, string.Empty, left.Length);
+ }
+
+ return new EncodedZip(ModeRange, string.Empty, left, right, Math.Min(left.Length, right.Length));
+ }
+
+ private static List CompileList(IEnumerable? sources, Func compile)
+ {
+ var list = new List();
+ if (sources == null)
+ {
+ return list;
+ }
+
+ var ordinal = 0;
+ foreach (var source in sources)
+ {
+ var rule = compile(source, ordinal++, true);
+ if (rule != null)
+ {
+ list.Add(rule);
+ }
+ }
+
+ return list;
+ }
+
+ private static int DimRank(GeoRule rule) =>
+ rule.MatchDim switch
+ {
+ DimZip => 1,
+ DimState => 2,
+ DimCity => 3,
+ DimCountry => 4,
+ _ => 9
+ };
+
+ private static int RangeSpecificity(GeoRule rule)
+ {
+ if (rule.MatchDim != DimZip)
+ {
+ return 0;
+ }
+
+ if (rule.MatchMode == ModeExact)
+ {
+ return rule.MatchTextStd.Length * 10;
+ }
+
+ if (rule.MatchMode == ModeRange)
+ {
+ return Math.Min(rule.ZipStartStd.Length, rule.ZipEndStd.Length);
+ }
+
+ return 0;
+ }
+
+ private static string? EmptyToNull(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ private sealed record EncodedZip(string Mode, string Text, string Start, string End, int PrefixLen);
+}
diff --git a/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs
index 7377412..2ebfabf 100644
--- a/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs
+++ b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs
@@ -36,6 +36,14 @@ public static class KpiAreaInfoImportHelper
["RightPostcode"] = nameof(KpiAreaInfo.RightPostcode),
["rightPostcode"] = nameof(KpiAreaInfo.RightPostcode),
["rightPostCode"] = nameof(KpiAreaInfo.RightPostcode),
+ ["匹配维度"] = nameof(KpiAreaInfo.MatchDim),
+ ["MatchDim"] = nameof(KpiAreaInfo.MatchDim),
+ ["matchDim"] = nameof(KpiAreaInfo.MatchDim),
+ ["匹配方式"] = nameof(KpiAreaInfo.MatchMode),
+ ["MatchMode"] = nameof(KpiAreaInfo.MatchMode),
+ ["matchMode"] = nameof(KpiAreaInfo.MatchMode),
+ ["前缀长度"] = nameof(KpiAreaInfo.PrefixLen),
+ ["PrefixLen"] = nameof(KpiAreaInfo.PrefixLen),
};
public static async Task BuildTemplateBytesAsync()
@@ -46,10 +54,34 @@ public static class KpiAreaInfoImportHelper
{
["国家代码"] = "US",
["国家名称"] = "美国",
- ["州/省"] = "CA",
+ ["匹配维度"] = "zip",
+ ["匹配方式"] = "range",
+ ["州/省"] = "",
["城市"] = "",
["起始邮编"] = "90000",
["截止邮编"] = "90199"
+ },
+ new()
+ {
+ ["国家代码"] = "US",
+ ["国家名称"] = "美国",
+ ["匹配维度"] = "zip",
+ ["匹配方式"] = "prefix",
+ ["州/省"] = "",
+ ["城市"] = "",
+ ["起始邮编"] = "90",
+ ["截止邮编"] = ""
+ },
+ new()
+ {
+ ["国家代码"] = "US",
+ ["国家名称"] = "美国",
+ ["匹配维度"] = "state",
+ ["匹配方式"] = "exact",
+ ["州/省"] = "CA",
+ ["城市"] = "",
+ ["起始邮编"] = "",
+ ["截止邮编"] = ""
}
};
@@ -134,7 +166,12 @@ public static class KpiAreaInfoImportHelper
Province = mapped.GetValueOrDefault(nameof(KpiAreaInfo.Province)),
City = mapped.GetValueOrDefault(nameof(KpiAreaInfo.City)),
LeftPostcode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.LeftPostcode)),
- RightPostcode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.RightPostcode))
+ RightPostcode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.RightPostcode)),
+ MatchDim = mapped.GetValueOrDefault(nameof(KpiAreaInfo.MatchDim)),
+ MatchMode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.MatchMode)),
+ PrefixLen = int.TryParse(mapped.GetValueOrDefault(nameof(KpiAreaInfo.PrefixLen)), out var prefixLen)
+ ? prefixLen
+ : 0
};
}
@@ -177,13 +214,7 @@ public static class KpiAreaInfoImportHelper
ValidatePostcode(entity.LeftPostcode, "起始邮编");
ValidatePostcode(entity.RightPostcode, "截止邮编");
-
- var hasLeft = !string.IsNullOrWhiteSpace(entity.LeftPostcode);
- var hasRight = !string.IsNullOrWhiteSpace(entity.RightPostcode);
- if (hasLeft != hasRight)
- {
- throw new InvalidOperationException("起始邮编与截止邮编需同时填写或同时留空");
- }
+ GeoPartitionMatcher.PrepareEntity(entity);
}
private static void ValidatePostcode(string? value, string label)
diff --git a/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs b/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs
index 166cf03..8644700 100644
--- a/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs
+++ b/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs
@@ -1,18 +1,13 @@
-using System.Text.RegularExpressions;
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Application.Shared;
///
-/// KPI 分区快照映射与左开右闭邮编匹配。
+/// KPI 分区快照映射。邮编区间为闭区间,prefix/exact/range 编码进 PostcodeRange。
///
public static class KpiAreaSnapshotMapper
{
- private static readonly Regex PostcodeRangePattern = new(
- @"^\(\s*([^,)\]]*)\s*,\s*([^,)\]]*)\s*\]$",
- RegexOptions.Compiled | RegexOptions.CultureInvariant);
-
public static KpiAreaSnapshotRuleDto MapToSnapshotRule(KpiAreaInfo entity) =>
new()
{
@@ -20,17 +15,37 @@ public static class KpiAreaSnapshotMapper
CountryCode = entity.CountryCode,
Province = entity.Province,
City = entity.City,
+ MatchDim = entity.MatchDim,
+ MatchMode = entity.MatchMode,
+ PrefixLen = entity.PrefixLen,
PostcodeRange = BuildPostcodeRange(entity.LeftPostcode, entity.RightPostcode)
};
public static string? BuildPostcodeRange(string? left, string? right)
{
- if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right))
+ var zipLeft = GeoPartitionMatcher.NormalizeZip(left);
+ var zipRight = GeoPartitionMatcher.NormalizeZip(right);
+ if (zipLeft.Length == 0 && zipRight.Length == 0)
{
return null;
}
- return $"({left?.Trim()},{right?.Trim()}]";
+ if (zipLeft.Length > 0 && zipRight.Length == 0)
+ {
+ return $"prefix:{zipLeft}";
+ }
+
+ if (zipLeft.Length == 0 && zipRight.Length > 0)
+ {
+ return $"prefix:{zipRight}";
+ }
+
+ if (zipLeft == zipRight)
+ {
+ return $"exact:{zipLeft}";
+ }
+
+ return $"[{zipLeft},{zipRight}]";
}
public static string? ResolvePostcodeRange(KpiAreaSnapshotRuleDto rule)
@@ -48,58 +63,6 @@ public static class KpiAreaSnapshotMapper
string countryCode,
string? province,
string? city,
- string? zipCode)
- {
- if (!string.IsNullOrWhiteSpace(rule.CountryCode)
- && !string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.Province)
- && !string.Equals(rule.Province, province, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.City)
- && !string.Equals(rule.City, city, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- var postcodeRange = ResolvePostcodeRange(rule);
- if (string.IsNullOrWhiteSpace(postcodeRange))
- {
- return true;
- }
-
- if (string.IsNullOrWhiteSpace(zipCode))
- {
- return false;
- }
-
- return IsZipInLeftOpenRightClosedRange(zipCode.Trim(), postcodeRange);
- }
-
- public static bool IsZipInLeftOpenRightClosedRange(string zipCode, string postcodeRange)
- {
- var match = PostcodeRangePattern.Match(postcodeRange.Trim());
- if (!match.Success)
- {
- return false;
- }
-
- var left = match.Groups[1].Value.Trim();
- var right = match.Groups[2].Value.Trim();
- if (long.TryParse(zipCode, out var zip)
- && long.TryParse(left, out var leftValue)
- && long.TryParse(right, out var rightValue))
- {
- return zip > leftValue && zip <= rightValue;
- }
-
- return string.Compare(zipCode, left, StringComparison.OrdinalIgnoreCase) > 0
- && string.Compare(zipCode, right, StringComparison.OrdinalIgnoreCase) <= 0;
- }
+ string? zipCode) =>
+ GeoPartitionMatcher.IsHit([rule], countryCode, province, city, zipCode);
}
diff --git a/src/Oms.Application/Shared/RulesSnapshotHelper.cs b/src/Oms.Application/Shared/RulesSnapshotHelper.cs
index fc5744c..88f9062 100644
--- a/src/Oms.Application/Shared/RulesSnapshotHelper.cs
+++ b/src/Oms.Application/Shared/RulesSnapshotHelper.cs
@@ -64,12 +64,7 @@ public static class RulesSnapshotHelper
return false;
}
- return infos.Any(rule => KpiAreaSnapshotMapper.IsRuleMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return GeoPartitionMatcher.IsHit(infos, countryCode, province, city, zipCode);
}
///
diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs b/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs
index 3c5d130..0e016fa 100644
--- a/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs
+++ b/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs
@@ -19,4 +19,12 @@ public class KpiAreaInfo : OmsAuditedEntity
public string? LeftPostcode { get; set; }
public string? RightPostcode { get; set; }
+
+ /// zip / state / city / country
+ public string? MatchDim { get; set; }
+
+ /// prefix / exact / range / all
+ public string? MatchMode { get; set; }
+
+ public int PrefixLen { get; set; }
}
diff --git a/src/Oms.Tests/GeoPartitionMatcherTests.cs b/src/Oms.Tests/GeoPartitionMatcherTests.cs
new file mode 100644
index 0000000..567998a
--- /dev/null
+++ b/src/Oms.Tests/GeoPartitionMatcherTests.cs
@@ -0,0 +1,145 @@
+using FluentAssertions;
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Shared;
+using Oms.Domain.ChannelsAggregate.Entities;
+
+namespace Oms.Tests;
+
+public class GeoPartitionMatcherTests
+{
+ [Fact]
+ public void ZipPrefix_LongerPrefixWins()
+ {
+ var rules = CompileEntities(
+ Zip("US", "9"),
+ Zip("US", "90"));
+
+ var hit = GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LA", "90001");
+ hit!.LeftPostcode.Should().Be("90");
+ }
+
+ [Fact]
+ public void ZipPresent_ShouldSkipStateAndCity()
+ {
+ var rules = CompileEntities(
+ State("US", "CA"),
+ City("US", "CA", "LA"));
+
+ GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LA", "90001").Should().BeNull();
+ }
+
+ [Fact]
+ public void ZipEmpty_StateExact_ShouldHit()
+ {
+ var rules = CompileEntities(
+ Zip("US", "90"),
+ State("US", "CA"));
+
+ var hit = GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LA", null);
+ hit!.Province.Should().Be("CA");
+ hit.LeftPostcode.Should().BeNull();
+ }
+
+ [Fact]
+ public void ZipEmpty_CityRequiresState()
+ {
+ var rules = CompileEntities(City("US", "CA", "LOSANGELES"));
+
+ GeoPartitionMatcher.MatchEntity(rules, "US", "NY", "LOSANGELES", null).Should().BeNull();
+ GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LOSANGELES", null).Should().NotBeNull();
+ }
+
+ [Fact]
+ public void ZipRange_ShouldBeClosedInterval()
+ {
+ var rules = CompileEntities(Range("US", "90000", "90199"));
+
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "90000").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "90199").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "90200").Should().BeFalse();
+ }
+
+ [Fact]
+ public void CountryFallback_ShouldHitWhenZipMisses()
+ {
+ var rules = CompileEntities(
+ Range("US", "10000", "19999"),
+ CountryOnly("US"));
+
+ var hit = GeoPartitionMatcher.MatchEntity(rules, "US", "CA", null, "90001");
+ hit!.LeftPostcode.Should().BeNull();
+ hit.Province.Should().BeNull();
+ }
+
+ [Fact]
+ public void ThreePartitions_ShouldNotInterfere()
+ {
+ var black = CompileEntities(Range("US", "90000", "90010"));
+ var remote = CompileEntities(Range("US", "05999", "06999"));
+ var price = CompileEntities(Zip("US", "90"));
+
+ GeoPartitionMatcher.IsHit(black, "US", null, null, "90005").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(remote, "US", null, null, "90005").Should().BeFalse();
+ GeoPartitionMatcher.MatchEntity(price, "US", null, null, "90005")!.LeftPostcode.Should().Be("90");
+ }
+
+ [Fact]
+ public void SnapshotLegacyLeftOpenFormat_ShouldParseAsClosed()
+ {
+ var infos = new List
+ {
+ new() { Id = 1, CountryCode = "US", PostcodeRange = "(00000,50000]" }
+ };
+
+ GeoPartitionMatcher.IsHit(infos, "US", null, null, "00000").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(infos, "US", null, null, "50000").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(infos, "US", null, null, "50001").Should().BeFalse();
+ }
+
+ [Fact]
+ public void PrepareEntity_PrefixKeepsRightEmpty()
+ {
+ var entity = new KpiAreaInfo { CountryCode = "us", LeftPostcode = "90" };
+ GeoPartitionMatcher.PrepareEntity(entity);
+ entity.LeftPostcode.Should().Be("90");
+ entity.RightPostcode.Should().BeNull();
+ entity.MatchDim.Should().Be("zip");
+ entity.MatchMode.Should().Be("prefix");
+ entity.PrefixLen.Should().Be(2);
+ GeoPartitionMatcher.Describe(entity).MatchDim.Should().Be("zip");
+ GeoPartitionMatcher.Describe(entity).MatchMode.Should().Be("prefix");
+ }
+
+ [Fact]
+ public void PrepareEntity_CityWithoutState_ShouldThrow()
+ {
+ var entity = new KpiAreaInfo { CountryCode = "US", City = "LA" };
+ var act = () => GeoPartitionMatcher.PrepareEntity(entity);
+ act.Should().Throw().WithMessage("*州*");
+ }
+
+ private static List CompileEntities(params KpiAreaInfo[] items)
+ {
+ foreach (var item in items)
+ {
+ GeoPartitionMatcher.PrepareEntity(item);
+ }
+
+ return items.ToList();
+ }
+
+ private static KpiAreaInfo Zip(string country, string prefix) =>
+ new() { CountryCode = country, LeftPostcode = prefix };
+
+ private static KpiAreaInfo Range(string country, string left, string right) =>
+ new() { CountryCode = country, LeftPostcode = left, RightPostcode = right };
+
+ private static KpiAreaInfo State(string country, string state) =>
+ new() { CountryCode = country, Province = state };
+
+ private static KpiAreaInfo City(string country, string state, string city) =>
+ new() { CountryCode = country, Province = state, City = city };
+
+ private static KpiAreaInfo CountryOnly(string country) =>
+ new() { CountryCode = country };
+}
diff --git a/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs
index 1adc5ed..2f62e90 100644
--- a/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs
+++ b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs
@@ -27,17 +27,18 @@ public class KpiAreaInfoImportHelperTests
}
[Fact]
- public void PrepareEntityForImport_MissingPostcodePair_ShouldThrow()
+ public void PrepareEntityForImport_PrefixOnlyLeft_ShouldSucceed()
{
var entity = new KpiAreaInfo
{
CountryCode = "US",
CountryName = "美国",
- LeftPostcode = "90000"
+ LeftPostcode = "90"
};
- var act = () => KpiAreaInfoImportHelper.PrepareEntityForImport(entity, new Dictionary());
+ KpiAreaInfoImportHelper.PrepareEntityForImport(entity, new Dictionary());
- act.Should().Throw().WithMessage("*截止邮编*");
+ entity.LeftPostcode.Should().Be("90");
+ entity.RightPostcode.Should().BeNull();
}
}
diff --git a/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs b/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs
index a612862..58f5cf4 100644
--- a/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs
+++ b/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs
@@ -5,19 +5,21 @@ namespace Oms.Tests;
public class KpiAreaSnapshotMapperTests
{
- [Theory]
- [InlineData("06000", "(00000,50000]", true)]
- [InlineData("00000", "(00000,50000]", false)]
- [InlineData("50000", "(00000,50000]", true)]
- [InlineData("50001", "(00000,50000]", false)]
- public void IsZipInLeftOpenRightClosedRange_ShouldMatchExpected(string zip, string range, bool expected)
+ [Fact]
+ public void BuildPostcodeRange_ShouldEncodeClosedRange()
{
- KpiAreaSnapshotMapper.IsZipInLeftOpenRightClosedRange(zip, range).Should().Be(expected);
+ KpiAreaSnapshotMapper.BuildPostcodeRange("00000", "50000").Should().Be("[00000,50000]");
}
[Fact]
- public void BuildPostcodeRange_ShouldFormatLeftOpenRightClosed()
+ public void BuildPostcodeRange_EqualBounds_ShouldEncodeExact()
{
- KpiAreaSnapshotMapper.BuildPostcodeRange("00000", "50000").Should().Be("(00000,50000]");
+ KpiAreaSnapshotMapper.BuildPostcodeRange("90001", "90001").Should().Be("exact:90001");
+ }
+
+ [Fact]
+ public void BuildPostcodeRange_LeftOnly_ShouldEncodePrefix()
+ {
+ KpiAreaSnapshotMapper.BuildPostcodeRange("90", null).Should().Be("prefix:90");
}
}
--
2.45.2
From c91d8ca0e6d5202586d9a09f5cb60eae1f667da9 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Mon, 5 Oct 2026 16:30:18 +0800
Subject: [PATCH 20/41] =?UTF-8?q?test:=20=E8=A1=A5=E9=BD=90=20GB/CA=20?=
=?UTF-8?q?=E5=AD=97=E6=AF=8D=E6=95=B0=E5=AD=97=E9=82=AE=E7=BC=96=E5=89=8D?=
=?UTF-8?q?=E7=BC=80=E3=80=81=E7=B2=BE=E7=A1=AE=E4=B8=8E=E9=97=AD=E5=8C=BA?=
=?UTF-8?q?=E9=97=B4=E5=8C=B9=E9=85=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
src/Oms.Tests/GeoPartitionMatcherTests.cs | 107 ++++++++++++++++++++++
1 file changed, 107 insertions(+)
diff --git a/src/Oms.Tests/GeoPartitionMatcherTests.cs b/src/Oms.Tests/GeoPartitionMatcherTests.cs
index 567998a..98d8d3a 100644
--- a/src/Oms.Tests/GeoPartitionMatcherTests.cs
+++ b/src/Oms.Tests/GeoPartitionMatcherTests.cs
@@ -118,6 +118,110 @@ public class GeoPartitionMatcherTests
act.Should().Throw().WithMessage("*州*");
}
+ [Theory]
+ [InlineData("BT9 7AA", "BT97AA")]
+ [InlineData("bt97aa", "BT97AA")]
+ [InlineData("BT9-7AA", "BT97AA")]
+ [InlineData("B46 3AB", "B463AB")]
+ [InlineData(" b46 3ab ", "B463AB")]
+ public void NormalizeZip_AlphanumericWithSpaces_ShouldStripAndUpper(string raw, string expected)
+ {
+ GeoPartitionMatcher.NormalizeZip(raw).Should().Be(expected);
+ }
+
+ [Fact]
+ public void Gb_Bt97aa_LongerOutwardPrefixWins()
+ {
+ var rules = CompileEntities(
+ Zip("GB", "BT"),
+ Zip("GB", "BT9"),
+ Zip("GB", "BT97"),
+ Zip("GB", "B"));
+
+ var hit = GeoPartitionMatcher.MatchEntity(rules, "GB", "NIR", "Belfast", "BT9 7AA");
+ hit!.LeftPostcode.Should().Be("BT97");
+ GeoPartitionMatcher.MatchEntity(rules, "gb", null, null, "BT97AA")!.LeftPostcode.Should().Be("BT97");
+ }
+
+ [Fact]
+ public void Gb_Bt97aa_ExactIgnoresSpaces()
+ {
+ var rules = CompileEntities(Exact("GB", "BT9 7AA"));
+
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT97AA").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT9 7AA").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT9 7AB").Should().BeFalse();
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT98AA").Should().BeFalse();
+ }
+
+ [Fact]
+ public void Gb_Bt97aa_ClosedAlphanumericRange()
+ {
+ var rules = CompileEntities(Range("GB", "BT90AA", "BT99ZZ"));
+
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT97AA").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT9 7AA").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT90AA").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT99ZZ").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT8 1AA").Should().BeFalse();
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "E1 6AN").Should().BeFalse();
+ }
+
+ [Fact]
+ public void Ca_B463ab_PrefixAndSpaceVariants()
+ {
+ var rules = CompileEntities(
+ Zip("CA", "B"),
+ Zip("CA", "B4"),
+ Zip("CA", "B46"),
+ Zip("CA", "B463"));
+
+ var hit = GeoPartitionMatcher.MatchEntity(rules, "CA", "ON", "Toronto", "B46 3AB");
+ hit!.LeftPostcode.Should().Be("B463");
+ GeoPartitionMatcher.IsHit(rules, "CA", null, null, "B463AB").Should().BeTrue();
+ GeoPartitionMatcher.MatchEntity(rules, "CA", null, null, "B47 3AB")!.LeftPostcode.Should().Be("B4");
+ GeoPartitionMatcher.IsHit(rules, "CA", null, null, "M5V 2T6").Should().BeFalse();
+ }
+
+ [Fact]
+ public void Ca_B463ab_ExactAndRange()
+ {
+ var exact = CompileEntities(Exact("CA", "B46 3AB"));
+ var range = CompileEntities(Range("CA", "B460AA", "B469ZZ"));
+
+ GeoPartitionMatcher.IsHit(exact, "CA", null, null, "B46 3AB").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(exact, "CA", null, null, "B463AB").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(exact, "CA", null, null, "B46 3AC").Should().BeFalse();
+
+ GeoPartitionMatcher.IsHit(range, "CA", null, null, "B46 3AB").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(range, "CA", null, null, "B46 0AA").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(range, "CA", null, null, "B47 3AB").Should().BeFalse();
+ }
+
+ [Fact]
+ public void GbAndCa_SameLookingPostcode_CountryMustMatch()
+ {
+ var gb = CompileEntities(Zip("GB", "B46"), Exact("GB", "BT97AA"));
+ var ca = CompileEntities(Zip("CA", "B46"));
+
+ GeoPartitionMatcher.IsHit(gb, "GB", null, null, "B46 3AB").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(gb, "CA", null, null, "B46 3AB").Should().BeFalse();
+ GeoPartitionMatcher.IsHit(ca, "CA", null, null, "B46 3AB").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(ca, "GB", null, null, "BT97AA").Should().BeFalse();
+ GeoPartitionMatcher.IsHit(gb, "GB", null, null, "BT97AA").Should().BeTrue();
+ }
+
+ [Fact]
+ public void Gb_Bt97aa_ZipPresent_ShouldSkipState()
+ {
+ var rules = CompileEntities(
+ State("GB", "NIR"),
+ Zip("GB", "BT1"));
+
+ GeoPartitionMatcher.MatchEntity(rules, "GB", "NIR", "Belfast", "BT97AA").Should().BeNull();
+ GeoPartitionMatcher.MatchEntity(rules, "GB", "NIR", "Belfast", null)!.Province.Should().Be("NIR");
+ }
+
private static List CompileEntities(params KpiAreaInfo[] items)
{
foreach (var item in items)
@@ -131,6 +235,9 @@ public class GeoPartitionMatcherTests
private static KpiAreaInfo Zip(string country, string prefix) =>
new() { CountryCode = country, LeftPostcode = prefix };
+ private static KpiAreaInfo Exact(string country, string postcode) =>
+ new() { CountryCode = country, LeftPostcode = postcode, RightPostcode = postcode };
+
private static KpiAreaInfo Range(string country, string left, string right) =>
new() { CountryCode = country, LeftPostcode = left, RightPostcode = right };
--
2.45.2
From b748aa40007da8a0ab8b2727ccc4af8160865308 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Mon, 5 Oct 2026 17:35:18 +0800
Subject: [PATCH 21/41] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E5=88=86?=
=?UTF-8?q?=E5=8C=BA=20range=E3=80=81=E8=BF=9B=E4=BD=8D=E7=9B=B8=E6=8E=A5?=
=?UTF-8?q?=E4=B8=8E=E4=BB=85=E5=9B=BD=E5=AE=B6=E4=BC=B0=E7=AE=97=E5=8F=A3?=
=?UTF-8?q?=E5=BE=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
进位区间改为左开右闭且禁止空洞,邮编 range 按数字/字母数字比较,查价仅国家时不再串用别国 zone。
Co-authored-by: Cursor
---
.../Channels/WeightRoundingService.cs | 12 +++
.../Orders/ParcelBillingCalculator.cs | 40 ++++++--
.../Services/Orders/PriceCheckInputProfile.cs | 5 +
.../Shared/GeoPartitionMatcher.cs | 80 ++++++++++++++-
.../Shared/WeightRoundingRangeGuard.cs | 75 ++++++++++++++
src/Oms.Tests/GeoPartitionMatcherTests.cs | 32 ++++++
src/Oms.Tests/ParcelBillingCalculatorTests.cs | 23 +++++
.../PriceCheckSnapshotVerificationTests.cs | 23 ++++-
.../WeightRoundingRangeGuardTests.cs | 97 +++++++++++++++++++
9 files changed, 370 insertions(+), 17 deletions(-)
create mode 100644 src/Oms.Application/Shared/WeightRoundingRangeGuard.cs
create mode 100644 src/Oms.Tests/WeightRoundingRangeGuardTests.cs
diff --git a/src/Oms.Application/Services/Channels/WeightRoundingService.cs b/src/Oms.Application/Services/Channels/WeightRoundingService.cs
index c919d5b..a24f4a7 100644
--- a/src/Oms.Application/Services/Channels/WeightRoundingService.cs
+++ b/src/Oms.Application/Services/Channels/WeightRoundingService.cs
@@ -113,6 +113,7 @@ public class WeightRoundingService : ApplicationService
public async Task AddInfoAsync(WeightRoundingInfoSaveInput input)
{
await GetEntityOrThrowAsync(input.RoundingId);
+ await ValidateInfoRangeAsync(input);
await WeightRoundingRepository.InsertInfoAsync(new WeightRoundingInfo
{
RoundingId = input.RoundingId,
@@ -134,6 +135,7 @@ public class WeightRoundingService : ApplicationService
{
var entity = await GetInfoOrThrowAsync(input.Id);
await GetEntityOrThrowAsync(input.RoundingId);
+ await ValidateInfoRangeAsync(input, entity.Id);
entity.RoundingId = input.RoundingId;
entity.StartValue = input.StartValue;
entity.EndValue = input.EndValue;
@@ -202,6 +204,16 @@ public class WeightRoundingService : ApplicationService
return entity;
}
+ private async Task ValidateInfoRangeAsync(WeightRoundingInfoSaveInput input, int? excludeId = null)
+ {
+ var existed = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(input.RoundingId);
+ WeightRoundingRangeGuard.EnsureContiguous(
+ input.StartValue,
+ input.EndValue,
+ existed,
+ excludeId ?? input.Id);
+ }
+
private static WeightRoundingOutput MapOutput(WeightRounding entity)
{
return new WeightRoundingOutput
diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
index 0858b86..3b781c8 100644
--- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
+++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
@@ -66,8 +66,9 @@ internal static class ParcelBillingCalculator
public static decimal ApplyRounding(decimal rawWeight, IReadOnlyList ranges)
{
var hit = ranges
- .OrderBy(x => x.Id)
- .FirstOrDefault(x => rawWeight >= x.StartValue && rawWeight <= x.EndValue);
+ .OrderBy(x => x.StartValue)
+ .ThenBy(x => x.Id)
+ .FirstOrDefault(x => WeightInLeftOpenRightClosedInterval(rawWeight, x.StartValue, x.EndValue));
if (hit == null)
{
throw new InvalidOperationException("计重进位区间未匹配");
@@ -129,8 +130,8 @@ internal static class ParcelBillingCalculator
}
///
- /// 查价仅填国家时:若快照已配置该目的国分区(即使规则带邮编区间),
- /// 则返回卖价明细中的分区键供估算报价;未配置该国家时返回空(不跨国家兜底)。
+ /// 查价仅填国家时:只使用该国分区规则对应的 zone 键(及与这些键重合的卖价明细分区)。
+ /// 未配置该国家时返回空;不把别国明细分区拿来估算。
///
public static IReadOnlyList CollectCountryEstimateZoneKeys(
SalesProductPricePublishSnapshotDto? priceSnapshot,
@@ -143,15 +144,34 @@ internal static class ParcelBillingCalculator
return [];
}
- var countryConfigured = priceSnapshot.PriceAreaInfos.Any(rule =>
- string.IsNullOrWhiteSpace(rule.CountryCode)
- || string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase));
- if (!countryConfigured)
+ var countryRules = priceSnapshot.PriceAreaInfos
+ .Where(rule => string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ if (countryRules.Count == 0)
{
return [];
}
- return CollectDistinctDetailZoneKeys(priceSnapshot);
+ var allowed = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var rule in countryRules)
+ {
+ foreach (var key in BuildPriceZoneKeys(rule, priceSnapshot.PriceAreaName, priceSnapshot.PriceAreaId))
+ {
+ if (!string.IsNullOrWhiteSpace(key))
+ {
+ allowed.Add(key.Trim());
+ }
+ }
+ }
+
+ var detailZones = CollectDistinctDetailZoneKeys(priceSnapshot);
+ var mapped = detailZones.Where(zone => allowed.Contains(zone)).ToList();
+ if (mapped.Count > 0)
+ {
+ return mapped;
+ }
+
+ return allowed.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
}
public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area)
@@ -2170,7 +2190,7 @@ internal static class ParcelBillingCalculator
}
catch
{
- return false;
+ throw new InvalidOperationException($"计重规则条件无效:{condition}");
}
}
diff --git a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
index 895f8c3..c83a0c1 100644
--- a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
+++ b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
@@ -52,6 +52,11 @@ internal sealed class PriceCheckInputProfile
missing.Add(PriceCheckMissingFieldCodes.PostalCode);
}
+ if (!hasPostal && !hasCity)
+ {
+ missing.Add(PriceCheckMissingFieldCodes.City);
+ }
+
if (!hasProvince)
{
missing.Add(PriceCheckMissingFieldCodes.Province);
diff --git a/src/Oms.Application/Shared/GeoPartitionMatcher.cs b/src/Oms.Application/Shared/GeoPartitionMatcher.cs
index 1aaae50..4eab0fd 100644
--- a/src/Oms.Application/Shared/GeoPartitionMatcher.cs
+++ b/src/Oms.Application/Shared/GeoPartitionMatcher.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using System.Text.RegularExpressions;
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities;
@@ -65,6 +66,79 @@ public static class GeoPartitionMatcher
return chars.Length == 0 ? string.Empty : new string(chars).ToUpperInvariant();
}
+ ///
+ /// 规范化后的邮编比较:纯数字按等长补零;相同字母前缀+数字后缀按数值;其余按 Ordinal。
+ ///
+ public static int CompareNormalizedZip(string? left, string? right)
+ {
+ var a = left ?? string.Empty;
+ var b = right ?? string.Empty;
+ if (a.Length == 0 && b.Length == 0)
+ {
+ return 0;
+ }
+
+ if (IsDigits(a) && IsDigits(b))
+ {
+ var width = Math.Max(a.Length, b.Length);
+ return string.CompareOrdinal(a.PadLeft(width, '0'), b.PadLeft(width, '0'));
+ }
+
+ if (TrySplitLetterPrefixNumber(a, out var prefixA, out var numberA, out var restA)
+ && TrySplitLetterPrefixNumber(b, out var prefixB, out var numberB, out var restB)
+ && string.Equals(prefixA, prefixB, StringComparison.Ordinal))
+ {
+ var numberCmp = numberA.CompareTo(numberB);
+ return numberCmp != 0 ? numberCmp : string.CompareOrdinal(restA, restB);
+ }
+
+ return string.CompareOrdinal(a, b);
+ }
+
+ private static bool IsDigits(string value) =>
+ value.Length > 0 && value.All(char.IsAsciiDigit);
+
+ private static bool TrySplitLetterPrefixNumber(
+ string zip,
+ out string prefix,
+ out int number,
+ out string rest)
+ {
+ prefix = string.Empty;
+ number = 0;
+ rest = string.Empty;
+ if (zip.Length == 0 || !char.IsAsciiLetter(zip[0]))
+ {
+ return false;
+ }
+
+ var i = 0;
+ while (i < zip.Length && char.IsAsciiLetter(zip[i]))
+ {
+ i++;
+ }
+
+ if (i >= zip.Length || !char.IsAsciiDigit(zip[i]))
+ {
+ return false;
+ }
+
+ prefix = zip[..i];
+ var digitStart = i;
+ while (i < zip.Length && char.IsAsciiDigit(zip[i]))
+ {
+ i++;
+ }
+
+ if (!int.TryParse(zip[digitStart..i], NumberStyles.None, CultureInfo.InvariantCulture, out number))
+ {
+ return false;
+ }
+
+ rest = zip[i..];
+ return true;
+ }
+
public static string NormalizeRegion(string? value) =>
string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
@@ -116,8 +190,8 @@ public static class GeoPartitionMatcher
}
else if (rule.MatchMode == ModeRange)
{
- hit = zipStd.CompareTo(rule.ZipStartStd) >= 0
- && zipStd.CompareTo(rule.ZipEndStd) <= 0;
+ hit = CompareNormalizedZip(zipStd, rule.ZipStartStd) >= 0
+ && CompareNormalizedZip(zipStd, rule.ZipEndStd) <= 0;
}
if (hit)
@@ -565,7 +639,7 @@ public static class GeoPartitionMatcher
throw new InvalidOperationException("邮编区间必须填写截止邮编");
}
- if (zipLeft.CompareTo(zipRight) > 0)
+ if (CompareNormalizedZip(zipLeft, zipRight) > 0)
{
throw new InvalidOperationException("起始邮编不能大于截止邮编");
}
diff --git a/src/Oms.Application/Shared/WeightRoundingRangeGuard.cs b/src/Oms.Application/Shared/WeightRoundingRangeGuard.cs
new file mode 100644
index 0000000..f2c2c84
--- /dev/null
+++ b/src/Oms.Application/Shared/WeightRoundingRangeGuard.cs
@@ -0,0 +1,75 @@
+using Fake.Application;
+using Oms.Domain.ChannelsAggregate.Entities;
+
+namespace Oms.Application.Shared;
+
+///
+/// 计重进位区间校验:同一进位制下左开右闭 (start, end],端点相接不算重叠,排序后不允许空洞。
+///
+public static class WeightRoundingRangeGuard
+{
+ public static void EnsureValidRange(decimal startValue, decimal endValue)
+ {
+ if (startValue >= endValue)
+ {
+ throw new BusinessException("起始值须小于截止值");
+ }
+ }
+
+ ///
+ /// (a, b] 与 (c, d] 是否重叠。
+ ///
+ public static bool Overlaps(decimal startA, decimal endA, decimal startB, decimal endB) =>
+ startA < endB && startB < endA;
+
+ public static void EnsureNoOverlap(
+ decimal startValue,
+ decimal endValue,
+ IEnumerable existing,
+ int? excludeId = null)
+ {
+ EnsureValidRange(startValue, endValue);
+ foreach (var item in existing)
+ {
+ if (excludeId is > 0 && item.Id == excludeId.Value)
+ {
+ continue;
+ }
+
+ if (Overlaps(startValue, endValue, item.StartValue, item.EndValue))
+ {
+ throw new BusinessException(
+ $"起始区间 ({startValue}, {endValue}] 与已有区间 ({item.StartValue}, {item.EndValue}] 重叠");
+ }
+ }
+ }
+
+ public static void EnsureContiguous(
+ decimal startValue,
+ decimal endValue,
+ IEnumerable existing,
+ int? excludeId = null)
+ {
+ EnsureNoOverlap(startValue, endValue, existing, excludeId);
+ var merged = existing
+ .Where(item => excludeId is not > 0 || item.Id != excludeId.Value)
+ .Select(item => (Start: item.StartValue, End: item.EndValue))
+ .Append((Start: startValue, End: endValue))
+ .OrderBy(x => x.Start)
+ .ThenBy(x => x.End)
+ .ToList();
+
+ for (var i = 0; i < merged.Count - 1; i++)
+ {
+ var currentEnd = merged[i].End;
+ var nextStart = merged[i + 1].Start;
+ if (currentEnd == nextStart)
+ {
+ continue;
+ }
+
+ throw new BusinessException(
+ $"起始区间必须首尾相接(左开右闭):({merged[i].Start}, {currentEnd}] 与 ({nextStart}, {merged[i + 1].End}] 之间存在缺口");
+ }
+ }
+}
diff --git a/src/Oms.Tests/GeoPartitionMatcherTests.cs b/src/Oms.Tests/GeoPartitionMatcherTests.cs
index 98d8d3a..e2a1afc 100644
--- a/src/Oms.Tests/GeoPartitionMatcherTests.cs
+++ b/src/Oms.Tests/GeoPartitionMatcherTests.cs
@@ -59,6 +59,38 @@ public class GeoPartitionMatcherTests
GeoPartitionMatcher.IsHit(rules, "US", null, null, "90200").Should().BeFalse();
}
+ [Fact]
+ public void ZipRange_NumericDifferentLength_ShouldCompareAsNumbers()
+ {
+ var rules = CompileEntities(Range("US", "9", "10"));
+
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "9").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "10").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "8").Should().BeFalse();
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "11").Should().BeFalse();
+ }
+
+ [Fact]
+ public void ZipRange_LeadingZeros_ShouldKeepZipWidth()
+ {
+ var rules = CompileEntities(Range("US", "02100", "02200"));
+
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "02115").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "02100").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "02200").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "02099").Should().BeFalse();
+ }
+
+ [Fact]
+ public void ZipRange_AlphaNumericPrefix_ShouldCompareNumericTail()
+ {
+ var rules = CompileEntities(Range("GB", "YO0", "YO9"));
+
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "YO0").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "YO9").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "GB", null, null, "YO10").Should().BeFalse();
+ }
+
[Fact]
public void CountryFallback_ShouldHitWhenZipMisses()
{
diff --git a/src/Oms.Tests/ParcelBillingCalculatorTests.cs b/src/Oms.Tests/ParcelBillingCalculatorTests.cs
index 91b621d..65ef824 100644
--- a/src/Oms.Tests/ParcelBillingCalculatorTests.cs
+++ b/src/Oms.Tests/ParcelBillingCalculatorTests.cs
@@ -48,6 +48,29 @@ public class ParcelBillingCalculatorTests
rounded.Should().Be(1.5M);
}
+ [Fact]
+ public void ApplyRounding_LeftOpenRightClosed_BoundaryGoesToLeftRange()
+ {
+ var ranges = new[]
+ {
+ ParcelBillingTestData.RoundingRange(0, 10, "0.5"),
+ ParcelBillingTestData.RoundingRange(10, 30, "1"),
+ };
+
+ ParcelBillingCalculator.ApplyRounding(10M, ranges).Should().Be(10M);
+ ParcelBillingCalculator.ApplyRounding(10.01M, ranges).Should().Be(11M);
+ }
+
+ [Fact]
+ public void ApplyRounding_LeftOpenRightClosed_StartIsExclusive()
+ {
+ var ranges = new[] { ParcelBillingTestData.RoundingRange(0, 30, "0.5") };
+
+ var act = () => ParcelBillingCalculator.ApplyRounding(0M, ranges);
+
+ act.Should().Throw().WithMessage("*计重进位区间未匹配*");
+ }
+
#endregion
#region 应收(卖价)
diff --git a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
index 0dd3f7d..a22ed8f 100644
--- a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
+++ b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
@@ -308,23 +308,38 @@ public class PriceCheckSnapshotVerificationTests
public void CountryEstimate_WhenCountryConfiguredWithPostcodeRules_ShouldReturnDetailZones()
{
var snapshot = BuildUserSnapshot();
+ snapshot.Prices.Add(PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
+ {
+ Id = 9,
+ Status = 2,
+ CostItem = "freight",
+ Details = [Detail(90, 9, "total", "99", "0,2", 1)]
+ }));
snapshot.PriceAreaInfos =
[
new KpiAreaSnapshotRuleDto
{
- Id = 11,
+ Id = 3,
CountryCode = "US",
PostcodeRange = "(90000,99999]"
+ },
+ new KpiAreaSnapshotRuleDto
+ {
+ Id = 99,
+ CountryCode = "DE",
+ PostcodeRange = "(00000,99999]"
}
];
- // 精确匹配(无邮编)应失败
ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(snapshot, "US", null, null, null)
.Should().BeNull();
- // 国家估算应返回卖价明细分区键
ParcelBillingCalculator.CollectCountryEstimateZoneKeys(snapshot, "US")
- .Should().Contain(["3"]);
+ .Should().Contain("3")
+ .And.NotContain("99");
+ ParcelBillingCalculator.CollectCountryEstimateZoneKeys(snapshot, "DE")
+ .Should().Contain("99")
+ .And.NotContain("3");
}
[Fact]
diff --git a/src/Oms.Tests/WeightRoundingRangeGuardTests.cs b/src/Oms.Tests/WeightRoundingRangeGuardTests.cs
new file mode 100644
index 0000000..24f0236
--- /dev/null
+++ b/src/Oms.Tests/WeightRoundingRangeGuardTests.cs
@@ -0,0 +1,97 @@
+using Fake.Application;
+using FluentAssertions;
+using Oms.Application.Shared;
+using Oms.Domain.ChannelsAggregate.Entities;
+
+namespace Oms.Tests;
+
+public class WeightRoundingRangeGuardTests
+{
+ [Fact]
+ public void AdjacentLeftOpenRightClosed_ShouldNotOverlap()
+ {
+ var existing = new[] { Range(1, 0, 30) };
+
+ var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(30, 60, existing);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void ContainedRange_ShouldOverlap()
+ {
+ var existing = new[] { Range(1, 0, 30) };
+
+ var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(5, 10, existing);
+
+ act.Should().Throw().WithMessage("*重叠*");
+ }
+
+ [Fact]
+ public void CrossingRange_ShouldOverlap()
+ {
+ var existing = new[] { Range(1, 0, 30) };
+
+ var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(20, 40, existing);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void UpdateSameRow_ShouldIgnoreSelf()
+ {
+ var existing = new[] { Range(8, 0, 30), Range(9, 30, 60) };
+
+ var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(0, 30, existing, excludeId: 8);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void StartNotLessThanEnd_ShouldFail()
+ {
+ var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(30, 30, []);
+
+ act.Should().Throw().WithMessage("*起始值须小于截止值*");
+ }
+
+ [Fact]
+ public void HoleBetweenRanges_ShouldFail()
+ {
+ var existing = new[] { Range(1, 0, 10) };
+
+ var act = () => WeightRoundingRangeGuard.EnsureContiguous(20, 30, existing);
+
+ act.Should().Throw().WithMessage("*缺口*");
+ }
+
+ [Fact]
+ public void AdjacentRanges_ShouldBeContiguous()
+ {
+ var existing = new[] { Range(1, 0, 10) };
+
+ var act = () => WeightRoundingRangeGuard.EnsureContiguous(10, 25, existing);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void UpdateSameRow_Contiguous_ShouldIgnoreSelf()
+ {
+ var existing = new[] { Range(8, 0, 30), Range(9, 30, 60) };
+
+ var act = () => WeightRoundingRangeGuard.EnsureContiguous(0, 30, existing, excludeId: 8);
+
+ act.Should().NotThrow();
+ }
+
+ private static WeightRoundingInfo Range(int id, decimal start, decimal end) =>
+ new()
+ {
+ Id = id,
+ RoundingId = 1,
+ StartValue = start,
+ EndValue = end,
+ Public = "0.5"
+ };
+}
--
2.45.2
From f063c99d1b52286ff56b0336a979e32dd6d9b783 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Mon, 5 Oct 2026 18:48:21 +0800
Subject: [PATCH 22/41] =?UTF-8?q?feat:=20OpenAPI=20=E5=A2=9E=E5=8A=A0?=
=?UTF-8?q?=E8=BD=A8=E8=BF=B9/=E6=96=87=E4=BB=B6=E6=9F=A5=E8=AF=A2?=
=?UTF-8?q?=EF=BC=8C=E5=88=9B=E5=8D=95=E6=8C=89=20clientNo=20=E5=B9=82?=
=?UTF-8?q?=E7=AD=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
客户 HMAC 通过后再查本客户订单;禁止下游面单,附件增加 POD。
Co-authored-by: Cursor
---
db.sql | 2 +-
.../Dtos/OpenApi/OpenApiQueryDtos.cs | 92 +++++++++
.../ISmallBagTrackOrderClient.cs | 9 +
.../SmallBagTrack/SmallBagTrackOrderClient.cs | 174 ++++++++++++++++++
.../CustomerOpenApiCredentialService.cs | 5 +-
.../Services/OpenApi/OpenApiService.cs | 139 +++++++++++++-
.../Services/Orders/OrderFileManager.cs | 24 ++-
.../Services/Orders/OrderService.cs | 40 ++++
.../Shared/OpenApiCustomerOrderResolver.cs | 54 ++++++
.../Consts/OrderFileTypeCodes.cs | 17 +-
.../OrdersAggregate/Entities/OrderFile.cs | 2 +-
11 files changed, 550 insertions(+), 8 deletions(-)
create mode 100644 src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs
create mode 100644 src/Oms.Application/Shared/OpenApiCustomerOrderResolver.cs
diff --git a/db.sql b/db.sql
index 009c706..b2c744a 100644
--- a/db.sql
+++ b/db.sql
@@ -214,7 +214,7 @@ CREATE TABLE `t_order` (
CREATE TABLE `t_order_file` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
`OrderId` INT NOT NULL COMMENT '订单Id',
- `FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片',
+ `FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片/pod签收证明',
`FileName` VARCHAR(255) NULL COMMENT '原始文件名',
`FileUrl` VARCHAR(512) NOT NULL COMMENT '文件地址(相对路径或外链)',
`ContentType` VARCHAR(128) NULL COMMENT '文件MIME类型',
diff --git a/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs b/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs
new file mode 100644
index 0000000..a1299f9
--- /dev/null
+++ b/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs
@@ -0,0 +1,92 @@
+namespace Oms.Application.Dtos.OpenApi;
+
+///
+/// 按客户单号 / 服务单号 / 跟踪号定位订单(至少填一项)。
+///
+public class OpenApiOrderLookupInput
+{
+ public string? ClientNo { get; set; }
+
+ public string? ServiceNo { get; set; }
+
+ public string? TrackNo { get; set; }
+}
+
+public class OpenApiTrackQueryInput : OpenApiOrderLookupInput
+{
+}
+
+public class OpenApiFilesGetInput : OpenApiOrderLookupInput
+{
+ ///
+ /// 附件类型:tradeProof / systemLabel / lastMileLabel / infraredImage / pod。不含 downstreamLabel。
+ ///
+ public string? FileType { get; set; }
+}
+
+public class OpenApiTrackQueryOutput
+{
+ public int OrderId { get; set; }
+
+ public string? ClientNo { get; set; }
+
+ public string? ServiceNo { get; set; }
+
+ public string? TrackNo { get; set; }
+
+ public List Events { get; set; } = [];
+}
+
+public class OpenApiTrackEventDto
+{
+ public string? TrackCode { get; set; }
+
+ public string? Status { get; set; }
+
+ public string? Content { get; set; }
+
+ public string? EnContent { get; set; }
+
+ public string? Location { get; set; }
+
+ public DateTime? Time { get; set; }
+
+ public List Files { get; set; } = [];
+}
+
+public class OpenApiTrackFileDto
+{
+ public string? FileName { get; set; }
+
+ public string? Url { get; set; }
+}
+
+public class OpenApiFilesGetOutput
+{
+ public int OrderId { get; set; }
+
+ public string? ClientNo { get; set; }
+
+ public string? ServiceNo { get; set; }
+
+ public string FileType { get; set; } = string.Empty;
+
+ public List Files { get; set; } = [];
+}
+
+public class OpenApiFileItemDto
+{
+ public int Id { get; set; }
+
+ public string? FileName { get; set; }
+
+ public string FileUrl { get; set; } = string.Empty;
+
+ public string? ContentType { get; set; }
+
+ public long? FileSize { get; set; }
+
+ public string? Source { get; set; }
+
+ public DateTime CreateTime { get; set; }
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs
index c3e8190..15b8a0b 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs
@@ -1,3 +1,5 @@
+using Oms.Application.Dtos.OpenApi;
+
namespace Oms.Application.Integrations.SmallBagTrack;
public interface ISmallBagTrackOrderClient
@@ -45,4 +47,11 @@ public interface ISmallBagTrackOrderClient
int linkOrderType,
string? authorization = null,
CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询小包轨迹(POST ProviderService/Track/Query)。
+ ///
+ Task> QuerySmallBagTracksAsync(
+ string orderNo,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
index ecb80a1..1d1189f 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
@@ -1,8 +1,10 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
+using Fake.Application;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
+using Oms.Application.Dtos.OpenApi;
namespace Oms.Application.Integrations.SmallBagTrack;
@@ -424,6 +426,178 @@ internal sealed class SmallBagTrackOrderClient(
}
}
+ public async Task> QuerySmallBagTracksAsync(
+ string orderNo,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(orderNo))
+ {
+ throw new BusinessException("服务单号为空,无法查询轨迹");
+ }
+
+ var settings = await configProvider.GetAsync(cancellationToken);
+ if (!settings.Enabled)
+ {
+ throw new BusinessException("轨迹服务未启用");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.BaseUrl))
+ {
+ throw new BusinessException("轨迹服务未配置");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
+ {
+ throw new BusinessException("轨迹服务未配置");
+ }
+
+ var appKey = settings.AppKey.Trim();
+ var url = $"{settings.BaseUrl.Trim().TrimEnd('/')}/ProviderService/Track/Query";
+ var body = new
+ {
+ appKey,
+ orderType = SmallBagTrackLifecycleEvents.OrderType,
+ orderNos = new[] { orderNo.Trim() }
+ };
+
+ var response = await SendSignedPostAsync(
+ settings,
+ appKey,
+ url,
+ body,
+ authorization: null,
+ cancellationToken);
+
+ if (!response.Response.IsSuccessStatusCode)
+ {
+ throw new BusinessException("轨迹查询失败");
+ }
+
+ return ParseTrackEvents(response.ResponseBody);
+ }
+
+ private static List ParseTrackEvents(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return [];
+ }
+
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ var arr = root;
+ if (root.ValueKind == JsonValueKind.Object
+ && (root.TryGetProperty("data", out var data) || root.TryGetProperty("Data", out data)))
+ {
+ arr = data;
+ }
+
+ if (arr.ValueKind != JsonValueKind.Array)
+ {
+ return [];
+ }
+
+ var events = new List();
+ foreach (var orderEl in arr.EnumerateArray())
+ {
+ if (orderEl.ValueKind != JsonValueKind.Object)
+ {
+ continue;
+ }
+
+ if (!orderEl.TryGetProperty("trackDetails", out var details)
+ && !orderEl.TryGetProperty("TrackDetails", out details))
+ {
+ continue;
+ }
+
+ if (details.ValueKind != JsonValueKind.Array)
+ {
+ continue;
+ }
+
+ foreach (var el in details.EnumerateArray())
+ {
+ var content = ReadJsonString(el, "content", "Content");
+ if (string.IsNullOrWhiteSpace(content) || content == "--")
+ {
+ continue;
+ }
+
+ events.Add(new OpenApiTrackEventDto
+ {
+ TrackCode = ReadJsonString(el, "code", "Code"),
+ Status = ReadJsonString(el, "status", "Status"),
+ Content = content,
+ EnContent = ReadJsonString(el, "enContent", "EnContent"),
+ Location = ReadJsonString(el, "location", "Location"),
+ Time = ReadJsonTime(el),
+ Files = ReadJsonFiles(el)
+ });
+ }
+ }
+
+ return events;
+ }
+
+ private static List ReadJsonFiles(JsonElement el)
+ {
+ if (!el.TryGetProperty("files", out var files) && !el.TryGetProperty("Files", out files))
+ {
+ return [];
+ }
+
+ if (files.ValueKind != JsonValueKind.Array)
+ {
+ return [];
+ }
+
+ var list = new List();
+ foreach (var f in files.EnumerateArray())
+ {
+ list.Add(new OpenApiTrackFileDto
+ {
+ FileName = ReadJsonString(f, "fileName", "FileName"),
+ Url = ReadJsonString(f, "downloadUrl", "DownloadUrl", "url", "Url")
+ });
+ }
+
+ return list;
+ }
+
+ private static DateTime? ReadJsonTime(JsonElement el)
+ {
+ if (!el.TryGetProperty("time", out var time) && !el.TryGetProperty("Time", out time))
+ {
+ return null;
+ }
+
+ if (time.ValueKind == JsonValueKind.String && time.TryGetDateTime(out var dt))
+ {
+ return dt;
+ }
+
+ if (time.ValueKind == JsonValueKind.Number && time.TryGetInt64(out var unix))
+ {
+ return DateTimeOffset.FromUnixTimeSeconds(unix > 10_000_000_000 ? unix / 1000 : unix).UtcDateTime;
+ }
+
+ return null;
+ }
+
+ private static string? ReadJsonString(JsonElement el, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String)
+ {
+ return p.GetString();
+ }
+ }
+
+ return null;
+ }
+
private async Task SendSignedPostAsync(
SmallBagTrackOptions settings,
string appKey,
diff --git a/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs
index 1381ab6..787e720 100644
--- a/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs
+++ b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs
@@ -181,13 +181,14 @@ public class CustomerOpenApiCredentialService : ApplicationService
{
if (!OmsSessionHelper.IsCustomerLogin(CurrentUser))
{
- throw new BusinessException("仅客户门户可调用此接口");
+ throw new BusinessException(
+ "当前是管理后台登录,不能调用客户门户接口。请到「客户」列表打开对应客户的开放接口凭证;客户请从客户门户登录后操作");
}
var mapId = OmsSessionHelper.TryGetCustomerMapId(CurrentUser);
if (!mapId.HasValue || mapId.Value <= 0)
{
- throw new BusinessException("客户未登录");
+ throw new BusinessException("当前登录不是客户账号,无法读取开放接口凭证");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == mapId.Value);
diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
index 0076fbb..6d661e3 100644
--- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs
+++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
@@ -6,8 +6,10 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.OpenApi;
using Oms.Application.Dtos.Orders;
+using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Services.Orders;
using Oms.Application.Shared;
+using Oms.Domain.OrdersAggregate.Consts;
namespace Oms.Application.Services.OpenApi;
@@ -33,6 +35,15 @@ public class OpenApiService : ApplicationService
private OrderService OrderService =>
LazyServiceProvider.GetRequiredService();
+ private OrderFileManager OrderFileManager =>
+ LazyServiceProvider.GetRequiredService();
+
+ private OpenApiCustomerOrderResolver OrderResolver =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ISmallBagTrackOrderClient TrackClient =>
+ LazyServiceProvider.GetRequiredService();
+
private IHttpContextAccessor HttpContextAccessor =>
LazyServiceProvider.GetRequiredService();
@@ -84,7 +95,8 @@ public class OpenApiService : ApplicationService
throw new BusinessException("请求体不能为空");
}
- var data = await OrderService.CreateParcelAsync(input.ToOrderParcelCreateInput(customer.Id));
+ var data = await OrderService.CreateParcelForCustomerOpenApiAsync(
+ input.ToOrderParcelCreateInput(customer.Id));
return OpenApiEnvelope.Ok(data);
}
catch (BusinessException ex)
@@ -103,6 +115,131 @@ public class OpenApiService : ApplicationService
}
}
+ ///
+ /// 查询轨迹。Header 同创建包裹。按 clientNo / serviceNo / trackNo 定位本客户订单。
+ ///
+ [AllowAnonymous]
+ [HttpPost("/openapi/track/query")]
+ public async Task> QueryTrackAsync(
+ [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign)
+ {
+ try
+ {
+ var rawBody = await ReadRawBodyAsync();
+ var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody);
+ var input = DeserializeBody(rawBody);
+ var order = await OrderResolver.ResolveAsync(customer.Id, input);
+ if (string.IsNullOrWhiteSpace(order.ServiceNo))
+ {
+ throw new BusinessException("订单无服务单号,无法查询轨迹");
+ }
+
+ var events = await TrackClient.QuerySmallBagTracksAsync(order.ServiceNo);
+ return OpenApiEnvelope.Ok(new OpenApiTrackQueryOutput
+ {
+ OrderId = order.Id,
+ ClientNo = order.ClientNo,
+ ServiceNo = order.ServiceNo,
+ TrackNo = order.TrackNo,
+ Events = events
+ });
+ }
+ catch (BusinessException ex)
+ {
+ return OpenApiEnvelope.Fail(ex.Message);
+ }
+ catch (JsonException)
+ {
+ return OpenApiEnvelope.Fail("请求体格式错误");
+ }
+ catch (Exception ex)
+ {
+ return OpenApiEnvelope.Fail(
+ string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message,
+ "500");
+ }
+ }
+
+ ///
+ /// 按附件类型获取文件。Header 同创建包裹。
+ /// fileType:tradeProof / systemLabel / lastMileLabel / infraredImage / pod。不含 downstreamLabel。
+ ///
+ [AllowAnonymous]
+ [HttpPost("/openapi/files/get")]
+ public async Task> GetFilesAsync(
+ [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp,
+ [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign)
+ {
+ try
+ {
+ var rawBody = await ReadRawBodyAsync();
+ var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody);
+ var input = DeserializeBody(rawBody);
+ if (string.IsNullOrWhiteSpace(input.FileType))
+ {
+ throw new BusinessException("请传入 fileType");
+ }
+
+ if (!OrderFileTypeCodes.IsCustomerOpenApiAllowed(input.FileType))
+ {
+ throw new BusinessException("不支持的附件类型");
+ }
+
+ var order = await OrderResolver.ResolveAsync(customer.Id, input);
+ var files = await OrderFileManager.GetByTypeAsync(order.Id, input.FileType);
+ return OpenApiEnvelope.Ok(new OpenApiFilesGetOutput
+ {
+ OrderId = order.Id,
+ ClientNo = order.ClientNo,
+ ServiceNo = order.ServiceNo,
+ FileType = OrderFileTypeCodes.Normalize(input.FileType),
+ Files = files.Select(x => new OpenApiFileItemDto
+ {
+ Id = x.Id,
+ FileName = x.FileName,
+ FileUrl = x.FileUrl,
+ ContentType = x.ContentType,
+ FileSize = x.FileSize,
+ Source = x.Source,
+ CreateTime = x.CreateTime
+ }).ToList()
+ });
+ }
+ catch (BusinessException ex)
+ {
+ return OpenApiEnvelope.Fail(ex.Message);
+ }
+ catch (JsonException)
+ {
+ return OpenApiEnvelope.Fail("请求体格式错误");
+ }
+ catch (Exception ex)
+ {
+ return OpenApiEnvelope.Fail(
+ string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message,
+ "500");
+ }
+ }
+
+ private T DeserializeBody(string rawBody)
+ {
+ if (string.IsNullOrWhiteSpace(rawBody))
+ {
+ throw new BusinessException("请求体不能为空");
+ }
+
+ var input = JsonSerializer.Deserialize(rawBody, BodyJsonOptions);
+ if (input == null)
+ {
+ throw new BusinessException("请求体不能为空");
+ }
+
+ return input;
+ }
+
private async Task ReadRawBodyAsync()
{
var http = HttpContextAccessor.HttpContext
diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs
index 62e273e..3cf553b 100644
--- a/src/Oms.Application/Services/Orders/OrderFileManager.cs
+++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs
@@ -66,6 +66,25 @@ public sealed class OrderFileManager(
return result;
}
+ public async Task> GetByTypeAsync(
+ int orderId,
+ string fileType,
+ CancellationToken cancellationToken = default)
+ {
+ if (!OrderFileTypeCodes.IsValid(fileType))
+ {
+ throw new BusinessException("不支持的附件类型");
+ }
+
+ var type = OrderFileTypeCodes.Normalize(fileType);
+ var list = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
+ return list
+ .OrderBy(x => x.Sort)
+ .ThenBy(x => x.Id)
+ .Select(MapItem)
+ .ToList();
+ }
+
public async Task GetGroupedAsync(
int orderId,
bool customerView,
@@ -302,7 +321,8 @@ public sealed class OrderFileManager(
or OrderFileTypeCodes.DownstreamLabel
or OrderFileTypeCodes.LastMileLabel;
var isInfraredOrProof = fileType is OrderFileTypeCodes.InfraredImage
- or OrderFileTypeCodes.TradeProof;
+ or OrderFileTypeCodes.TradeProof
+ or OrderFileTypeCodes.Pod;
if (isLabelType)
{
@@ -325,7 +345,7 @@ public sealed class OrderFileManager(
{
if (!ImageExt.Contains(ext) && !string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase))
{
- throw new BusinessException("交易证明/红外图片仅支持图片或 pdf");
+ throw new BusinessException("交易证明/红外图片/POD 仅支持图片或 pdf");
}
if (file.Length > MaxPdfBytes)
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index f052699..4d7ab7f 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -531,6 +531,46 @@ public partial class OrderService : ApplicationService
return output;
}
+ ///
+ /// 客户 OpenAPI 创单:同一客户相同 clientNo 视为幂等,返回已存在订单,不新建。
+ ///
+ public async Task CreateParcelForCustomerOpenApiAsync(OrderParcelCreateInput input)
+ {
+ NormalizeParcelCreateInput(input);
+ if (!string.IsNullOrWhiteSpace(input.ClientNo) && input.CustomerId is > 0)
+ {
+ var existing = await OrderRepository.GetByCustomerAndClientNoAsync(
+ input.CustomerId.Value,
+ input.ClientNo);
+ if (existing != null)
+ {
+ return await BuildParcelCreateOutputAsync(existing);
+ }
+ }
+
+ try
+ {
+ return await CreateParcelAsync(input);
+ }
+ catch (BusinessException ex) when (ex.Message == "客户单号已存在")
+ {
+ if (string.IsNullOrWhiteSpace(input.ClientNo) || input.CustomerId is not > 0)
+ {
+ throw;
+ }
+
+ var raced = await OrderRepository.GetByCustomerAndClientNoAsync(
+ input.CustomerId.Value,
+ input.ClientNo);
+ if (raced == null)
+ {
+ throw;
+ }
+
+ return await BuildParcelCreateOutputAsync(raced);
+ }
+ }
+
private async Task TryAttachPickupAfterParcelCreateAsync(
OrderParcelCreateInput input,
OrderParcelCreateOutput output)
diff --git a/src/Oms.Application/Shared/OpenApiCustomerOrderResolver.cs b/src/Oms.Application/Shared/OpenApiCustomerOrderResolver.cs
new file mode 100644
index 0000000..565cbce
--- /dev/null
+++ b/src/Oms.Application/Shared/OpenApiCustomerOrderResolver.cs
@@ -0,0 +1,54 @@
+using Fake.Application;
+using Fake.DependencyInjection;
+using Oms.Application.Dtos.OpenApi;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+
+namespace Oms.Application.Shared;
+
+///
+/// OpenAPI 按客户凭证解析自有订单。
+///
+public class OpenApiCustomerOrderResolver : IScopedDependency
+{
+ private readonly IOrderRepository _orderRepository;
+
+ public OpenApiCustomerOrderResolver(IOrderRepository orderRepository)
+ {
+ _orderRepository = orderRepository;
+ }
+
+ public async Task ResolveAsync(int customerId, OpenApiOrderLookupInput input)
+ {
+ var clientNo = input.ClientNo?.Trim();
+ var serviceNo = input.ServiceNo?.Trim();
+ var trackNo = input.TrackNo?.Trim();
+ if (string.IsNullOrWhiteSpace(clientNo)
+ && string.IsNullOrWhiteSpace(serviceNo)
+ && string.IsNullOrWhiteSpace(trackNo))
+ {
+ throw new BusinessException("请传入 clientNo、serviceNo 或 trackNo");
+ }
+
+ Order? order = null;
+ if (!string.IsNullOrWhiteSpace(clientNo))
+ {
+ order = await _orderRepository.GetByCustomerAndClientNoAsync(customerId, clientNo);
+ }
+ else if (!string.IsNullOrWhiteSpace(serviceNo))
+ {
+ order = await _orderRepository.GetByServiceNoAsync(serviceNo);
+ }
+ else if (!string.IsNullOrWhiteSpace(trackNo))
+ {
+ order = await _orderRepository.GetByTrackNoAsync(trackNo);
+ }
+
+ if (order == null || order.CustomerId != customerId)
+ {
+ throw new BusinessException("订单不存在");
+ }
+
+ return order;
+ }
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs
index f82a107..986fd4c 100644
--- a/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs
+++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs
@@ -10,6 +10,7 @@ public static class OrderFileTypeCodes
public const string DownstreamLabel = "downstreamLabel";
public const string LastMileLabel = "lastMileLabel";
public const string InfraredImage = "infraredImage";
+ public const string Pod = "pod";
public const int MaxFilesPerType = 20;
@@ -19,7 +20,8 @@ public static class OrderFileTypeCodes
SystemLabel,
DownstreamLabel,
LastMileLabel,
- InfraredImage
+ InfraredImage,
+ Pod
};
///
@@ -31,6 +33,19 @@ public static class OrderFileTypeCodes
public static bool IsValid(string? fileType) =>
!string.IsNullOrWhiteSpace(fileType) && All.Contains(fileType.Trim());
+ ///
+ /// 客户 OpenAPI 可查询的附件类型。不含下游面单。
+ ///
+ public static bool IsCustomerOpenApiAllowed(string? fileType)
+ {
+ if (!IsValid(fileType))
+ {
+ return false;
+ }
+
+ return !string.Equals(fileType.Trim(), DownstreamLabel, StringComparison.OrdinalIgnoreCase);
+ }
+
public static string Normalize(string fileType) => fileType.Trim();
}
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs
index 3e6ecdd..a6105d8 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs
@@ -9,7 +9,7 @@ public class OrderFile : OmsAuditedAggregateRoot
public int OrderId { get; set; }
///
- /// 附件类型:tradeProof / systemLabel / downstreamLabel / lastMileLabel / infraredImage。
+ /// 附件类型:tradeProof / systemLabel / downstreamLabel / lastMileLabel / infraredImage / pod。
///
[SugarColumn(Length = 32)]
public string FileType { get; set; } = string.Empty;
--
2.45.2
From 5bf81b5138ede7fef5e5a376236fa06a1eb54daf Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Tue, 6 Oct 2026 10:39:28 +0800
Subject: [PATCH 23/41] =?UTF-8?q?fix:=20OpenAPI=20=E8=BD=A8=E8=BF=B9/?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=E8=A1=A5=20Swagger=20=E8=AF=B7=E6=B1=82?=
=?UTF-8?q?=E4=BD=93=20schema?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
FromBody 仅用于文档展示,验签仍读原始 Body。
Co-authored-by: Cursor
---
.../Dtos/OpenApi/OpenApiQueryDtos.cs | 5 +++++
.../Services/OpenApi/OpenApiService.cs | 15 ++++++++++++---
2 files changed, 17 insertions(+), 3 deletions(-)
diff --git a/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs b/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs
index a1299f9..90b6e26 100644
--- a/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs
+++ b/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs
@@ -5,17 +5,22 @@ namespace Oms.Application.Dtos.OpenApi;
///
public class OpenApiOrderLookupInput
{
+ /// 客户单号。与 serviceNo / trackNo 至少填一项;同时传时优先 clientNo。
public string? ClientNo { get; set; }
+ /// 服务单号(OMS 运单号)。
public string? ServiceNo { get; set; }
+ /// 跟踪号。
public string? TrackNo { get; set; }
}
+/// 查询轨迹入参。
public class OpenApiTrackQueryInput : OpenApiOrderLookupInput
{
}
+/// 按附件类型获取文件入参。
public class OpenApiFilesGetInput : OpenApiOrderLookupInput
{
///
diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
index 6d661e3..bba414f 100644
--- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs
+++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs
@@ -79,10 +79,13 @@ public class OpenApiService : ApplicationService
///
[AllowAnonymous]
[HttpPost("/openapi/parcel/create")]
+ [Consumes("application/json")]
+ [Produces("application/json")]
public async Task> CreateParcelAsync(
[FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
[FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp,
- [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign)
+ [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign,
+ [FromBody] OpenApiParcelCreateInput? requestBody)
{
try
{
@@ -120,10 +123,13 @@ public class OpenApiService : ApplicationService
///
[AllowAnonymous]
[HttpPost("/openapi/track/query")]
+ [Consumes("application/json")]
+ [Produces("application/json")]
public async Task> QueryTrackAsync(
[FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
[FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp,
- [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign)
+ [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign,
+ [FromBody] OpenApiTrackQueryInput? requestBody)
{
try
{
@@ -168,10 +174,13 @@ public class OpenApiService : ApplicationService
///
[AllowAnonymous]
[HttpPost("/openapi/files/get")]
+ [Consumes("application/json")]
+ [Produces("application/json")]
public async Task> GetFilesAsync(
[FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
[FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp,
- [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign)
+ [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign,
+ [FromBody] OpenApiFilesGetInput? requestBody)
{
try
{
--
2.45.2
From 04dc9ca1ffc37540c8816e18a9ef18a86070da80 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Tue, 6 Oct 2026 10:58:23 +0800
Subject: [PATCH 24/41] =?UTF-8?q?feat:=20=E5=88=86=E5=8C=BA=E6=98=8E?=
=?UTF-8?q?=E7=BB=86=E5=AF=BC=E5=85=A5=E6=A8=A1=E6=9D=BF=E5=8E=BB=E6=8E=89?=
=?UTF-8?q?=E5=9B=BD=E5=AE=B6=E5=90=8D=E7=A7=B0=EF=BC=8C=E5=B9=B6=E5=A2=9E?=
=?UTF-8?q?=E5=8A=A0=E6=9E=9A=E4=B8=BE=E5=80=BC=20Sheet?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
导入模板只保留国家代码;Sheet2 列出匹配维度/方式的编码与名称,导入时按代码补国家名称。
Co-authored-by: Cursor
---
.../Shared/KpiAreaInfoImportHelper.cs | 58 +++++++++++++++++--
src/Oms.Tests/KpiAreaInfoImportHelperTests.cs | 58 +++++++++++++++++++
2 files changed, 110 insertions(+), 6 deletions(-)
diff --git a/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs
index 2ebfabf..d61f58f 100644
--- a/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs
+++ b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs
@@ -11,6 +11,9 @@ namespace Oms.Application.Shared;
///
public static class KpiAreaInfoImportHelper
{
+ public const string TemplateSheetName = "导入模板";
+ public const string EnumSheetName = "枚举值";
+
private static readonly Regex ChinesePattern = new(@"[\u4E00-\u9FA5]", RegexOptions.Compiled);
private static readonly Dictionary HeaderAliases = new(StringComparer.OrdinalIgnoreCase)
@@ -48,12 +51,11 @@ public static class KpiAreaInfoImportHelper
public static async Task BuildTemplateBytesAsync()
{
- var rows = new List>
+ var templateRows = new List>
{
new()
{
["国家代码"] = "US",
- ["国家名称"] = "美国",
["匹配维度"] = "zip",
["匹配方式"] = "range",
["州/省"] = "",
@@ -64,7 +66,6 @@ public static class KpiAreaInfoImportHelper
new()
{
["国家代码"] = "US",
- ["国家名称"] = "美国",
["匹配维度"] = "zip",
["匹配方式"] = "prefix",
["州/省"] = "",
@@ -75,7 +76,6 @@ public static class KpiAreaInfoImportHelper
new()
{
["国家代码"] = "US",
- ["国家名称"] = "美国",
["匹配维度"] = "state",
["匹配方式"] = "exact",
["州/省"] = "CA",
@@ -85,11 +85,44 @@ public static class KpiAreaInfoImportHelper
}
};
+ var enumRows = new List>
+ {
+ new() { ["枚举类型"] = "匹配维度", ["编码"] = "country", ["名称"] = "仅国家" },
+ new() { ["枚举类型"] = "匹配维度", ["编码"] = "zip", ["名称"] = "邮编" },
+ new() { ["枚举类型"] = "匹配维度", ["编码"] = "state", ["名称"] = "州/省" },
+ new() { ["枚举类型"] = "匹配维度", ["编码"] = "city", ["名称"] = "城市" },
+ new() { ["枚举类型"] = "匹配方式", ["编码"] = "prefix", ["名称"] = "前缀" },
+ new() { ["枚举类型"] = "匹配方式", ["编码"] = "exact", ["名称"] = "精确" },
+ new() { ["枚举类型"] = "匹配方式", ["编码"] = "range", ["名称"] = "区间" },
+ new() { ["枚举类型"] = "匹配方式", ["编码"] = "all", ["名称"] = "不限" }
+ };
+
+ var sheets = new Dictionary
+ {
+ [TemplateSheetName] = templateRows,
+ [EnumSheetName] = enumRows
+ };
+
await using var stream = new MemoryStream();
- await MiniExcel.SaveAsAsync(stream, rows);
+ await MiniExcel.SaveAsAsync(stream, sheets);
return stream.ToArray();
}
+ internal static string? ResolveImportSheetName(IEnumerable sheetNames)
+ {
+ var names = sheetNames.Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
+ if (names.Count == 0)
+ {
+ return null;
+ }
+
+ return names.FirstOrDefault(x =>
+ string.Equals(x, TemplateSheetName, StringComparison.OrdinalIgnoreCase))
+ ?? names.FirstOrDefault(x =>
+ !string.Equals(x, EnumSheetName, StringComparison.OrdinalIgnoreCase))
+ ?? names[0];
+ }
+
public static string ResolveTemplateFileName(string? areaType) =>
areaType?.Trim().ToLowerInvariant() switch
{
@@ -101,7 +134,20 @@ public static class KpiAreaInfoImportHelper
public static async Task> ParseImportRowsAsync(Stream fileStream)
{
- var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true);
+ 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, KpiAreaInfo Entity)>();
var rowNumber = 1;
diff --git a/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs
index 2f62e90..03cdf8c 100644
--- a/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs
+++ b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs
@@ -1,4 +1,5 @@
using FluentAssertions;
+using MiniExcelLibs;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.SystemAggregate.Entities;
@@ -7,6 +8,63 @@ namespace Oms.Tests;
public class KpiAreaInfoImportHelperTests
{
+ [Fact]
+ public async Task BuildTemplateBytesAsync_ShouldUseNamedSheetsWithoutCountryName()
+ {
+ var bytes = await KpiAreaInfoImportHelper.BuildTemplateBytesAsync();
+ await using var stream = new MemoryStream(bytes);
+
+ var sheetNames = MiniExcel.GetSheetNames(stream);
+ sheetNames.Should().Equal(
+ KpiAreaInfoImportHelper.TemplateSheetName,
+ KpiAreaInfoImportHelper.EnumSheetName);
+
+ stream.Position = 0;
+ var templateRows = (await MiniExcel.QueryAsync(
+ stream,
+ useHeaderRow: true,
+ sheetName: KpiAreaInfoImportHelper.TemplateSheetName))
+ .Cast>()
+ .ToList();
+ templateRows.Should().NotBeEmpty();
+ templateRows[0].Keys.Should().NotContain(key => key.Contains("国家名称"));
+ templateRows[0].Should().ContainKey("国家代码");
+ templateRows[0].Should().ContainKey("匹配维度");
+ templateRows[0].Should().ContainKey("匹配方式");
+
+ stream.Position = 0;
+ var enumRows = (await MiniExcel.QueryAsync(
+ stream,
+ useHeaderRow: true,
+ sheetName: KpiAreaInfoImportHelper.EnumSheetName))
+ .Cast>()
+ .ToList();
+ enumRows.Should().HaveCount(8);
+ enumRows.Should().Contain(row =>
+ Convert.ToString(row["枚举类型"]) == "匹配维度"
+ && Convert.ToString(row["编码"]) == "zip"
+ && Convert.ToString(row["名称"]) == "邮编");
+ enumRows.Should().Contain(row =>
+ Convert.ToString(row["枚举类型"]) == "匹配方式"
+ && Convert.ToString(row["编码"]) == "range"
+ && Convert.ToString(row["名称"]) == "区间");
+ }
+
+ [Fact]
+ public async Task ParseImportRowsAsync_ShouldReadTemplateSheetOnly()
+ {
+ var bytes = await KpiAreaInfoImportHelper.BuildTemplateBytesAsync();
+ await using var stream = new MemoryStream(bytes);
+
+ var rows = await KpiAreaInfoImportHelper.ParseImportRowsAsync(stream);
+
+ rows.Should().HaveCount(3);
+ rows[0].Entity.CountryCode.Should().Be("US");
+ rows[0].Entity.CountryName.Should().BeNull();
+ rows[0].Entity.MatchDim.Should().Be("zip");
+ rows[0].Entity.MatchMode.Should().Be("range");
+ }
+
[Fact]
public void PrepareEntityForImport_ValidRow_ShouldFillCountryName()
{
--
2.45.2
From 14c42db7523391ba67f2355dfe39c663a149f54f Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Tue, 6 Oct 2026 12:21:06 +0800
Subject: [PATCH 25/41] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20/file-storag?=
=?UTF-8?q?e=20=E6=8E=A5=E5=8F=A3=EF=BC=8C=E4=BE=9B=E9=99=84=E4=BB=B6?=
=?UTF-8?q?=E9=A2=84=E8=A7=88=E6=8C=89=E5=AF=B9=E8=B1=A1=20key=20=E6=B5=81?=
=?UTF-8?q?=E5=BC=8F=E8=AF=BB=E5=8F=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
补 normalize-key 与 stream,兼容带或不带 admin 前缀的 COS 路径。
Co-authored-by: Cursor
---
.../Dtos/Storage/FileStorageDtos.cs | 49 +++
src/Oms.Application/OmsApplicationModule.cs | 6 +
.../Services/Storage/FileStorageService.cs | 301 ++++++++++++++++++
.../Storage/FileStoragePath.cs | 58 ++++
src/Oms.Tests/FileStoragePathTests.cs | 19 ++
5 files changed, 433 insertions(+)
create mode 100644 src/Oms.Application/Dtos/Storage/FileStorageDtos.cs
create mode 100644 src/Oms.Application/Services/Storage/FileStorageService.cs
create mode 100644 src/Oms.Tests/FileStoragePathTests.cs
diff --git a/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs b/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs
new file mode 100644
index 0000000..2e38732
--- /dev/null
+++ b/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs
@@ -0,0 +1,49 @@
+namespace Oms.Application.Dtos.Storage;
+
+public class FileStorageNormalizeKeyInput
+{
+ public string? PathOrUrl { get; set; }
+}
+
+public class FileStorageKeyOutput
+{
+ public string Key { get; set; } = string.Empty;
+}
+
+public class FileStorageCapabilitiesOutput
+{
+ public bool SupportsClientDirectUpload { get; set; }
+
+ public string? Provider { get; set; }
+}
+
+public class FileStorageAccessUrlOutput
+{
+ public string Key { get; set; } = string.Empty;
+
+ public string Url { get; set; } = string.Empty;
+}
+
+public class FileStoragePresignInput
+{
+ public string? Folder { get; set; }
+
+ public string? FileName { get; set; }
+
+ public string? ContentType { get; set; }
+}
+
+public class FileStoragePresignOutput
+{
+ public string Key { get; set; } = string.Empty;
+
+ public string UploadUrl { get; set; } = string.Empty;
+
+ public string Method { get; set; } = "PUT";
+
+ public string? ContentType { get; set; }
+
+ public Dictionary? Headers { get; set; }
+
+ public string? ExpireAt { get; set; }
+}
diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs
index acd9b8a..c24e699 100644
--- a/src/Oms.Application/OmsApplicationModule.cs
+++ b/src/Oms.Application/OmsApplicationModule.cs
@@ -12,6 +12,7 @@ using Oms.Application.Integrations.Itfx;
using Oms.Application.Integrations.PlatformShop;
using Oms.Application.Labeling;
using Oms.Application.Labeling.Hiprint;
+using Oms.Application.Services.Storage;
using Oms.Application.Shared;
using Oms.Application.Workers;
using Oms.Domain;
@@ -52,6 +53,11 @@ public class OmsApplicationModule : FakeModule
context.Services.AddTransient();
context.Services.AddTransient();
+ context.Services.AddHttpClient(FileStorageService.UrlFetchClientName, client =>
+ {
+ client.Timeout = TimeSpan.FromSeconds(60);
+ });
+
context.Services.AddOmsSmallBagTrack(configuration);
context.Services.AddOmsItfxIntegration(configuration);
context.Services.AddOmsPlatformShopIntegration(configuration);
diff --git a/src/Oms.Application/Services/Storage/FileStorageService.cs b/src/Oms.Application/Services/Storage/FileStorageService.cs
new file mode 100644
index 0000000..d043bc2
--- /dev/null
+++ b/src/Oms.Application/Services/Storage/FileStorageService.cs
@@ -0,0 +1,301 @@
+using System.Text.RegularExpressions;
+using Fake.Application;
+using Fake.ObjectStorage;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Storage;
+using Oms.Application.Storage;
+
+namespace Oms.Application.Services.Storage;
+
+///
+/// 对象存储辅助 API(预览 stream / 规范化 key / 直传预签名)。
+///
+[ApiExplorerSettings(GroupName = "OMS")]
+public class FileStorageService : ApplicationService
+{
+ public const string UrlFetchClientName = "oms-storage-url-fetch";
+
+ private static readonly HashSet AllowedFolders = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "avatars",
+ "sku",
+ "parcel",
+ "recharge",
+ "order-files",
+ "order-templates",
+ "xhs",
+ "prints",
+ "exports",
+ "misc"
+ };
+
+ private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService();
+
+ private IHttpClientFactory HttpClientFactory =>
+ LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 是否支持浏览器直传 COS。
+ ///
+ [HttpGet("/file-storage/capabilities")]
+ public FileStorageCapabilitiesOutput GetCapabilities()
+ {
+ return new FileStorageCapabilitiesOutput
+ {
+ SupportsClientDirectUpload = !FileStorage.IsLocalProvider(),
+ Provider = FileStorage.Name
+ };
+ }
+
+ ///
+ /// 将 COS URL / 相对路径规范为 object key。
+ ///
+ [HttpPost("/file-storage/normalize-key")]
+ public FileStorageKeyOutput NormalizeKey([FromBody] FileStorageNormalizeKeyInput input)
+ {
+ if (string.IsNullOrWhiteSpace(input?.PathOrUrl))
+ {
+ throw new BusinessException("路径不能为空");
+ }
+
+ return new FileStorageKeyOutput { Key = FileStoragePath.NormalizeKey(input.PathOrUrl) };
+ }
+
+ ///
+ /// 按 key 换临时访问地址。
+ ///
+ [HttpGet("/file-storage/access-url")]
+ public async Task GetAccessUrlAsync(
+ [FromQuery] string key,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ throw new BusinessException("key 不能为空");
+ }
+
+ var trimmed = key.Trim();
+ var url = await FileStorage.GetPublicUrlAsync(trimmed, cancellationToken);
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ throw new BusinessException("未返回访问地址");
+ }
+
+ return new FileStorageAccessUrlOutput
+ {
+ Key = FileStoragePath.NormalizeKey(trimmed),
+ Url = url
+ };
+ }
+
+ ///
+ /// 签发预签名 PUT,供前端直传。
+ ///
+ [HttpPost("/file-storage/presign-upload")]
+ public async Task PresignUploadAsync(
+ [FromBody] FileStoragePresignInput input,
+ CancellationToken cancellationToken)
+ {
+ var folder = (input.Folder ?? "misc").Trim().Trim('/');
+ if (string.IsNullOrWhiteSpace(folder) || folder.Contains("..") || folder.Contains('/') || folder.Contains('\\'))
+ {
+ throw new BusinessException("folder 无效");
+ }
+
+ if (!AllowedFolders.Contains(folder))
+ {
+ throw new BusinessException("不支持的上传目录");
+ }
+
+ var fileName = string.IsNullOrWhiteSpace(input.FileName) ? "file.bin" : Path.GetFileName(input.FileName.Trim());
+ var ext = Path.GetExtension(fileName);
+ if (ext.Length > 16)
+ {
+ ext = "";
+ }
+
+ var objectKey = $"uploads/{folder}/{DateTime.Now:yyyyMM}/{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
+ var result = await FileStorage.CreatePresignedUploadAsync(
+ new ObjectStoragePresignUploadArgs
+ {
+ ObjectKey = objectKey,
+ ContentType = string.IsNullOrWhiteSpace(input.ContentType) ? null : input.ContentType.Trim()
+ },
+ cancellationToken);
+
+ var uploadUrl = result.UploadUrl;
+ if (string.IsNullOrWhiteSpace(uploadUrl))
+ {
+ throw new BusinessException("预签名上传凭证不完整");
+ }
+
+ Dictionary? headers = null;
+ if (result.Headers is { Count: > 0 })
+ {
+ headers = result.Headers.ToDictionary(x => x.Key, x => x.Value ?? "", StringComparer.OrdinalIgnoreCase);
+ }
+
+ return new FileStoragePresignOutput
+ {
+ Key = string.IsNullOrWhiteSpace(result.ObjectKey) ? objectKey : result.ObjectKey,
+ UploadUrl = uploadUrl,
+ Method = string.IsNullOrWhiteSpace(result.Method) ? "PUT" : result.Method,
+ ContentType = result.ContentType ?? input.ContentType,
+ Headers = headers,
+ ExpireAt = FormatExpireAt(result.ExpireAt)
+ };
+ }
+
+ ///
+ /// 鉴权读取对象字节(面单/附件预览)。key 可为 object key 或 COS 签名 URL。
+ ///
+ [HttpGet("/file-storage/stream")]
+ public async Task StreamAsync(
+ [FromQuery] string key,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ throw new BusinessException("key 不能为空");
+ }
+
+ var raw = key.Trim();
+ foreach (var candidate in FileStoragePath.ExpandReadKeys(raw))
+ {
+ var bytes = await TryReadObjectBytesAsync(candidate, cancellationToken);
+ if (bytes is { Length: > 0 })
+ {
+ return FileResult(bytes, candidate);
+ }
+ }
+
+ if (FileStoragePath.IsAbsoluteUrl(raw))
+ {
+ var fetched = await TryFetchUrlBytesAsync(raw, cancellationToken);
+ if (fetched is { Length: > 0 })
+ {
+ return FileResult(fetched, raw);
+ }
+ }
+
+ throw new BusinessException("文件不存在或无法读取");
+ }
+
+ private async Task TryReadObjectBytesAsync(string objectKey, CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (!await FileStorage.ExistsAsync(objectKey, cancellationToken))
+ {
+ return null;
+ }
+
+ await using var stream = await FileStorage.GetAsync(objectKey, cancellationToken);
+ using var ms = new MemoryStream();
+ await stream.CopyToAsync(ms, cancellationToken);
+ return ms.ToArray();
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return null;
+ }
+ catch (DirectoryNotFoundException)
+ {
+ return null;
+ }
+ catch (FileNotFoundException)
+ {
+ return null;
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private async Task TryFetchUrlBytesAsync(string url, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var client = HttpClientFactory.CreateClient(UrlFetchClientName);
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.TryAddWithoutValidation("Accept", "*/*");
+ using var response = await client.SendAsync(
+ request,
+ HttpCompletionOption.ResponseHeadersRead,
+ cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ return await response.Content.ReadAsByteArrayAsync(cancellationToken);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private static FileContentResult FileResult(byte[] bytes, string nameHint)
+ {
+ var mime = GuessMime(nameHint);
+ var path = nameHint.Contains('?', StringComparison.Ordinal)
+ ? nameHint[..nameHint.IndexOf('?', StringComparison.Ordinal)]
+ : nameHint;
+ var fileName = Path.GetFileName(path);
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ fileName = "file";
+ }
+
+ return new FileContentResult(bytes, mime)
+ {
+ FileDownloadName = fileName
+ };
+ }
+
+ private static string GuessMime(string pathOrUrl)
+ {
+ if (Regex.IsMatch(pathOrUrl, @"\.pdf(\?|#|$)", RegexOptions.IgnoreCase))
+ {
+ return "application/pdf";
+ }
+
+ if (Regex.IsMatch(pathOrUrl, @"\.png(\?|#|$)", RegexOptions.IgnoreCase))
+ {
+ return "image/png";
+ }
+
+ if (Regex.IsMatch(pathOrUrl, @"\.jpe?g(\?|#|$)", RegexOptions.IgnoreCase))
+ {
+ return "image/jpeg";
+ }
+
+ if (Regex.IsMatch(pathOrUrl, @"\.webp(\?|#|$)", RegexOptions.IgnoreCase))
+ {
+ return "image/webp";
+ }
+
+ if (Regex.IsMatch(pathOrUrl, @"\.gif(\?|#|$)", RegexOptions.IgnoreCase))
+ {
+ return "image/gif";
+ }
+
+ return "application/octet-stream";
+ }
+
+ private static string? FormatExpireAt(object? expireAt)
+ {
+ return expireAt switch
+ {
+ DateTimeOffset dto when dto != default => dto.ToString("o"),
+ DateTime dt when dt != default => dt.ToString("o"),
+ _ => expireAt?.ToString()
+ };
+ }
+}
diff --git a/src/Oms.Application/Storage/FileStoragePath.cs b/src/Oms.Application/Storage/FileStoragePath.cs
index d0322a8..45cae8f 100644
--- a/src/Oms.Application/Storage/FileStoragePath.cs
+++ b/src/Oms.Application/Storage/FileStoragePath.cs
@@ -38,4 +38,62 @@ public static class FileStoragePath
!string.IsNullOrWhiteSpace(value)
&& (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| value.StartsWith("https://", StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// 读取对象时尝试的 key:含/不含 admin/ 前缀(自定义域路径常带 PathBase)。
+ ///
+ public static IReadOnlyList ExpandReadKeys(string? pathOrUrl)
+ {
+ var keys = new List();
+ void Push(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return;
+ }
+
+ var key = raw.Replace('\\', '/').Trim().TrimStart('/');
+ if (key.Length == 0)
+ {
+ return;
+ }
+
+ if (keys.Exists(x => string.Equals(x, key, StringComparison.OrdinalIgnoreCase)))
+ {
+ return;
+ }
+
+ keys.Add(key);
+ }
+
+ if (IsAbsoluteUrl(pathOrUrl))
+ {
+ try
+ {
+ Push(NormalizeKey(pathOrUrl));
+ }
+ catch (ArgumentException)
+ {
+ // ignore
+ }
+ }
+ else
+ {
+ Push(pathOrUrl);
+ }
+
+ foreach (var existing in keys.ToArray())
+ {
+ if (existing.StartsWith("admin/", StringComparison.OrdinalIgnoreCase))
+ {
+ Push(existing["admin/".Length..]);
+ }
+ else
+ {
+ Push("admin/" + existing);
+ }
+ }
+
+ return keys;
+ }
}
diff --git a/src/Oms.Tests/FileStoragePathTests.cs b/src/Oms.Tests/FileStoragePathTests.cs
new file mode 100644
index 0000000..07b0843
--- /dev/null
+++ b/src/Oms.Tests/FileStoragePathTests.cs
@@ -0,0 +1,19 @@
+using FluentAssertions;
+using Oms.Application.Storage;
+
+namespace Oms.Tests;
+
+public class FileStoragePathTests
+{
+ [Fact]
+ public void ExpandReadKeys_ShouldStripQueryAndTryAdminPrefix()
+ {
+ var url =
+ "https://cos.test.ideship.com/admin/uploads/prints/orders/IDE2610061059117673-20261006105912.pdf?q-sign-algorithm=sha1";
+
+ var keys = FileStoragePath.ExpandReadKeys(url);
+
+ keys.Should().Contain("admin/uploads/prints/orders/IDE2610061059117673-20261006105912.pdf");
+ keys.Should().Contain("uploads/prints/orders/IDE2610061059117673-20261006105912.pdf");
+ }
+}
--
2.45.2
From 8d7df40bdf140976f214c3759a9123308e73c670 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Tue, 6 Oct 2026 15:37:03 +0800
Subject: [PATCH 26/41] =?UTF-8?q?feat:=20=E9=94=80=E5=94=AE=E4=BA=A7?=
=?UTF-8?q?=E5=93=81=E7=8B=AC=E7=AB=8B=E6=94=B9=E4=BB=A3=E7=A0=81=EF=BC=8C?=
=?UTF-8?q?=E5=88=86=E5=8C=BA=E6=98=8E=E7=BB=86=E5=AF=BC=E5=85=A5=E4=BF=9D?=
=?UTF-8?q?=E5=AD=98=E6=8C=89=E8=A7=84=E5=88=99=E5=8E=BB=E9=87=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
.../Dtos/Channels/KpiAreaInfoImportOutput.cs | 2 +
.../Channels/SalesProductUpdateCodeInput.cs | 21 +++++++
.../Services/Channels/KpiAreaService.cs | 56 +++++++++++++++++++
.../Channels/SalesProductService.Helpers.cs | 30 +++++++++-
.../Services/Channels/SalesProductService.cs | 32 ++++++++---
.../Shared/GeoPartitionMatcher.cs | 27 +++++++++
src/Oms.Tests/GeoPartitionMatcherTests.cs | 49 ++++++++++++++++
7 files changed, 208 insertions(+), 9 deletions(-)
create mode 100644 src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs
index 5927568..db2cd35 100644
--- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs
@@ -7,6 +7,8 @@ public class KpiAreaInfoImportOutput
{
public int SuccessCount { get; set; }
+ public int SkipCount { get; set; }
+
public int FailCount { get; set; }
public List Failures { get; set; } = [];
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs
new file mode 100644
index 0000000..ec05ce5
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs
@@ -0,0 +1,21 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 单独修改销售产品代码入参。
+///
+public class SalesProductUpdateCodeInput
+{
+ ///
+ /// 销售产品主键。
+ ///
+ [Required]
+ public int Id { get; set; }
+
+ ///
+ /// 新的产品代码,仅大写字母、数字、-。
+ ///
+ [Required]
+ public string ProductCode { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs
index 7c76aa1..4a2d60f 100644
--- a/src/Oms.Application/Services/Channels/KpiAreaService.cs
+++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs
@@ -136,6 +136,7 @@ public class KpiAreaService : ApplicationService
PrefixLen = input.PrefixLen ?? 0
};
PrepareAreaInfo(entity);
+ await EnsureInfoNotDuplicateAsync(entity, excludeId: null);
await KpiAreaRepository.InsertInfoAsync(entity);
}
@@ -160,6 +161,7 @@ public class KpiAreaService : ApplicationService
entity.MatchMode = input.MatchMode;
entity.PrefixLen = input.PrefixLen ?? 0;
PrepareAreaInfo(entity);
+ await EnsureInfoNotDuplicateAsync(entity, excludeId: entity.Id);
await KpiAreaRepository.UpdateInfoAsync(entity);
}
@@ -214,6 +216,12 @@ public class KpiAreaService : ApplicationService
.GroupBy(x => x.CountryCode.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
+ var existingKeys = new HashSet(StringComparer.Ordinal);
+ foreach (var item in await KpiAreaRepository.GetAllInfosByAreaIdAsync(input.AreaId))
+ {
+ TryAddDuplicateKey(existingKeys, item);
+ }
+
var output = new KpiAreaInfoImportOutput();
foreach (var (rowNumber, entity) in parsedRows)
{
@@ -221,6 +229,17 @@ public class KpiAreaService : ApplicationService
{
KpiAreaInfoImportHelper.PrepareEntityForImport(entity, countriesByCode);
entity.AreaId = input.AreaId;
+ if (!TryAddDuplicateKey(existingKeys, entity))
+ {
+ output.SkipCount++;
+ output.Failures.Add(new KpiAreaInfoImportFailRow
+ {
+ RowNumber = rowNumber,
+ Reason = "与已有规则重复,已跳过"
+ });
+ continue;
+ }
+
await KpiAreaRepository.InsertInfoAsync(entity);
output.SuccessCount++;
}
@@ -314,6 +333,43 @@ public class KpiAreaService : ApplicationService
}
}
+ private async Task EnsureInfoNotDuplicateAsync(KpiAreaInfo entity, int? excludeId)
+ {
+ var key = GeoPartitionMatcher.BuildDuplicateKey(entity);
+ var existing = await KpiAreaRepository.GetAllInfosByAreaIdAsync(entity.AreaId);
+ foreach (var item in existing)
+ {
+ if (excludeId.HasValue && item.Id == excludeId.Value)
+ {
+ continue;
+ }
+
+ if (TryBuildDuplicateKey(item, out var existingKey)
+ && string.Equals(existingKey, key, StringComparison.Ordinal))
+ {
+ throw new BusinessException("分区明细已存在相同规则");
+ }
+ }
+ }
+
+ private static bool TryAddDuplicateKey(HashSet keys, KpiAreaInfo entity) =>
+ TryBuildDuplicateKey(entity, out var key) && keys.Add(key);
+
+ private static bool TryBuildDuplicateKey(KpiAreaInfo entity, out string key)
+ {
+ try
+ {
+ GeoPartitionMatcher.PrepareEntity(entity);
+ key = GeoPartitionMatcher.BuildDuplicateKey(entity);
+ return true;
+ }
+ catch (InvalidOperationException)
+ {
+ key = string.Empty;
+ return false;
+ }
+ }
+
private static KpiAreaInfoOutput MapInfoOutput(KpiAreaInfo entity)
{
var (matchDim, matchMode, prefixLen) = GeoPartitionMatcher.Describe(entity);
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
index 5c3203d..4deba9e 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
@@ -11,6 +11,29 @@ namespace Oms.Application.Services.Channels;
public partial class SalesProductService
{
+ ///
+ /// 规范化并校验产品代码格式。
+ ///
+ private static string NormalizeProductCode(string? productCode)
+ {
+ var code = (productCode ?? string.Empty).Trim().ToUpperInvariant();
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ throw new Fake.Application.BusinessException("产品代码不能为空");
+ }
+
+ for (var i = 0; i < code.Length; i++)
+ {
+ var ch = code[i];
+ if (ch is < 'A' or > 'Z' && ch is < '0' or > '9' && ch != '-')
+ {
+ throw new Fake.Application.BusinessException("产品代码仅支持大写字母、数字和-");
+ }
+ }
+
+ return code;
+ }
+
///
/// 校验产品编码唯一性。
///
@@ -344,9 +367,12 @@ public partial class SalesProductService
///
/// 将保存入参映射到销售产品实体。
///
- private static void ApplyProductInput(SalesProduct entity, SalesProductSaveInput input)
+ private static void ApplyProductInput(SalesProduct entity, SalesProductSaveInput input, bool updateProductCode = true)
{
- entity.ProductCode = input.ProductCode;
+ if (updateProductCode)
+ {
+ entity.ProductCode = NormalizeProductCode(input.ProductCode);
+ }
entity.ProductName = input.ProductName;
entity.ProductGroup = input.ProductGroup;
entity.MinTimeliness = input.MinTimeliness;
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs
index 9ebf67f..5f75ce2 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.cs
@@ -74,8 +74,8 @@ public partial class SalesProductService : ApplicationService
[HttpPost("/channel/sales-product/create")]
public async Task CreateAsync(SalesProductSaveInput input)
{
- await ValidateProductCodeAsync(input.ProductCode, null);
- var code = input.ProductCode.Trim();
+ var code = NormalizeProductCode(input.ProductCode);
+ await ValidateProductCodeAsync(code, null);
var deleted = await SalesProductRepository.GetByProductCodeIncludingDeletedAsync(code);
if (deleted != null && deleted.IsDeleted)
{
@@ -99,9 +99,28 @@ public partial class SalesProductService : ApplicationService
public async Task UpdateAsync(SalesProductSaveInput input)
{
var entity = await GetProductOrThrowAsync(input.Id);
- await ValidateProductCodeAsync(input.ProductCode, entity.Id);
- await FreeSoftDeletedProductCodeIfNeededAsync(input.ProductCode.Trim(), entity.Id);
- ApplyProductInput(entity, input);
+ ApplyProductInput(entity, input, updateProductCode: false);
+ await SalesProductRepository.UpdateAsync(entity);
+ }
+
+ ///
+ /// 单独修改销售产品代码。
+ ///
+ /// 入参:产品主键与新代码。
+ /// 无返回体,仅完成产品代码更新。
+ [HttpPost("/channel/sales-product/product-code/update")]
+ public async Task UpdateProductCodeAsync(SalesProductUpdateCodeInput input)
+ {
+ var entity = await GetProductOrThrowAsync(input.Id);
+ var code = NormalizeProductCode(input.ProductCode);
+ if (string.Equals(entity.ProductCode, code, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ await ValidateProductCodeAsync(code, entity.Id);
+ await FreeSoftDeletedProductCodeIfNeededAsync(code, entity.Id);
+ entity.ProductCode = code;
await SalesProductRepository.UpdateAsync(entity);
}
@@ -127,8 +146,7 @@ public partial class SalesProductService : ApplicationService
public async Task SaveConfigAsync(SalesProductSaveInput input)
{
var entity = await GetProductOrThrowAsync(input.Id);
- await ValidateProductCodeAsync(input.ProductCode, entity.Id);
- ApplyProductInput(entity, input);
+ ApplyProductInput(entity, input, updateProductCode: false);
await SalesProductRepository.UpdateAsync(entity);
}
diff --git a/src/Oms.Application/Shared/GeoPartitionMatcher.cs b/src/Oms.Application/Shared/GeoPartitionMatcher.cs
index 4eab0fd..ace4b34 100644
--- a/src/Oms.Application/Shared/GeoPartitionMatcher.cs
+++ b/src/Oms.Application/Shared/GeoPartitionMatcher.cs
@@ -409,6 +409,33 @@ public static class GeoPartitionMatcher
}
}
+ ///
+ /// 同一分区内判定完全相同规则的指纹(不比较 AreaId)。
+ /// 须在 PrepareEntity 之后调用。
+ ///
+ public static string BuildDuplicateKey(KpiAreaInfo entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ var dim = NormalizeDim(entity.MatchDim) ?? DimCountry;
+ var mode = NormalizeMode(entity.MatchMode) ?? ModeAll;
+ var country = NormKey(entity.CountryCode);
+
+ return dim switch
+ {
+ DimState => $"{DimState}|{country}|{NormKey(entity.Province)}",
+ DimCity => $"{DimCity}|{country}|{NormKey(entity.Province)}|{NormKey(entity.City)}",
+ DimZip when mode == ModePrefix =>
+ $"{DimZip}|{ModePrefix}|{country}|{NormKey(entity.LeftPostcode)}|{entity.PrefixLen}",
+ DimZip when mode == ModeExact =>
+ $"{DimZip}|{ModeExact}|{country}|{NormKey(entity.LeftPostcode)}",
+ DimZip =>
+ $"{DimZip}|{ModeRange}|{country}|{NormKey(entity.LeftPostcode)}|{NormKey(entity.RightPostcode)}",
+ _ => $"{DimCountry}|{country}"
+ };
+ }
+
+ private static string NormKey(string? value) => (value ?? string.Empty).Trim().ToUpperInvariant();
+
public static (string MatchDim, string MatchMode, int PrefixLen) Describe(KpiAreaInfo? entity)
{
if (entity == null)
diff --git a/src/Oms.Tests/GeoPartitionMatcherTests.cs b/src/Oms.Tests/GeoPartitionMatcherTests.cs
index e2a1afc..5d10a15 100644
--- a/src/Oms.Tests/GeoPartitionMatcherTests.cs
+++ b/src/Oms.Tests/GeoPartitionMatcherTests.cs
@@ -254,6 +254,55 @@ public class GeoPartitionMatcherTests
GeoPartitionMatcher.MatchEntity(rules, "GB", "NIR", "Belfast", null)!.Province.Should().Be("NIR");
}
+ [Fact]
+ public void BuildDuplicateKey_SameCategory_ShouldMatchIgnoringCase()
+ {
+ var a = CountryOnly("us");
+ var b = CountryOnly("US");
+ GeoPartitionMatcher.PrepareEntity(a);
+ GeoPartitionMatcher.PrepareEntity(b);
+ GeoPartitionMatcher.BuildDuplicateKey(a).Should().Be(GeoPartitionMatcher.BuildDuplicateKey(b));
+ }
+
+ [Fact]
+ public void BuildDuplicateKey_DifferentDims_ShouldNotCollide()
+ {
+ var country = CountryOnly("US");
+ var state = State("US", "CA");
+ var zip = Zip("US", "90");
+ GeoPartitionMatcher.PrepareEntity(country);
+ GeoPartitionMatcher.PrepareEntity(state);
+ GeoPartitionMatcher.PrepareEntity(zip);
+
+ var keys = new[]
+ {
+ GeoPartitionMatcher.BuildDuplicateKey(country),
+ GeoPartitionMatcher.BuildDuplicateKey(state),
+ GeoPartitionMatcher.BuildDuplicateKey(zip)
+ };
+ keys.Should().OnlyHaveUniqueItems();
+ }
+
+ [Fact]
+ public void BuildDuplicateKey_SameZipRange_ShouldMatch()
+ {
+ var a = Range("US", "90000", "90199");
+ var b = Range("us", "90000", "90199");
+ GeoPartitionMatcher.PrepareEntity(a);
+ GeoPartitionMatcher.PrepareEntity(b);
+ GeoPartitionMatcher.BuildDuplicateKey(a).Should().Be(GeoPartitionMatcher.BuildDuplicateKey(b));
+ }
+
+ [Fact]
+ public void BuildDuplicateKey_PrefixVsExact_ShouldNotCollide()
+ {
+ var prefix = Zip("US", "90");
+ var exact = Exact("US", "90");
+ GeoPartitionMatcher.PrepareEntity(prefix);
+ GeoPartitionMatcher.PrepareEntity(exact);
+ GeoPartitionMatcher.BuildDuplicateKey(prefix).Should().NotBe(GeoPartitionMatcher.BuildDuplicateKey(exact));
+ }
+
private static List CompileEntities(params KpiAreaInfo[] items)
{
foreach (var item in items)
--
2.45.2
From f972a28d79e2326527169e456b7c8f63a4dba437 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Tue, 6 Oct 2026 17:40:24 +0800
Subject: [PATCH 27/41] =?UTF-8?q?feat:=20=E8=90=BD=E5=9C=B0=E6=BB=9A?=
=?UTF-8?q?=E5=8A=A8=E4=BC=9A=E5=91=98=E3=80=81=E5=88=B8=E5=8F=91=E6=94=BE?=
=?UTF-8?q?=E4=B8=8E=E9=82=80=E8=AF=B7=E5=88=86=E6=88=90=E5=A5=96=E5=8A=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
sql/README.md | 2 +-
sql/migrate_allocation_match_condition.sql | 21 --
sql/migrate_coupon_grant_system.sql | 109 ++++++++
sql/migrate_customer_export_qualification.sql | 98 -------
sql/migrate_customer_openapi_credential.sql | 41 ---
sql/migrate_declare_element.sql | 38 ---
sql/migrate_issue_template_handler.sql | 45 ---
sql/migrate_member_level_rolling_points.sql | 216 +++++++++++++++
sql/migrate_order_export_qualification_id.sql | 24 --
...rate_pickup_direction_and_address_type.sql | 16 --
sql/migrate_pickup_fee_push.sql | 11 -
sql/migrate_referral_invite_reward.sql | 97 +++++++
.../Dtos/Marketing/CommissionDtos.cs | 40 +++
.../Dtos/Marketing/CouponDtos.cs | 8 +
.../Dtos/Marketing/MarketingPriceBreakdown.cs | 4 +
.../Dtos/Marketing/MemberLevelOutput.cs | 3 +
.../Dtos/Marketing/MemberLevelSaveInput.cs | 2 +
.../Dtos/Marketing/MemberOverviewOutput.cs | 50 ++--
.../Dtos/Marketing/ReferralDtos.cs | 260 +++++++++---------
.../Dtos/Orders/OrderRefundInput.cs | 14 +
.../Services/Customers/CustomerService.cs | 8 +-
.../MarketingCommissionAdminService.cs | 2 +
.../MarketingCommissionRuleService.cs | 47 +++-
.../Marketing/MarketingCouponGrantService.cs | 6 +-
.../MarketingCouponTemplateService.cs | 6 +
.../Marketing/MarketingMemberLevelService.cs | 6 +-
.../MarketingPortalCommissionService.cs | 2 +
.../Marketing/MarketingPortalCouponService.cs | 3 +-
.../Marketing/MarketingPortalMemberService.cs | 6 +-
.../MarketingPortalReferralService.cs | 63 ++++-
.../Orders/OrderService.ParcelBilling.cs | 21 +-
.../Orders/OrderService.ParcelQuote.cs | 12 +-
.../Services/Orders/OrderService.cs | 64 ++++-
.../Orders/ParcelBillingCalculator.cs | 7 +-
.../Shared/CustomerBalanceMutator.cs | 52 ++++
.../Shared/MarketingBillingCalculator.cs | 124 +++++++--
.../Shared/MarketingCouponManager.cs | 239 +++++++++++++++-
.../Shared/MarketingCouponScope.cs | 52 ++++
.../Shared/MarketingCustomerHelper.cs | 4 +-
.../Shared/MarketingEnumNames.cs | 4 +
.../Shared/MarketingFeeHelper.cs | 33 +++
.../Shared/MarketingInviteRewardCalculator.cs | 141 ++++++++++
.../Shared/MarketingOrderHelper.cs | 48 +++-
.../Shared/MarketingPointsManager.cs | 228 ++++++++++-----
.../Shared/MarketingReferralBinder.cs | 28 ++
.../Shared/ReferralCommissionManager.cs | 196 +++++++++----
.../Workers/MarketingBackgroundWorker.cs | 22 +-
.../Consts/MarketingConsts.cs | 19 ++
.../Entities/CouponInstance.cs | 2 +
.../Entities/MemberLevel.cs | 2 +
.../Entities/ReferralCommissionDetail.cs | 4 +
.../Entities/ReferralCommissionRule.cs | 18 ++
.../Enums/MarketingEnums.cs | 4 +-
.../Repositories/IMarketingRepositories.cs | 21 ++
.../OrdersAggregate/Entities/Order.cs | 2 +
.../Marketing/MarketingRepositories.cs | 98 ++++++-
.../MarketingInviteRewardCalculatorTests.cs | 76 +++++
src/Oms.Tests/MarketingMemberPointsTests.cs | 200 ++++++++++++++
.../WeChat/WeChatCustomerService.cs | 6 +
59 files changed, 2323 insertions(+), 652 deletions(-)
delete mode 100644 sql/migrate_allocation_match_condition.sql
create mode 100644 sql/migrate_coupon_grant_system.sql
delete mode 100644 sql/migrate_customer_export_qualification.sql
delete mode 100644 sql/migrate_customer_openapi_credential.sql
delete mode 100644 sql/migrate_declare_element.sql
delete mode 100644 sql/migrate_issue_template_handler.sql
create mode 100644 sql/migrate_member_level_rolling_points.sql
delete mode 100644 sql/migrate_order_export_qualification_id.sql
delete mode 100644 sql/migrate_pickup_direction_and_address_type.sql
delete mode 100644 sql/migrate_pickup_fee_push.sql
create mode 100644 sql/migrate_referral_invite_reward.sql
create mode 100644 src/Oms.Application/Dtos/Orders/OrderRefundInput.cs
create mode 100644 src/Oms.Application/Shared/MarketingCouponScope.cs
create mode 100644 src/Oms.Application/Shared/MarketingFeeHelper.cs
create mode 100644 src/Oms.Application/Shared/MarketingInviteRewardCalculator.cs
create mode 100644 src/Oms.Tests/MarketingInviteRewardCalculatorTests.cs
create mode 100644 src/Oms.Tests/MarketingMemberPointsTests.cs
diff --git a/sql/README.md b/sql/README.md
index ddb4def..9d84b35 100644
--- a/sql/README.md
+++ b/sql/README.md
@@ -3,5 +3,5 @@
OMS / 共用库的建表脚本已统一到仓库根目录 `db.sql`。
- 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行)
-- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录下单个表脚本(如 `c_parcel_arrival.sql`、`c_parcel_item_AttributeText.sql`、`c_parcel_item_ImageUrl.sql`)可直接在已有库执行增量建表;若仍为旧名 `C_*`,先执行 `migrate_rename_warehouse_C_to_c.sql`
+- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过)
- 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落
diff --git a/sql/migrate_allocation_match_condition.sql b/sql/migrate_allocation_match_condition.sql
deleted file mode 100644
index 274f2e0..0000000
--- a/sql/migrate_allocation_match_condition.sql
+++ /dev/null
@@ -1,21 +0,0 @@
--- 存量库:排货规则服务商表增加判断条件 MatchCondition
--- 可重复执行;仅在列不存在时 ADD。
--- 目标表:t_sales_product_allocation_provider
-
-SET @db := DATABASE();
-
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_sales_product_allocation_provider'
- AND COLUMN_NAME = 'MatchCondition'
-);
-SET @sql := IF(
- @exists = 0,
- 'ALTER TABLE `t_sales_product_allocation_provider` ADD COLUMN `MatchCondition` TEXT NULL COMMENT ''排货判断条件(中文公式),空表示全部订单可用'' AFTER `CustomerIds`',
- 'SELECT ''MatchCondition already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_coupon_grant_system.sql b/sql/migrate_coupon_grant_system.sql
new file mode 100644
index 0000000..f5b5419
--- /dev/null
+++ b/sql/migrate_coupon_grant_system.sql
@@ -0,0 +1,109 @@
+-- 优惠券发放体系:未解锁状态、到期提醒标记、新人/裂变券模板(可重复执行)
+SET @db := DATABASE();
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_coupon_instance'
+ AND COLUMN_NAME = 'ExpiryNoticeSent'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_coupon_instance`
+ ADD COLUMN `ExpiryNoticeSent` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''到期前3天已提醒'' AFTER `LockExpireTime`',
+ 'SELECT ''ExpiryNoticeSent already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 新人首单券 15 元,领取后 15 天
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
+)
+SELECT
+ 'NEWCOMER_FIRST', '新人首单券', 'fixed_off', 15.00, NULL, NULL,
+ 0, 0, 0, 1,
+ 2, NULL, NULL, 15,
+ NULL, 'newcomer', 1, '注册自动下发,仅抵基础运费,退款不退回',
+ 0, 0, NOW(), 0, NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_FIRST'
+);
+
+-- 新人第二单券 15 元,满 150 基础运费,解锁后 30 天
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
+)
+SELECT
+ 'NEWCOMER_SECOND', '新人第二单券', 'fixed_off', 15.00, NULL, NULL,
+ 150, 0, 0, 1,
+ 3, NULL, NULL, 30,
+ NULL, 'newcomer', 1, '首单派送成功后解锁,实付基础运费满 150',
+ 0, 0, NOW(), 0, NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_SECOND'
+);
+
+-- 新人备货券 20 元,满 200,注册后 60 天
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
+)
+SELECT
+ 'NEWCOMER_STOCK', '新人备货券', 'fixed_off', 20.00, NULL, NULL,
+ 200, 0, 0, 1,
+ 2, NULL, NULL, 60,
+ NULL, 'newcomer', 1, '注册自动下发,实付基础运费满 200',
+ 0, 0, NOW(), 0, NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_STOCK'
+);
+
+-- 裂变:老客 20 元无门槛
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
+)
+SELECT
+ 'REFERRAL_INVITER', '邀请有礼券', 'fixed_off', 20.00, NULL, NULL,
+ 0, 0, 0, 1,
+ 2, NULL, NULL, 30,
+ NULL, 'referral', 1, '被邀请人首单派送成功后发给邀请人',
+ 0, 0, NOW(), 0, NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'REFERRAL_INVITER'
+);
+
+-- 裂变:新客额外 5 元
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
+)
+SELECT
+ 'REFERRAL_INVITEE', '邀请注册加赠券', 'fixed_off', 5.00, NULL, NULL,
+ 0, 0, 0, 1,
+ 2, NULL, NULL, 30,
+ NULL, 'referral', 1, '被邀请人注册即发 5 元券',
+ 0, 0, NOW(), 0, NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'REFERRAL_INVITEE'
+);
diff --git a/sql/migrate_customer_export_qualification.sql b/sql/migrate_customer_export_qualification.sql
deleted file mode 100644
index efd88fe..0000000
--- a/sql/migrate_customer_export_qualification.sql
+++ /dev/null
@@ -1,98 +0,0 @@
--- 存量库:客户出口资质备案表(含 IsDefault / Remark)
--- 可重复执行。
--- 目标表:t_customer_export_qualification
-
-SET @db := DATABASE();
-
--- 1) 表不存在则整表创建
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.TABLES
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_customer_export_qualification'
-);
-
-SET @sql := IF(
- @exists = 0,
- 'CREATE TABLE `t_customer_export_qualification` (
- `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
- `CustomerId` INT NOT NULL COMMENT ''归属客户 t_customer.Id'',
- `CompanyName` VARCHAR(200) NOT NULL COMMENT ''企业名称'',
- `SocialCreditCode` VARCHAR(64) NOT NULL COMMENT ''企业社会信用代码'',
- `DomesticConsignor` VARCHAR(200) NOT NULL COMMENT ''境内发货人'',
- `DomesticConsignorCustomsCode` VARCHAR(64) NULL COMMENT ''境内发货人海关代码'',
- `ProducerName` VARCHAR(200) NOT NULL COMMENT ''生产销售单位'',
- `ProducerCustomsCode` VARCHAR(64) NULL COMMENT ''生产销售单位海关代码'',
- `CustomsPort` VARCHAR(100) NULL COMMENT ''备案海关港口地'',
- `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'',
- `Remark` VARCHAR(512) NULL COMMENT ''备注'',
- `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
- `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
- `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
- `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
- `UpdateUserId` INT NULL COMMENT ''更新人'',
- `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
- PRIMARY KEY (`Id`),
- KEY `idx_ceq_customer` (`CustomerId`, `IsDeleted`, `Id`),
- KEY `idx_ceq_credit` (`SocialCreditCode`, `IsDeleted`),
- KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)
-) COMMENT=''客户出口资质备案''',
- 'SELECT ''t_customer_export_qualification already exists'' AS info'
-);
-
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- 2) 已建表但缺 IsDefault 时补列
-SET @col := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_customer_export_qualification'
- AND COLUMN_NAME = 'IsDefault'
-);
-SET @sql := IF(
- @col = 0,
- 'ALTER TABLE `t_customer_export_qualification`
- ADD COLUMN `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'' AFTER `CustomsPort`',
- 'SELECT ''IsDefault already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- 3) 已建表但缺 Remark 时补列
-SET @col := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_customer_export_qualification'
- AND COLUMN_NAME = 'Remark'
-);
-SET @sql := IF(
- @col = 0,
- 'ALTER TABLE `t_customer_export_qualification`
- ADD COLUMN `Remark` VARCHAR(512) NULL COMMENT ''备注'' AFTER `IsDefault`',
- 'SELECT ''Remark already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- 4) 默认索引
-SET @idx := (
- SELECT COUNT(1)
- FROM information_schema.STATISTICS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_customer_export_qualification'
- AND INDEX_NAME = 'idx_ceq_customer_default'
-);
-SET @sql := IF(
- @idx = 0,
- 'ALTER TABLE `t_customer_export_qualification` ADD KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)',
- 'SELECT ''idx_ceq_customer_default already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_customer_openapi_credential.sql b/sql/migrate_customer_openapi_credential.sql
deleted file mode 100644
index da9e138..0000000
--- a/sql/migrate_customer_openapi_credential.sql
+++ /dev/null
@@ -1,41 +0,0 @@
--- 客户 OpenAPI 机机凭证(一客户一对 AppKey/AppSecret,独立于 itfx)
--- 可重复执行。
--- 目标表:t_customer_openapi_credential
-
-SET @db := DATABASE();
-
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.TABLES
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_customer_openapi_credential'
-);
-
-SET @sql := IF(
- @exists = 0,
- 'CREATE TABLE `t_customer_openapi_credential` (
- `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
- `CustomerId` INT NOT NULL COMMENT ''归属主客户 t_customer.Id'',
- `AppKey` VARCHAR(64) NOT NULL COMMENT ''开放接口 AppKey'',
- `AppSecret` VARCHAR(128) NOT NULL COMMENT ''开放接口 AppSecret(明文,创建/重置时返回)'',
- `Enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:0-否,1-是'',
- `LastUsedAt` DATETIME NULL COMMENT ''最近鉴权成功时间'',
- `LastUsedIp` VARCHAR(64) NULL COMMENT ''最近鉴权成功 IP'',
- `Remark` VARCHAR(512) NULL COMMENT ''备注'',
- `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
- `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
- `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
- `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
- `UpdateUserId` INT NULL COMMENT ''更新人'',
- `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
- PRIMARY KEY (`Id`),
- UNIQUE KEY `uk_coc_customer` (`CustomerId`),
- UNIQUE KEY `uk_coc_app_key` (`AppKey`),
- KEY `idx_coc_enabled` (`Enabled`, `IsDeleted`)
-) COMMENT=''客户 OpenAPI 机机凭证''',
- 'SELECT ''t_customer_openapi_credential already exists'' AS info'
-);
-
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_declare_element.sql b/sql/migrate_declare_element.sql
deleted file mode 100644
index 62f59fd..0000000
--- a/sql/migrate_declare_element.sql
+++ /dev/null
@@ -1,38 +0,0 @@
--- 存量库:SKU / 订单申报增加「出口申报要素」DeclareElement
--- 可重复执行;仅在列不存在时 ADD。
-
-SET @db := DATABASE();
-
--- c_sku.DeclareElement
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 'c_sku'
- AND COLUMN_NAME = 'DeclareElement'
-);
-SET @sql := IF(
- @exists = 0,
- 'ALTER TABLE `c_sku` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ImHsCode`',
- 'SELECT ''c_sku.DeclareElement already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- t_order_declaration.DeclareElement
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_order_declaration'
- AND COLUMN_NAME = 'DeclareElement'
-);
-SET @sql := IF(
- @exists = 0,
- 'ALTER TABLE `t_order_declaration` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ExHsCode`',
- 'SELECT ''t_order_declaration.DeclareElement already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_issue_template_handler.sql b/sql/migrate_issue_template_handler.sql
deleted file mode 100644
index 409cdb7..0000000
--- a/sql/migrate_issue_template_handler.sql
+++ /dev/null
@@ -1,45 +0,0 @@
--- 存量库:为问题模板补充处理人字段(与 db.sql / IssueTemplate 实体对齐)
--- 可重复执行;仅在列不存在时 ADD。
--- 目标表:t_issue_template
-
-SET @db := DATABASE();
-
--- HandlerType
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_issue_template'
- AND COLUMN_NAME = 'HandlerType'
-);
-SET @sql := IF(
- @exists = 0,
- 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerType` VARCHAR(16) NULL COMMENT ''处理人类型:customer=订单所属客户,user=指定用户'' AFTER `NotifyMethod`',
- 'SELECT ''HandlerType already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- HandlerUserId
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_issue_template'
- AND COLUMN_NAME = 'HandlerUserId'
-);
-SET @sql := IF(
- @exists = 0,
- 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerUserId` INT NULL COMMENT ''指定处理人用户Id(HandlerType=user 时必填)'' AFTER `HandlerType`',
- 'SELECT ''HandlerUserId already exists'' AS info'
-);
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
--- 历史数据默认按「指定用户」语义;无处理人时可在页面再编辑
-UPDATE `t_issue_template`
-SET `HandlerType` = 'user'
-WHERE (`HandlerType` IS NULL OR `HandlerType` = '')
- AND `IsDeleted` = 0;
diff --git a/sql/migrate_member_level_rolling_points.sql b/sql/migrate_member_level_rolling_points.sql
new file mode 100644
index 0000000..ee8c2cf
--- /dev/null
+++ b/sql/migrate_member_level_rolling_points.sql
@@ -0,0 +1,216 @@
+-- 旧会员体系 → V0~V5(可重复执行)
+-- old: normal/silver/gold/diamond
+-- new: V0 普通 / V1 铜卡(新增) / V2 银卡 / V3 金卡 / V4 钻石 / V5 皇冠(新增)
+SET @db := DATABASE();
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_member_level'
+ AND COLUMN_NAME = 'CanDowngrade'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_member_level`
+ ADD COLUMN `CanDowngrade` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''V4/V5 允许逐级降级'' AFTER `ExtraDiscountRate`',
+ 'SELECT ''CanDowngrade already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ---------- 把旧行升级成新编码(仅当目标 V* 还不存在)----------
+UPDATE `t_member_level`
+SET
+ `LevelCode` = 'V0',
+ `LevelName` = '普通会员',
+ `MinPoints` = 0,
+ `ExtraDiscountRate` = 0.00,
+ `CanDowngrade` = 0,
+ `Sort` = 0,
+ `BenefitDesc` = '新注册 / 小单试单卖家',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0
+ AND LOWER(`LevelCode`) = 'normal'
+ AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V0') t);
+
+UPDATE `t_member_level`
+SET
+ `LevelCode` = 'V2',
+ `LevelName` = '银卡会员',
+ `MinPoints` = 60000,
+ `ExtraDiscountRate` = 0.02,
+ `CanDowngrade` = 0,
+ `Sort` = 2,
+ `BenefitDesc` = '主力中小卖家,基础运费 98 折',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0
+ AND LOWER(`LevelCode`) = 'silver'
+ AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V2') t);
+
+UPDATE `t_member_level`
+SET
+ `LevelCode` = 'V3',
+ `LevelName` = '金卡会员',
+ `MinPoints` = 120000,
+ `ExtraDiscountRate` = 0.03,
+ `CanDowngrade` = 0,
+ `Sort` = 3,
+ `BenefitDesc` = '优质中型卖家,基础运费 97 折;此级及以下只升不降',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0
+ AND LOWER(`LevelCode`) = 'gold'
+ AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V3') t);
+
+UPDATE `t_member_level`
+SET
+ `LevelCode` = 'V4',
+ `LevelName` = '钻石会员',
+ `MinPoints` = 240000,
+ `ExtraDiscountRate` = 0.04,
+ `CanDowngrade` = 1,
+ `Sort` = 4,
+ `BenefitDesc` = '中大卖家,基础运费 96 折;滚动积分不足可降到金卡',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0
+ AND LOWER(`LevelCode`) = 'diamond'
+ AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V4') t);
+
+-- ---------- 缺档则插入(铜卡、皇冠,以及未被旧行带过来的档)----------
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V0', '普通会员', 0, 0.00, 0, 0, '新注册 / 小单试单卖家', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V0' AND `IsDeleted` = 0);
+
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V1', '铜卡会员', 24000, 0.01, 0, 1, '起步稳定发货卖家,基础运费 99 折', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V1' AND `IsDeleted` = 0);
+
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V2', '银卡会员', 60000, 0.02, 0, 2, '主力中小卖家,基础运费 98 折', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V2' AND `IsDeleted` = 0);
+
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V3', '金卡会员', 120000, 0.03, 0, 3, '优质中型卖家,基础运费 97 折;此级及以下只升不降', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V3' AND `IsDeleted` = 0);
+
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V4', '钻石会员', 240000, 0.04, 1, 4, '中大卖家,基础运费 96 折;滚动积分不足可降到金卡', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V4' AND `IsDeleted` = 0);
+
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
+SELECT 'V5', '皇冠会员', 600000, 0.05, 1, 5, '顶级头部卖家,基础运费 95 折封顶;滚动积分不足可降到钻石', 2, 0, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V5' AND `IsDeleted` = 0);
+
+-- ---------- 旧编码与新档并存时:客户挂到新档,旧行停用 ----------
+UPDATE `t_customer` c
+INNER JOIN `t_member_level` old ON old.`Id` = c.`MemberLevelId` AND old.`IsDeleted` = 0
+INNER JOIN `t_member_level` canon ON canon.`IsDeleted` = 0 AND canon.`LevelCode` = CASE LOWER(old.`LevelCode`)
+ WHEN 'normal' THEN 'V0'
+ WHEN 'silver' THEN 'V2'
+ WHEN 'gold' THEN 'V3'
+ WHEN 'diamond' THEN 'V4'
+ ELSE NULL
+END
+SET c.`MemberLevelId` = canon.`Id`, c.`UpdateTime` = NOW()
+WHERE LOWER(old.`LevelCode`) IN ('normal', 'silver', 'gold', 'diamond')
+ AND old.`Id` <> canon.`Id`;
+
+UPDATE `t_member_level` old
+INNER JOIN `t_member_level` canon ON canon.`IsDeleted` = 0 AND canon.`LevelCode` = CASE LOWER(old.`LevelCode`)
+ WHEN 'normal' THEN 'V0'
+ WHEN 'silver' THEN 'V2'
+ WHEN 'gold' THEN 'V3'
+ WHEN 'diamond' THEN 'V4'
+ ELSE NULL
+END
+SET
+ old.`IsDeleted` = 1,
+ old.`Status` = 1,
+ old.`UpdateTime` = NOW()
+WHERE old.`IsDeleted` = 0
+ AND LOWER(old.`LevelCode`) IN ('normal', 'silver', 'gold', 'diamond')
+ AND old.`Id` <> canon.`Id`;
+
+-- ---------- 校正 V0~V5 门槛/折扣(已占用客户的行 Id 不变)----------
+UPDATE `t_member_level`
+SET
+ `LevelName` = '普通会员',
+ `MinPoints` = 0,
+ `ExtraDiscountRate` = 0.00,
+ `CanDowngrade` = 0,
+ `Sort` = 0,
+ `BenefitDesc` = '新注册 / 小单试单卖家',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V0';
+
+UPDATE `t_member_level`
+SET
+ `LevelName` = '铜卡会员',
+ `MinPoints` = 24000,
+ `ExtraDiscountRate` = 0.01,
+ `CanDowngrade` = 0,
+ `Sort` = 1,
+ `BenefitDesc` = '起步稳定发货卖家,基础运费 99 折',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V1';
+
+UPDATE `t_member_level`
+SET
+ `LevelName` = '银卡会员',
+ `MinPoints` = 60000,
+ `ExtraDiscountRate` = 0.02,
+ `CanDowngrade` = 0,
+ `Sort` = 2,
+ `BenefitDesc` = '主力中小卖家,基础运费 98 折',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V2';
+
+UPDATE `t_member_level`
+SET
+ `LevelName` = '金卡会员',
+ `MinPoints` = 120000,
+ `ExtraDiscountRate` = 0.03,
+ `CanDowngrade` = 0,
+ `Sort` = 3,
+ `BenefitDesc` = '优质中型卖家,基础运费 97 折;此级及以下只升不降',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V3';
+
+UPDATE `t_member_level`
+SET
+ `LevelName` = '钻石会员',
+ `MinPoints` = 240000,
+ `ExtraDiscountRate` = 0.04,
+ `CanDowngrade` = 1,
+ `Sort` = 4,
+ `BenefitDesc` = '中大卖家,基础运费 96 折;滚动积分不足可降到金卡',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V4';
+
+UPDATE `t_member_level`
+SET
+ `LevelName` = '皇冠会员',
+ `MinPoints` = 600000,
+ `ExtraDiscountRate` = 0.05,
+ `CanDowngrade` = 1,
+ `Sort` = 5,
+ `BenefitDesc` = '顶级头部卖家,基础运费 95 折封顶;滚动积分不足可降到钻石',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0 AND `LevelCode` = 'V5';
diff --git a/sql/migrate_order_export_qualification_id.sql b/sql/migrate_order_export_qualification_id.sql
deleted file mode 100644
index 7cb5d9b..0000000
--- a/sql/migrate_order_export_qualification_id.sql
+++ /dev/null
@@ -1,24 +0,0 @@
--- 存量库:t_order 增加出口资质备案 Id(9610Summary 选用)
--- 可重复执行。
-
-SET @db := DATABASE();
-
-SET @exists := (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = @db
- AND TABLE_NAME = 't_order'
- AND COLUMN_NAME = 'ExportQualificationId'
-);
-
-SET @sql := IF(
- @exists = 0,
- 'ALTER TABLE `t_order`
- ADD COLUMN `ExportQualificationId` INT NULL COMMENT ''出口资质备案 Id(t_customer_export_qualification.Id)'' AFTER `ExportCustomsMode`,
- ADD KEY `idx_order_export_qualification` (`ExportQualificationId`)',
- 'SELECT ''t_order.ExportQualificationId already exists'' AS info'
-);
-
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_pickup_direction_and_address_type.sql b/sql/migrate_pickup_direction_and_address_type.sql
deleted file mode 100644
index 8ebfac4..0000000
--- a/sql/migrate_pickup_direction_and_address_type.sql
+++ /dev/null
@@ -1,16 +0,0 @@
--- 揽收方向 + 客户地址类型扩展(国内揽收)
--- 正向 forward:客户揽收地址 → 仓库
--- 逆向 reverse:仓库 → 客户揽收地址
-
-ALTER TABLE `t_pickup_order`
- ADD COLUMN `Direction` VARCHAR(16) NOT NULL DEFAULT 'forward'
- COMMENT 'forward正向(客户→仓)/reverse逆向(仓→客户)'
- AFTER `SenderAddressId`;
-
-ALTER TABLE `t_pickup_order`
- ADD KEY `idx_pickup_direction_status` (`Direction`, `Status`, `CreateTime`);
-
--- 地址类型注释更新(列已存在,仅说明)
-ALTER TABLE `t_customer_sender_address`
- MODIFY COLUMN `AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender'
- COMMENT 'sender国内发件/return国内退件/pickup国内揽收/receiver海外收件';
diff --git a/sql/migrate_pickup_fee_push.sql b/sql/migrate_pickup_fee_push.sql
deleted file mode 100644
index 539f2a0..0000000
--- a/sql/migrate_pickup_fee_push.sql
+++ /dev/null
@@ -1,11 +0,0 @@
--- 揽收单:承运推送件重尺 + 系统卖价/买价费用快照
-ALTER TABLE `t_pickup_order`
- ADD COLUMN `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重' AFTER `ChargeableWeight`,
- ADD COLUMN `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON' AFTER `VolumetricWeight`,
- ADD COLUMN `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)' AFTER `PackageSizesJson`,
- ADD COLUMN `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)' AFTER `CarrierFreightWeight`,
- ADD COLUMN `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)' AFTER `CarrierQuotedFreightAmount`,
- ADD COLUMN `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文' AFTER `CarrierQuotedReceivableAmount`,
- ADD COLUMN `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON' AFTER `CarrierQuoteJson`,
- ADD COLUMN `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON' AFTER `ReceivableFeeJson`,
- ADD COLUMN `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间' AFTER `PayableFeeJson`;
diff --git a/sql/migrate_referral_invite_reward.sql b/sql/migrate_referral_invite_reward.sql
new file mode 100644
index 0000000..3d23f2c
--- /dev/null
+++ b/sql/migrate_referral_invite_reward.sql
@@ -0,0 +1,97 @@
+-- 邀请奖励:按实付基础运费百分比、T+N 工作日入运费余额、退款标记(可重复执行)
+SET @db := DATABASE();
+
+-- ---------- t_order.IsRefunded ----------
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order'
+ AND COLUMN_NAME = 'IsRefunded'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_order`
+ ADD COLUMN `IsRefunded` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否已退款(分成计次排除)'' AFTER `IsPaid`',
+ 'SELECT ''t_order.IsRefunded already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ---------- t_referral_commission_rule 新字段 ----------
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_referral_commission_rule'
+ AND COLUMN_NAME = 'MinInviteLevelCode'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_referral_commission_rule`
+ ADD COLUMN `MinInviteLevelCode` VARCHAR(16) NOT NULL DEFAULT ''V1'' COMMENT ''邀请资格最低等级'' AFTER `MonthlyCap`,
+ ADD COLUMN `MinPaidBaseFreight` DECIMAL(18,2) NOT NULL DEFAULT 50.00 COMMENT ''计奖最低实付基础运费'' AFTER `MinInviteLevelCode`,
+ ADD COLUMN `FirstOrderRate` DECIMAL(8,4) NOT NULL DEFAULT 0.0500 COMMENT ''首单奖励比例'' AFTER `MinPaidBaseFreight`,
+ ADD COLUMN `FirstOrderCap` DECIMAL(18,2) NOT NULL DEFAULT 50.00 COMMENT ''首单奖励封顶'' AFTER `FirstOrderRate`,
+ ADD COLUMN `FollowOrderRate` DECIMAL(8,4) NOT NULL DEFAULT 0.0300 COMMENT ''后续单奖励比例'' AFTER `FirstOrderCap`,
+ ADD COLUMN `FollowOrderCap` DECIMAL(18,2) NOT NULL DEFAULT 30.00 COMMENT ''后续单奖励封顶'' AFTER `FollowOrderRate`,
+ ADD COLUMN `FollowWindowDays` INT NOT NULL DEFAULT 90 COMMENT ''后续单注册后天数窗口'' AFTER `FollowOrderCap`,
+ ADD COLUMN `MaxRewardOrders` INT NOT NULL DEFAULT 6 COMMENT ''最多计奖订单数'' AFTER `FollowWindowDays`,
+ ADD COLUMN `SettleBusinessDays` INT NOT NULL DEFAULT 3 COMMENT ''派送成功后结算工作日'' AFTER `MaxRewardOrders`',
+ 'SELECT ''t_referral_commission_rule reward columns already exist'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_referral_commission_rule`
+SET
+ `CommissionMode` = 'percent_of_paid_base_freight',
+ `SettleTriggerOrderStatus` = 'deliverySuccess',
+ `SettleBusinessDays` = CASE WHEN `SettleBusinessDays` > 0 THEN `SettleBusinessDays` ELSE 3 END,
+ `FreezeDays` = CASE WHEN `SettleBusinessDays` > 0 THEN `SettleBusinessDays` ELSE 3 END,
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0;
+
+-- ---------- t_referral_commission_detail ----------
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_referral_commission_detail'
+ AND COLUMN_NAME = 'SettleDueTime'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_referral_commission_detail`
+ ADD COLUMN `SettleDueTime` DATETIME NULL COMMENT ''计划结算时间'' AFTER `EligibleTime`',
+ 'SELECT ''SettleDueTime already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_referral_commission_detail'
+ AND COLUMN_NAME = 'RewardOrderIndex'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_referral_commission_detail`
+ ADD COLUMN `RewardOrderIndex` INT NOT NULL DEFAULT 0 COMMENT ''被邀请人第几单'' AFTER `SettleDueTime`',
+ 'SELECT ''RewardOrderIndex already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_coupon_template`
+SET
+ `Remark` = '被邀请人注册即发 5 元券',
+ `UpdateTime` = NOW()
+WHERE `IsDeleted` = 0
+ AND `TemplateCode` = 'REFERRAL_INVITEE';
diff --git a/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs b/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
index e60808d..4fb56f0 100644
--- a/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
@@ -22,6 +22,24 @@ public class ReferralCommissionRuleOutput
public decimal? MonthlyCap { get; set; }
+ public string MinInviteLevelCode { get; set; } = "V1";
+
+ public decimal MinPaidBaseFreight { get; set; }
+
+ public decimal FirstOrderRate { get; set; }
+
+ public decimal FirstOrderCap { get; set; }
+
+ public decimal FollowOrderRate { get; set; }
+
+ public decimal FollowOrderCap { get; set; }
+
+ public int FollowWindowDays { get; set; }
+
+ public int MaxRewardOrders { get; set; }
+
+ public int SettleBusinessDays { get; set; }
+
public EnableStatus Status { get; set; }
public string StatusName { get; set; } = string.Empty;
@@ -50,6 +68,24 @@ public class ReferralCommissionRuleSaveInput
public decimal? MonthlyCap { get; set; }
+ public string MinInviteLevelCode { get; set; } = "V1";
+
+ public decimal MinPaidBaseFreight { get; set; } = 50M;
+
+ public decimal FirstOrderRate { get; set; } = 0.05M;
+
+ public decimal FirstOrderCap { get; set; } = 50M;
+
+ public decimal FollowOrderRate { get; set; } = 0.03M;
+
+ public decimal FollowOrderCap { get; set; } = 30M;
+
+ public int FollowWindowDays { get; set; } = 90;
+
+ public int MaxRewardOrders { get; set; } = 6;
+
+ public int SettleBusinessDays { get; set; } = 3;
+
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
}
@@ -92,6 +128,10 @@ public class CommissionDetailOutput
public decimal CommissionAmount { get; set; }
+ public int RewardOrderIndex { get; set; }
+
+ public DateTime? SettleDueTime { get; set; }
+
public CommissionStatus Status { get; set; }
public string StatusName { get; set; } = string.Empty;
diff --git a/src/Oms.Application/Dtos/Marketing/CouponDtos.cs b/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
index 42d15c3..826c143 100644
--- a/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
@@ -387,6 +387,10 @@ public class CouponInstanceOutput
public DateTime? LockExpireTime { get; set; }
+ public string? TemplateCode { get; set; }
+
+ public string? UnlockHint { get; set; }
+
}
@@ -415,6 +419,10 @@ public class CouponAvailableInput
public decimal? AmountAfterLevel { get; set; }
+ public decimal? BaseFreightAmount { get; set; }
+
+ public string? DestinationCountry { get; set; }
+
}
diff --git a/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs b/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs
index 4ee00a8..ba4651b 100644
--- a/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs
+++ b/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs
@@ -4,6 +4,8 @@ public class MarketingPriceBreakdown
{
public decimal ReceivableSubtotal { get; set; }
+ public decimal BaseFreightAmount { get; set; }
+
public decimal LevelDiscountAmount { get; set; }
public decimal AmountAfterLevel { get; set; }
@@ -12,6 +14,8 @@ public class MarketingPriceBreakdown
public decimal PayableAmount { get; set; }
+ public decimal PaidBaseFreightAmount { get; set; }
+
public int? MemberLevelId { get; set; }
public string? MemberLevelName { get; set; }
diff --git a/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs b/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs
index 773eed9..84ba9ff 100644
--- a/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs
+++ b/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs
@@ -17,6 +17,9 @@ public class MemberLevelOutput
public decimal ExtraDiscountRate { get; set; }
+ /// 是否允许降级。V4 钻石、V5 皇冠为 true,且只能逐级降。
+ public bool CanDowngrade { get; set; }
+
public int Sort { get; set; }
public string? IconUrl { get; set; }
diff --git a/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs b/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs
index f2f35bd..6aa2cc7 100644
--- a/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs
+++ b/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs
@@ -20,6 +20,8 @@ public class MemberLevelSaveInput
public decimal ExtraDiscountRate { get; set; }
+ public bool CanDowngrade { get; set; }
+
public int Sort { get; set; }
public string? IconUrl { get; set; }
diff --git a/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs b/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs
index 3c8a01e..b2b1cf3 100644
--- a/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs
+++ b/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs
@@ -1,25 +1,27 @@
-namespace Oms.Application.Dtos.Marketing;
-
-public class MemberOverviewOutput
-{
- public int? MemberLevelId { get; set; }
-
- public string? MemberLevelName { get; set; }
-
- public string? PriceTierKey { get; set; }
-
- public int TotalPoints { get; set; }
-
- public int AvailablePoints { get; set; }
-
- public int? NextLevelId { get; set; }
-
- public string? NextLevelName { get; set; }
-
- public int? PointsToNextLevel { get; set; }
-
- public decimal ExtraDiscountRate { get; set; }
-
- public string? BenefitDesc { get; set; }
-}
+namespace Oms.Application.Dtos.Marketing;
+
+public class MemberOverviewOutput
+{
+ public int? MemberLevelId { get; set; }
+
+ public string? MemberLevelName { get; set; }
+
+ public string? PriceTierKey { get; set; }
+
+ public int TotalPoints { get; set; }
+
+ public int RollingPoints { get; set; }
+
+ public int AvailablePoints { get; set; }
+
+ public int? NextLevelId { get; set; }
+
+ public string? NextLevelName { get; set; }
+
+ public int? PointsToNextLevel { get; set; }
+
+ public decimal ExtraDiscountRate { get; set; }
+
+ public string? BenefitDesc { get; set; }
+}
\ No newline at end of file
diff --git a/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs b/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs
index 7c3a326..f8cc66e 100644
--- a/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs
@@ -1,123 +1,137 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace Oms.Application.Dtos.Marketing;
-
-public class ReferralMyInviteOutput
-{
- public string InviteCode { get; set; } = string.Empty;
-
- public byte Status { get; set; }
-
- public string StatusName { get; set; } = string.Empty;
-
- public int RefereeCount { get; set; }
-}
-
-public class ReferralRefereeListInput
-{
- public int Page { get; set; }
-
- public int PageSize { get; set; }
-}
-
-public class ReferralRefereeOutput
-{
- public int CustomerId { get; set; }
-
- public string CustomerCode { get; set; } = string.Empty;
-
- public string? CustomerNameMasked { get; set; }
-
- public string? PhoneMasked { get; set; }
-
- public DateTime CreateTime { get; set; }
-
- public bool HasPaidOrder { get; set; }
-
- public bool HasDeliverySuccessOrder { get; set; }
-}
-
-public class ReferralValidateCodeOutput
-{
- public bool Valid { get; set; }
-
- public string? InviterCustomerCode { get; set; }
-
- public string? InviterNameMasked { get; set; }
-
- public string? Message { get; set; }
-}
-
-public class ReferralTreeInput
-{
- public int? CustomerId { get; set; }
-
- public string? CustomerCode { get; set; }
-
- public int Page { get; set; }
-
- public int PageSize { get; set; }
-}
-
-public class ReferralReferrerListInput
-{
- public string? Keyword { get; set; }
-
- public int Page { get; set; }
-
- public int PageSize { get; set; }
-}
-
-public class ReferralReferrerOutput
-{
- public int CustomerId { get; set; }
-
- public string CustomerCode { get; set; } = string.Empty;
-
- public string CustomerName { get; set; } = string.Empty;
-
- public string? Phone { get; set; }
-
- public int RefereeCount { get; set; }
-
- public DateTime? LatestBindTime { get; set; }
-}
-
-public class ReferralTreeNodeOutput
-{
- public int CustomerId { get; set; }
-
- public string CustomerCode { get; set; } = string.Empty;
-
- public string CustomerName { get; set; } = string.Empty;
-
- public string? Phone { get; set; }
-
- public int? ReferrerCustomerId { get; set; }
-
- public string? ReferrerCustomerCode { get; set; }
-
- public DateTime CreateTime { get; set; }
-
- public DateTime? ReferralBindTime { get; set; }
-
- public string? ReferralChannel { get; set; }
-}
-
-public class ReferralRebindInput
-{
- public int? CustomerId { get; set; }
-
- public string? CustomerCode { get; set; }
-
- public int? NewReferrerCustomerId { get; set; }
-
- public string? NewReferrerCustomerCode { get; set; }
-
- public string? NewInviteCode { get; set; }
-
- [Required]
- [StringLength(512, MinimumLength = 20)]
- public string Reason { get; set; } = string.Empty;
-}
-
\ No newline at end of file
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Marketing;
+
+public class ReferralMyInviteOutput
+{
+ public string InviteCode { get; set; } = string.Empty;
+
+ public byte Status { get; set; }
+
+ public string StatusName { get; set; } = string.Empty;
+
+ public int RefereeCount { get; set; }
+
+ public bool CanInvite { get; set; }
+
+ public string MinInviteLevelCode { get; set; } = "V1";
+
+ public decimal PendingReward { get; set; }
+
+ public decimal SettledReward { get; set; }
+}
+
+public class ReferralRefereeListInput
+{
+ public int Page { get; set; }
+
+ public int PageSize { get; set; }
+}
+
+public class ReferralRefereeOutput
+{
+ public int CustomerId { get; set; }
+
+ public string CustomerCode { get; set; } = string.Empty;
+
+ public string? CustomerNameMasked { get; set; }
+
+ public string? PhoneMasked { get; set; }
+
+ public DateTime CreateTime { get; set; }
+
+ public bool HasPaidOrder { get; set; }
+
+ public bool HasDeliverySuccessOrder { get; set; }
+
+ public int RewardOrderCount { get; set; }
+
+ public decimal PendingReward { get; set; }
+
+ public decimal SettledReward { get; set; }
+}
+
+public class ReferralValidateCodeOutput
+{
+ public bool Valid { get; set; }
+
+ public string? InviterCustomerCode { get; set; }
+
+ public string? InviterNameMasked { get; set; }
+
+ public string? Message { get; set; }
+}
+
+public class ReferralTreeInput
+{
+ public int? CustomerId { get; set; }
+
+ public string? CustomerCode { get; set; }
+
+ public int Page { get; set; }
+
+ public int PageSize { get; set; }
+}
+
+public class ReferralReferrerListInput
+{
+ public string? Keyword { get; set; }
+
+ public int Page { get; set; }
+
+ public int PageSize { get; set; }
+}
+
+public class ReferralReferrerOutput
+{
+ public int CustomerId { get; set; }
+
+ public string CustomerCode { get; set; } = string.Empty;
+
+ public string CustomerName { get; set; } = string.Empty;
+
+ public string? Phone { get; set; }
+
+ public int RefereeCount { get; set; }
+
+ public DateTime? LatestBindTime { get; set; }
+}
+
+public class ReferralTreeNodeOutput
+{
+ public int CustomerId { get; set; }
+
+ public string CustomerCode { get; set; } = string.Empty;
+
+ public string CustomerName { get; set; } = string.Empty;
+
+ public string? Phone { get; set; }
+
+ public int? ReferrerCustomerId { get; set; }
+
+ public string? ReferrerCustomerCode { get; set; }
+
+ public DateTime CreateTime { get; set; }
+
+ public DateTime? ReferralBindTime { get; set; }
+
+ public string? ReferralChannel { get; set; }
+}
+
+public class ReferralRebindInput
+{
+ public int? CustomerId { get; set; }
+
+ public string? CustomerCode { get; set; }
+
+ public int? NewReferrerCustomerId { get; set; }
+
+ public string? NewReferrerCustomerCode { get; set; }
+
+ public string? NewInviteCode { get; set; }
+
+ [Required]
+ [StringLength(512, MinimumLength = 20)]
+ public string Reason { get; set; } = string.Empty;
+}
+
diff --git a/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs b/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs
new file mode 100644
index 0000000..ac425f8
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Orders;
+
+public class OrderRefundInput
+{
+ [Required]
+ public int Id { get; set; }
+
+ ///
+ /// true=全额退款(可退券);false=部分退款(券不退回)。
+ ///
+ public bool IsFullRefund { get; set; } = true;
+}
diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs
index 8e6bed7..dd14bec 100644
--- a/src/Oms.Application/Services/Customers/CustomerService.cs
+++ b/src/Oms.Application/Services/Customers/CustomerService.cs
@@ -242,7 +242,9 @@ public class CustomerService : ApplicationService
entity,
input.InviteCode,
ReferralCodeRepository,
- CustomerRepository);
+ CustomerRepository,
+ MemberLevelRepository,
+ LazyServiceProvider.GetRequiredService());
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
entity,
input.AdvisorCode,
@@ -251,6 +253,10 @@ public class CustomerService : ApplicationService
try
{
await CouponManager.GrantNewcomerCouponsAsync(entity.Id);
+ if (entity.ReferrerCustomerId.HasValue)
+ {
+ await CouponManager.GrantInviteeCouponAsync(entity.Id);
+ }
}
catch
{
diff --git a/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs b/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
index 75f867e..fe8ffc3 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
@@ -246,6 +246,8 @@ public class MarketingCommissionAdminService : ApplicationService
Status = entity.Status,
StatusName = MarketingEnumNames.Of(entity.Status),
EligibleTime = entity.EligibleTime,
+ SettleDueTime = entity.SettleDueTime,
+ RewardOrderIndex = entity.RewardOrderIndex,
SettleTime = entity.SettleTime,
CancelReason = entity.CancelReason,
CreateTime = entity.CreateTime
diff --git a/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs b/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs
index dc79370..6f43d77 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs
@@ -47,15 +47,26 @@ public class MarketingCommissionRuleService : ApplicationService
entity.RuleName = input.RuleName.Trim();
entity.CommissionMode = string.IsNullOrWhiteSpace(input.CommissionMode)
- ? "per_amount"
+ ? "percent_of_paid_base_freight"
: input.CommissionMode.Trim();
- entity.BaseAmount = input.BaseAmount;
- entity.CommissionAmount = input.CommissionAmount;
- entity.FreezeDays = input.FreezeDays;
+ entity.BaseAmount = input.MinPaidBaseFreight;
+ entity.CommissionAmount = 0;
entity.SettleTriggerOrderStatus = string.IsNullOrWhiteSpace(input.SettleTriggerOrderStatus)
? "deliverySuccess"
: input.SettleTriggerOrderStatus.Trim();
entity.MonthlyCap = input.MonthlyCap;
+ entity.MinInviteLevelCode = string.IsNullOrWhiteSpace(input.MinInviteLevelCode) ? "V1" : input.MinInviteLevelCode.Trim();
+ entity.MinPaidBaseFreight = input.MinPaidBaseFreight;
+ entity.FirstOrderRate = input.FirstOrderRate;
+ entity.FirstOrderCap = input.FirstOrderCap;
+ entity.FollowOrderRate = input.FollowOrderRate;
+ entity.FollowOrderCap = input.FollowOrderCap;
+ entity.FollowWindowDays = input.FollowWindowDays;
+ entity.MaxRewardOrders = input.MaxRewardOrders;
+ entity.SettleBusinessDays = input.SettleBusinessDays >= 0
+ ? input.SettleBusinessDays
+ : Math.Max(input.FreezeDays, 0);
+ entity.FreezeDays = entity.SettleBusinessDays;
entity.Status = input.Status;
if (input.Id is > 0)
@@ -75,19 +86,20 @@ public class MarketingCommissionRuleService : ApplicationService
throw new BusinessException("Rule name is required");
}
- if (input.BaseAmount <= 0)
+ if (input.MinPaidBaseFreight < 0
+ || input.FirstOrderRate is < 0 or > 1
+ || input.FirstOrderCap < 0
+ || input.FollowOrderRate is < 0 or > 1
+ || input.FollowOrderCap < 0
+ || input.FollowWindowDays <= 0
+ || input.MaxRewardOrders <= 0)
{
- throw new BusinessException("Base amount must be positive");
+ throw new BusinessException("Invite reward parameters are invalid");
}
- if (input.CommissionAmount <= 0)
+ if (input.SettleBusinessDays < 0 && input.FreezeDays < 0)
{
- throw new BusinessException("Commission amount must be positive");
- }
-
- if (input.FreezeDays < 0)
- {
- throw new BusinessException("Freeze days cannot be negative");
+ throw new BusinessException("Settle business days cannot be negative");
}
}
@@ -101,6 +113,15 @@ public class MarketingCommissionRuleService : ApplicationService
FreezeDays = rule.FreezeDays,
SettleTriggerOrderStatus = rule.SettleTriggerOrderStatus,
MonthlyCap = rule.MonthlyCap,
+ MinInviteLevelCode = rule.MinInviteLevelCode,
+ MinPaidBaseFreight = rule.MinPaidBaseFreight,
+ FirstOrderRate = rule.FirstOrderRate,
+ FirstOrderCap = rule.FirstOrderCap,
+ FollowOrderRate = rule.FollowOrderRate,
+ FollowOrderCap = rule.FollowOrderCap,
+ FollowWindowDays = rule.FollowWindowDays,
+ MaxRewardOrders = rule.MaxRewardOrders,
+ SettleBusinessDays = rule.SettleBusinessDays > 0 ? rule.SettleBusinessDays : rule.FreezeDays,
Status = rule.Status,
StatusName = MarketingEnumNames.Of(rule.Status),
CreateTime = rule.CreateTime,
diff --git a/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs b/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
index 9899c3d..073e645 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
@@ -224,6 +224,10 @@ public class MarketingCouponGrantService : ApplicationService
ValidEndTime = entity.ValidEndTime,
UseTime = entity.UseTime,
OrderId = entity.OrderId,
- LockExpireTime = entity.LockExpireTime
+ LockExpireTime = entity.LockExpireTime,
+ TemplateCode = template?.TemplateCode,
+ UnlockHint = entity.Status == CouponInstanceStatus.PendingUnlock
+ ? "完成首单派送即可解锁"
+ : null
};
}
diff --git a/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs b/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs
index d45d478..5230798 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs
@@ -165,6 +165,12 @@ public class MarketingCouponTemplateService : ApplicationService
throw new BusinessException("Template code already exists");
}
+ if (input.ValidType == CouponValidType.DaysAfterUnlock &&
+ (!input.ValidDaysAfterReceive.HasValue || input.ValidDaysAfterReceive.Value <= 0))
+ {
+ throw new BusinessException("Unlock validity days is required");
+ }
+
ValidateCouponTypeFields(input);
}
diff --git a/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs b/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs
index ee89690..455dd90 100644
--- a/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs
@@ -132,9 +132,9 @@ public class MarketingMemberLevelService : ApplicationService
throw new BusinessException("等级名称不能为空");
}
- if (input.ExtraDiscountRate < 0 || input.ExtraDiscountRate >= 1)
+ if (input.ExtraDiscountRate < 0 || input.ExtraDiscountRate > MarketingBillingCalculator.MaxFreightOffRate)
{
- throw new BusinessException("额外折扣率须在 0~1 之间(不含 1)");
+ throw new BusinessException("基础运费折扣率须在 0~0.05 之间(0.05 即 95 折封顶)");
}
if (await MemberLevelRepository.ExistsLevelCodeAsync(levelCode, excludeId))
@@ -149,6 +149,7 @@ public class MarketingMemberLevelService : ApplicationService
entity.LevelName = input.LevelName.Trim();
entity.MinPoints = input.MinPoints;
entity.ExtraDiscountRate = input.ExtraDiscountRate;
+ entity.CanDowngrade = input.CanDowngrade;
entity.Sort = input.Sort;
entity.IconUrl = string.IsNullOrWhiteSpace(input.IconUrl) ? null : input.IconUrl.Trim();
entity.BenefitDesc = string.IsNullOrWhiteSpace(input.BenefitDesc) ? null : input.BenefitDesc.Trim();
@@ -166,6 +167,7 @@ public class MarketingMemberLevelService : ApplicationService
LevelName = entity.LevelName,
MinPoints = entity.MinPoints,
ExtraDiscountRate = entity.ExtraDiscountRate,
+ CanDowngrade = entity.CanDowngrade,
Sort = entity.Sort,
IconUrl = entity.IconUrl,
BenefitDesc = entity.BenefitDesc,
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs
index 54e1901..3c3e011 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs
@@ -116,6 +116,8 @@ public class MarketingPortalCommissionService : ApplicationService
Status = entity.Status,
StatusName = MarketingEnumNames.Of(entity.Status),
EligibleTime = entity.EligibleTime,
+ SettleDueTime = entity.SettleDueTime,
+ RewardOrderIndex = entity.RewardOrderIndex,
SettleTime = entity.SettleTime,
CancelReason = entity.CancelReason,
CreateTime = entity.CreateTime
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs
index 93f0c15..cdd8ab5 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs
@@ -84,7 +84,8 @@ public class MarketingPortalCouponService : ApplicationService
var mainCustomer = await MarketingPortalSessionHelper.ResolveMainCustomerAsync(
CustomerRepository,
CurrentUser);
- return await CouponManager.BuildAvailableCouponsAsync(mainCustomer.Id, input.AmountAfterLevel);
+ return await CouponManager.BuildAvailableCouponsAsync(
+ mainCustomer.Id, input.BaseFreightAmount ?? input.AmountAfterLevel, input.DestinationCountry);
}
///
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs
index 4c382be..15726fa 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs
@@ -38,6 +38,7 @@ public class MarketingPortalMemberService : ApplicationService
CustomerRepository,
CurrentUser);
var account = await PointsManager.GetOrCreateAccountAsync(mainCustomer.Id);
+ var rollingPoints = await PointsManager.GetRollingPointsAsync(mainCustomer.Id);
var levels = await MemberLevelRepository.GetAllEnabledOrderedAsync();
MemberLevel? currentLevel = null;
@@ -48,7 +49,7 @@ public class MarketingPortalMemberService : ApplicationService
}
var nextLevel = levels
- .Where(x => x.MinPoints > account.TotalPoints)
+ .Where(x => x.MinPoints > rollingPoints)
.OrderBy(x => x.MinPoints)
.ThenBy(x => x.Sort)
.FirstOrDefault();
@@ -59,10 +60,11 @@ public class MarketingPortalMemberService : ApplicationService
MemberLevelName = currentLevel?.LevelName,
PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey),
TotalPoints = account.TotalPoints,
+ RollingPoints = rollingPoints,
AvailablePoints = account.AvailablePoints,
NextLevelId = nextLevel?.Id,
NextLevelName = nextLevel?.LevelName,
- PointsToNextLevel = nextLevel == null ? null : nextLevel.MinPoints - account.TotalPoints,
+ PointsToNextLevel = nextLevel == null ? null : Math.Max(0, nextLevel.MinPoints - rollingPoints),
ExtraDiscountRate = currentLevel?.ExtraDiscountRate ?? 0M,
BenefitDesc = currentLevel?.BenefitDesc
};
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs
index 16cb88d..2fe0eca 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs
@@ -28,6 +28,15 @@ public class MarketingPortalReferralService : ApplicationService
private IOrderRepository OrderRepository =>
LazyServiceProvider.GetRequiredService();
+ private IMemberLevelRepository MemberLevelRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private IReferralCommissionRuleRepository CommissionRuleRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private IReferralCommissionDetailRepository CommissionDetailRepository =>
+ LazyServiceProvider.GetRequiredService();
+
private MarketingReferralCodeProvisioner ReferralCodeProvisioner =>
LazyServiceProvider.GetRequiredService();
@@ -48,13 +57,22 @@ public class MarketingPortalReferralService : ApplicationService
var referralCode = await ReferralCodeProvisioner.GetOrCreateAsync(mainCustomer.Id);
var refereeCount = await CustomerRepository.CountAsync(
x => x.ReferrerCustomerId == mainCustomer.Id && x.ParentCustomerId == null);
+ var (canInvite, minLevel) = await ResolveCanInviteAsync(mainCustomer);
+ var pendingReward = await CommissionDetailRepository.SumByReferrerAsync(
+ mainCustomer.Id, CommissionStatus.Pending);
+ var settledReward = await CommissionDetailRepository.SumByReferrerAsync(
+ mainCustomer.Id, CommissionStatus.Settled);
return new ReferralMyInviteOutput
{
InviteCode = referralCode.InviteCode,
Status = (byte)referralCode.Status,
StatusName = MarketingEnumNames.Of(referralCode.Status),
- RefereeCount = refereeCount
+ RefereeCount = refereeCount,
+ CanInvite = canInvite,
+ MinInviteLevelCode = minLevel,
+ PendingReward = pendingReward,
+ SettledReward = settledReward
};
}
@@ -82,6 +100,14 @@ public class MarketingPortalReferralService : ApplicationService
var total = await CustomerRepository.CountAsync(
x => x.ReferrerCustomerId == mainCustomer.Id && x.ParentCustomerId == null);
+ var refereeIds = entities.Select(x => x.Id).ToArray();
+ var details = await CommissionDetailRepository.ListByReferrerAndRefereesAsync(
+ mainCustomer.Id, refereeIds);
+ var detailsByReferee = details
+ .Where(x => x.Status != CommissionStatus.Cancelled)
+ .GroupBy(x => x.RefereeCustomerId)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
var items = new List();
foreach (var entity in entities)
{
@@ -90,6 +116,8 @@ public class MarketingPortalReferralService : ApplicationService
var hasDeliverySuccess = await OrderRepository.AnyAsync(
x => x.CustomerId == entity.Id
&& x.OrderStatus == OrderStatusCodes.DeliverySuccess);
+ detailsByReferee.TryGetValue(entity.Id, out var refereeDetails);
+ refereeDetails ??= [];
items.Add(new ReferralRefereeOutput
{
@@ -99,7 +127,14 @@ public class MarketingPortalReferralService : ApplicationService
PhoneMasked = MarketingPortalSessionHelper.MaskPhone(entity.Phone),
CreateTime = entity.CreateTime,
HasPaidOrder = hasPaidOrder,
- HasDeliverySuccessOrder = hasDeliverySuccess
+ HasDeliverySuccessOrder = hasDeliverySuccess,
+ RewardOrderCount = refereeDetails.Count,
+ PendingReward = refereeDetails
+ .Where(x => x.Status == CommissionStatus.Pending)
+ .Sum(x => x.CommissionAmount),
+ SettledReward = refereeDetails
+ .Where(x => x.Status == CommissionStatus.Settled)
+ .Sum(x => x.CommissionAmount)
});
}
@@ -143,6 +178,16 @@ public class MarketingPortalReferralService : ApplicationService
};
}
+ var (canInvite, _) = await ResolveCanInviteAsync(inviter);
+ if (!canInvite)
+ {
+ return new ReferralValidateCodeOutput
+ {
+ Valid = false,
+ Message = "邀请人暂无邀请资格"
+ };
+ }
+
return new ReferralValidateCodeOutput
{
Valid = true,
@@ -151,4 +196,18 @@ public class MarketingPortalReferralService : ApplicationService
Message = "邀请码有效"
};
}
+
+ private async Task<(bool CanInvite, string MinLevel)> ResolveCanInviteAsync(
+ Domain.CustomersAggregate.Entities.Customer customer)
+ {
+ var rule = await CommissionRuleRepository.GetActiveRuleAsync();
+ var minLevel = string.IsNullOrWhiteSpace(rule?.MinInviteLevelCode) ? "V1" : rule.MinInviteLevelCode;
+ Domain.MarketingAggregate.Entities.MemberLevel? level = null;
+ if (customer.MemberLevelId.HasValue)
+ {
+ level = await MemberLevelRepository.FirstOrDefaultAsync(x => x.Id == customer.MemberLevelId.Value);
+ }
+
+ return (MarketingInviteRewardCalculator.CanInvite(level?.LevelCode, minLevel), minLevel);
+ }
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index f3c00a6..3423204 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -2849,11 +2849,15 @@ public partial class OrderService
private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId)
{
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
- var receivableSubtotal = fees
- .Where(x => x.Type == OrderFeeTypeCodes.Receivable)
- .Sum(x => x.Amount);
+ var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees);
+ var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees);
var marketingHelper = LazyServiceProvider.GetRequiredService();
- var breakdown = await marketingHelper.BuildBreakdownAsync(order.CustomerId, receivableSubtotal, couponInstanceId);
+ var breakdown = await marketingHelper.BuildBreakdownAsync(
+ order.CustomerId,
+ receivableSubtotal,
+ baseFreightAmount,
+ couponInstanceId,
+ order.ConsigneeCountry);
marketingHelper.ApplySnapshotToOrder(order, breakdown, couponInstanceId);
if (breakdown.CouponInstanceId.HasValue && order.CustomerId.HasValue)
@@ -2883,14 +2887,15 @@ public partial class OrderService
private async Task RefreshMarketingPayableAmountAsync(Order order)
{
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
- var receivableSubtotal = fees
- .Where(x => x.Type == OrderFeeTypeCodes.Receivable)
- .Sum(x => x.Amount);
+ var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees);
+ var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees);
var marketingHelper = LazyServiceProvider.GetRequiredService();
var breakdown = await marketingHelper.BuildBreakdownAsync(
order.CustomerId,
receivableSubtotal,
- order.CouponInstanceId);
+ baseFreightAmount,
+ order.CouponInstanceId,
+ order.ConsigneeCountry);
marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId);
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
index 7220889..4000bae 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
@@ -326,15 +326,18 @@ public partial class OrderService
foreach (var item in result.Items)
{
+ var originalTotal = item.TotalCost;
var breakdown = await marketingHelper.BuildBreakdownAsync(
input.CustomerId,
- item.TotalCost,
- input.CouponInstanceId);
+ originalTotal,
+ item.Freight,
+ input.CouponInstanceId,
+ input.DestinationCountry);
item.PriceBreakdown = breakdown;
item.TotalCost = breakdown.PayableAmount;
item.TotalCostNum = breakdown.PayableAmount;
firstBreakdown ??= breakdown;
- amountAfterLevel ??= breakdown.AmountAfterLevel;
+ amountAfterLevel ??= item.Freight;
}
result.PriceBreakdown = firstBreakdown;
@@ -342,7 +345,8 @@ public partial class OrderService
{
result.AvailableCoupons = await marketingHelper.BuildAvailableCouponsAsync(
input.CustomerId,
- amountAfterLevel);
+ amountAfterLevel,
+ input.DestinationCountry);
}
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index 4d7ab7f..9d33c6c 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -178,7 +178,11 @@ public partial class OrderService : ApplicationService
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer);
var pointsManager = LazyServiceProvider.GetRequiredService();
- await pointsManager.EarnOnOrderPaidAsync(mainCustomer.Id, order.Id, payAmount);
+ var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight(
+ MarketingFeeHelper.SumReceivableBaseFreight(fees),
+ order.LevelDiscountAmount,
+ order.CouponDiscountAmount);
+ await pointsManager.EarnOnOrderPaidAsync(mainCustomer.Id, order.Id, paidBaseFreight, order.ServiceNo);
order.PayTime = DateTime.Now;
order.IsPaid = 1;
@@ -199,6 +203,47 @@ public partial class OrderService : ApplicationService
var commissionManager = LazyServiceProvider.GetRequiredService();
await commissionManager.OnOrderDeliverySuccessAsync(order.Id);
+
+ var couponManager = LazyServiceProvider.GetRequiredService();
+ await couponManager.OnFirstDeliverySuccessAsync(order);
+ }
+
+ ///
+ /// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。
+ ///
+ [HttpPost("/order/refund")]
+ public async Task RefundAsync(OrderRefundInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.Id);
+ if (order.IsPaid == 0)
+ {
+ throw new BusinessException("订单未支付,无需退款");
+ }
+
+ var couponManager = LazyServiceProvider.GetRequiredService();
+ await couponManager.RestoreOnOrderRefundAsync(order, input.IsFullRefund);
+
+ order.IsRefunded = 1;
+ await UpdateOrderAsync(order);
+
+ var commissionManager = LazyServiceProvider.GetRequiredService();
+ await commissionManager.ClawbackOnOrderRefundAsync(order);
+
+ if (input.IsFullRefund && order.CustomerId.HasValue)
+ {
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
+ if (customer != null)
+ {
+ var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
+ CustomerRepository, customer);
+ var pointsManager = LazyServiceProvider.GetRequiredService();
+ await pointsManager.ClawbackOnOrderAsync(
+ mainCustomer.Id,
+ order.Id,
+ order.ServiceNo,
+ "订单退款扣回积分");
+ }
+ }
}
///
@@ -265,6 +310,23 @@ public partial class OrderService : ApplicationService
}
order.OrderStatus = OrderStatusCodes.Deleted;
+ if (order.IsPaid > 0 && order.CustomerId.HasValue)
+ {
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
+ if (customer != null)
+ {
+ var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
+ CustomerRepository,
+ customer);
+ var pointsManager = LazyServiceProvider.GetRequiredService();
+ await pointsManager.ClawbackOnOrderAsync(
+ mainCustomer.Id,
+ order.Id,
+ order.ServiceNo,
+ "订单删除扣回积分");
+ }
+ }
+
await OrderRepository.UpdateAsync(order);
await OrderRepository.DeleteAsync(order);
}
diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
index 3b781c8..c408031 100644
--- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
+++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
@@ -999,9 +999,10 @@ internal static class ParcelBillingCalculator
};
}
- private static bool IsFreightCostItem(string costItem) =>
- costItem.Equals("freight", StringComparison.OrdinalIgnoreCase)
- || costItem.Contains("运费", StringComparison.OrdinalIgnoreCase);
+ public static bool IsFreightCostItem(string costItem) =>
+ !string.IsNullOrWhiteSpace(costItem)
+ && (costItem.Equals("freight", StringComparison.OrdinalIgnoreCase)
+ || costItem.Contains("运费", StringComparison.OrdinalIgnoreCase));
internal static string ResolveFeeDisplayName(string costItem, string? priceName)
{
diff --git a/src/Oms.Application/Shared/CustomerBalanceMutator.cs b/src/Oms.Application/Shared/CustomerBalanceMutator.cs
index a2ded58..4a904ea 100644
--- a/src/Oms.Application/Shared/CustomerBalanceMutator.cs
+++ b/src/Oms.Application/Shared/CustomerBalanceMutator.cs
@@ -63,6 +63,58 @@ public class CustomerBalanceMutator : IScopedDependency
cancellationToken);
}
+ public Task CreditReferralRewardAsync(
+ Customer customer,
+ decimal amount,
+ int detailId,
+ string? orderNo,
+ string? remark,
+ CancellationToken cancellationToken = default)
+ {
+ if (amount <= 0)
+ {
+ throw new BusinessException("奖励金额必须大于 0");
+ }
+
+ return ApplyAsync(
+ customer,
+ amount,
+ CustomerAccountFlowBizTypes.ReferralCommission,
+ detailId,
+ orderNo,
+ remark,
+ cancellationToken);
+ }
+
+ public Task DebitReferralClawbackAsync(
+ Customer customer,
+ decimal amount,
+ int detailId,
+ string? orderNo,
+ string? remark,
+ CancellationToken cancellationToken = default)
+ {
+ if (amount <= 0)
+ {
+ return Task.CompletedTask;
+ }
+
+ var debit = Math.Min(amount, customer.Balance);
+ if (debit <= 0)
+ {
+ return Task.CompletedTask;
+ }
+
+ return ApplyAsync(
+ customer,
+ -debit,
+ CustomerAccountFlowBizTypes.ReferralCommission,
+ detailId,
+ orderNo,
+ remark,
+ cancellationToken);
+ }
+
public Task DebitPickupPayAsync(
Customer customer,
decimal amount,
diff --git a/src/Oms.Application/Shared/MarketingBillingCalculator.cs b/src/Oms.Application/Shared/MarketingBillingCalculator.cs
index b2b21b7..8887465 100644
--- a/src/Oms.Application/Shared/MarketingBillingCalculator.cs
+++ b/src/Oms.Application/Shared/MarketingBillingCalculator.cs
@@ -3,10 +3,12 @@ using Oms.Application.Dtos.Marketing;
namespace Oms.Application.Shared;
///
-/// 营销叠加计费(§11)。
+/// 会员折扣仅作用于基础运费;与优惠券不叠加,取更低应付(同价保留会员折,避免占券)。
///
public static class MarketingBillingCalculator
{
+ public const decimal MaxFreightOffRate = 0.05M;
+
public static MarketingPriceBreakdown Calculate(
decimal receivableSubtotal,
decimal extraDiscountRate,
@@ -15,35 +17,75 @@ public static class MarketingBillingCalculator
string? priceTierKey,
CouponDiscountInput? coupon)
{
- var levelDiscount = Math.Round(receivableSubtotal * extraDiscountRate, 2, MidpointRounding.AwayFromZero);
- var amountAfterLevel = receivableSubtotal - levelDiscount;
- var couponDiscount = coupon == null
- ? 0M
- : CalculateCouponDiscount(coupon, amountAfterLevel);
- var payable = Math.Max(0M, amountAfterLevel - couponDiscount);
-
- return new MarketingPriceBreakdown
- {
- ReceivableSubtotal = receivableSubtotal,
- LevelDiscountAmount = levelDiscount,
- AmountAfterLevel = amountAfterLevel,
- CouponDiscountAmount = couponDiscount,
- PayableAmount = payable,
- MemberLevelId = memberLevelId,
- MemberLevelName = memberLevelName,
- PriceTierKey = priceTierKey,
- CouponInstanceId = coupon?.CouponInstanceId
- };
+ return Calculate(
+ receivableSubtotal,
+ receivableSubtotal,
+ extraDiscountRate,
+ memberLevelId,
+ memberLevelName,
+ priceTierKey,
+ coupon);
}
- public static decimal CalculateCouponDiscount(CouponDiscountInput coupon, decimal amountAfterLevel)
+ public static MarketingPriceBreakdown Calculate(
+ decimal receivableSubtotal,
+ decimal baseFreightAmount,
+ decimal extraDiscountRate,
+ int? memberLevelId,
+ string? memberLevelName,
+ string? priceTierKey,
+ CouponDiscountInput? coupon)
{
- if (amountAfterLevel <= 0)
+ receivableSubtotal = Math.Max(0M, receivableSubtotal);
+ baseFreightAmount = Math.Max(0M, Math.Min(baseFreightAmount, receivableSubtotal));
+ extraDiscountRate = Math.Clamp(extraDiscountRate, 0M, MaxFreightOffRate);
+
+ var levelDiscount = Math.Round(baseFreightAmount * extraDiscountRate, 2, MidpointRounding.AwayFromZero);
+ var payableMember = Math.Max(0M, receivableSubtotal - levelDiscount);
+
+ var couponDiscount = coupon == null
+ ? 0M
+ : CalculateCouponDiscount(coupon, baseFreightAmount);
+ var payableCoupon = Math.Max(0M, receivableSubtotal - couponDiscount);
+
+ var useCoupon = coupon != null && couponDiscount > 0M && payableCoupon < payableMember;
+ if (useCoupon)
+ {
+ return Build(
+ receivableSubtotal,
+ baseFreightAmount,
+ 0M,
+ couponDiscount,
+ payableCoupon,
+ memberLevelId,
+ memberLevelName,
+ priceTierKey,
+ coupon!.CouponInstanceId);
+ }
+
+ return Build(
+ receivableSubtotal,
+ baseFreightAmount,
+ levelDiscount,
+ 0M,
+ payableMember,
+ memberLevelId,
+ memberLevelName,
+ priceTierKey,
+ null);
+ }
+
+ ///
+ /// 仅按基础运费判断门槛并抵扣,封顶不超过基础运费(不找零、不冲附加费)。
+ ///
+ public static decimal CalculateCouponDiscount(CouponDiscountInput coupon, decimal baseFreightAmount)
+ {
+ if (baseFreightAmount <= 0)
{
return 0M;
}
- if (amountAfterLevel < coupon.MinOrderAmount)
+ if (baseFreightAmount < coupon.MinOrderAmount)
{
return 0M;
}
@@ -52,12 +94,12 @@ public static class MarketingBillingCalculator
{
MarketingCouponTypeCodes.FixedOff => coupon.FaceValue ?? 0M,
MarketingCouponTypeCodes.DirectOff => coupon.FaceValue ?? 0M,
- MarketingCouponTypeCodes.PercentOff => CalculatePercentOff(amountAfterLevel, coupon.DiscountRate, coupon.MaxDiscountAmount),
+ MarketingCouponTypeCodes.PercentOff => CalculatePercentOff(baseFreightAmount, coupon.DiscountRate, coupon.MaxDiscountAmount),
MarketingCouponTypeCodes.ShippingOff => coupon.FaceValue ?? 0M,
_ => 0M
};
- return Math.Min(Math.Max(0M, discount), amountAfterLevel);
+ return Math.Min(Math.Max(0M, discount), baseFreightAmount);
}
private static decimal CalculatePercentOff(decimal baseAmount, decimal? discountRate, decimal? maxDiscount)
@@ -86,6 +128,36 @@ public static class MarketingBillingCalculator
var segments = Math.Floor(basePayableAmount / baseAmount);
return segments * commissionAmount;
}
+
+ private static MarketingPriceBreakdown Build(
+ decimal receivableSubtotal,
+ decimal baseFreightAmount,
+ decimal levelDiscount,
+ decimal couponDiscount,
+ decimal payable,
+ int? memberLevelId,
+ string? memberLevelName,
+ string? priceTierKey,
+ int? couponInstanceId)
+ {
+ return new MarketingPriceBreakdown
+ {
+ ReceivableSubtotal = receivableSubtotal,
+ BaseFreightAmount = baseFreightAmount,
+ LevelDiscountAmount = levelDiscount,
+ AmountAfterLevel = Math.Max(0M, receivableSubtotal - levelDiscount),
+ CouponDiscountAmount = couponDiscount,
+ PayableAmount = payable,
+ PaidBaseFreightAmount = MarketingFeeHelper.ResolvePaidBaseFreight(
+ baseFreightAmount,
+ levelDiscount,
+ couponDiscount),
+ MemberLevelId = memberLevelId,
+ MemberLevelName = memberLevelName,
+ PriceTierKey = priceTierKey,
+ CouponInstanceId = couponInstanceId
+ };
+ }
}
public static class MarketingCouponTypeCodes
@@ -112,4 +184,6 @@ public class CouponDiscountInput
public decimal? DiscountRate { get; set; }
public decimal? MaxDiscountAmount { get; set; }
+
+ public string? ApplyScopeJson { get; set; }
}
diff --git a/src/Oms.Application/Shared/MarketingCouponManager.cs b/src/Oms.Application/Shared/MarketingCouponManager.cs
index 58ab925..3abf583 100644
--- a/src/Oms.Application/Shared/MarketingCouponManager.cs
+++ b/src/Oms.Application/Shared/MarketingCouponManager.cs
@@ -4,25 +4,34 @@ using Fake.Domain.Exceptions;
using Fake.Security.Claims;
using Fake.Users;
using Oms.Application.Dtos.Marketing;
+using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.MarketingAggregate.Consts;
using Oms.Domain.MarketingAggregate.Entities;
using Oms.Domain.MarketingAggregate.Enums;
using Oms.Domain.MarketingAggregate.Repositories;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+using Oms.Domain.SystemAggregate.Entities;
+using Oms.Domain.SystemAggregate.Repositories;
using System.Text.Json;
namespace Oms.Application.Shared;
///
-/// 优惠券发放、锁定、核销与释放。
+/// 优惠券发放、锁定、核销、解锁与释放。
///
public class MarketingCouponManager : IScopedDependency
{
private const int LockMinutes = 30;
+ private static readonly DateTime PendingValidEnd = new(2099, 12, 31, 23, 59, 59);
private readonly ICouponTemplateRepository _templateRepository;
private readonly ICouponInstanceRepository _instanceRepository;
private readonly ICustomerRepository _customerRepository;
+ private readonly IOrderRepository _orderRepository;
+ private readonly INoticeRepository _noticeRepository;
private readonly IMarketingAuditLogRepository _auditLogRepository;
private readonly ICurrentUser _currentUser;
@@ -30,12 +39,16 @@ public class MarketingCouponManager : IScopedDependency
ICouponTemplateRepository templateRepository,
ICouponInstanceRepository instanceRepository,
ICustomerRepository customerRepository,
+ IOrderRepository orderRepository,
+ INoticeRepository noticeRepository,
IMarketingAuditLogRepository auditLogRepository,
ICurrentUser currentUser)
{
_templateRepository = templateRepository;
_instanceRepository = instanceRepository;
_customerRepository = customerRepository;
+ _orderRepository = orderRepository;
+ _noticeRepository = noticeRepository;
_auditLogRepository = auditLogRepository;
_currentUser = currentUser;
}
@@ -60,13 +73,16 @@ public class MarketingCouponManager : IScopedDependency
throw new BusinessException("该客户已达领取上限");
}
- var (validStart, validEnd) = ResolveValidRange(template);
+ var pendingUnlock = RequiresUnlock(template);
+ var (validStart, validEnd) = pendingUnlock
+ ? (DateTime.Now, PendingValidEnd)
+ : ResolveValidRange(template);
var instance = new CouponInstance
{
TemplateId = templateId,
CustomerId = mainCustomerId,
CouponCode = await GenerateCouponCodeAsync(cancellationToken),
- Status = CouponInstanceStatus.Available,
+ Status = pendingUnlock ? CouponInstanceStatus.PendingUnlock : CouponInstanceStatus.Available,
ReceiveTime = DateTime.Now,
ValidStartTime = validStart,
ValidEndTime = validEnd
@@ -82,7 +98,7 @@ public class MarketingCouponManager : IScopedDependency
}
///
- /// 注册成功后发放所有「新人券」模板(GrantChannel=newcomer 且已发布),有多少发多少;单张失败不阻断。
+ /// 注册成功后发放所有已发布新人券。第二单券发成未解锁。
///
public async Task GrantNewcomerCouponsAsync(int mainCustomerId, CancellationToken cancellationToken = default)
{
@@ -109,13 +125,65 @@ public class MarketingCouponManager : IScopedDependency
}
catch (BusinessException)
{
- // 库存不足、已达上限等跳过
}
}
return granted;
}
+ public async Task GrantInviteeCouponAsync(int mainCustomerId, CancellationToken cancellationToken = default)
+ {
+ if (mainCustomerId <= 0)
+ {
+ return;
+ }
+
+ var templates = await _templateRepository.GetListAsync(
+ x => x.Status == CouponTemplateStatus.Active
+ && x.GrantChannel == MarketingGrantChannels.Referral
+ && x.TemplateCode == MarketingCouponTemplateCodes.ReferralInvitee);
+ foreach (var template in templates)
+ {
+ try
+ {
+ await GrantAsync(template.Id, mainCustomerId, "referral_invitee_register", cancellationToken);
+ }
+ catch (BusinessException)
+ {
+ }
+ }
+ }
+
+ public async Task OnFirstDeliverySuccessAsync(Order order, CancellationToken cancellationToken = default)
+ {
+ if (order.CustomerId is not > 0)
+ {
+ return;
+ }
+
+ var sessionCustomer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value, cancellationToken);
+ if (sessionCustomer == null)
+ {
+ return;
+ }
+
+ var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
+ _customerRepository, sessionCustomer, cancellationToken);
+ var familyIds = await ResolveFamilyCustomerIdsAsync(mainCustomer.Id, cancellationToken);
+ var hadOtherDelivery = await _orderRepository.AnyAsync(
+ x => x.Id != order.Id
+ && x.CustomerId != null
+ && familyIds.Contains(x.CustomerId.Value)
+ && x.OrderStatus == OrderStatusCodes.DeliverySuccess,
+ cancellationToken);
+ if (hadOtherDelivery)
+ {
+ return;
+ }
+
+ await UnlockPendingCouponsAsync(mainCustomer.Id, cancellationToken);
+ }
+
public async Task LockForOrderAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default)
{
var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken);
@@ -137,6 +205,44 @@ public class MarketingCouponManager : IScopedDependency
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
+ public async Task RestoreOnOrderRefundAsync(Order order, bool isFullRefund, CancellationToken cancellationToken = default)
+ {
+ if (!order.CouponInstanceId.HasValue)
+ {
+ return;
+ }
+
+ var instance = await _instanceRepository.FirstOrDefaultAsync(
+ x => x.Id == order.CouponInstanceId.Value, cancellationToken);
+ if (instance == null || instance.Status != CouponInstanceStatus.Used)
+ {
+ return;
+ }
+
+ var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken);
+ var isFirstOrderCoupon = template != null
+ && string.Equals(template.TemplateCode, MarketingCouponTemplateCodes.NewcomerFirst, StringComparison.OrdinalIgnoreCase);
+
+ if (isFirstOrderCoupon || !isFullRefund)
+ {
+ return;
+ }
+
+ if (instance.ValidEndTime < DateTime.Now)
+ {
+ instance.Status = CouponInstanceStatus.Expired;
+ instance.LockExpireTime = null;
+ await _instanceRepository.UpdateAsync(instance, cancellationToken);
+ return;
+ }
+
+ instance.Status = CouponInstanceStatus.Available;
+ instance.OrderId = null;
+ instance.UseTime = null;
+ instance.LockExpireTime = null;
+ await _instanceRepository.UpdateAsync(instance, cancellationToken);
+ }
+
public async Task ReleaseLockAsync(int couponInstanceId, CancellationToken cancellationToken = default)
{
var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == couponInstanceId, cancellationToken);
@@ -163,9 +269,53 @@ public class MarketingCouponManager : IScopedDependency
}
}
+ public async Task ExpireOverdueAsync(int batchSize, CancellationToken cancellationToken = default)
+ {
+ var overdue = await _instanceRepository.GetExpiredAvailableAsync(DateTime.Now, batchSize, cancellationToken);
+ foreach (var instance in overdue)
+ {
+ instance.Status = CouponInstanceStatus.Expired;
+ instance.LockExpireTime = null;
+ await _instanceRepository.UpdateAsync(instance, cancellationToken);
+ }
+ }
+
+ public async Task NotifyExpiringAsync(int batchSize, CancellationToken cancellationToken = default)
+ {
+ var now = DateTime.Now;
+ var expiring = await _instanceRepository.GetExpiringAvailableAsync(
+ now, now.AddDays(3), batchSize, cancellationToken);
+ if (expiring.Count == 0)
+ {
+ return;
+ }
+
+ var templateIds = expiring.Select(x => x.TemplateId).Distinct().ToArray();
+ var templates = await _templateRepository.GetListAsync(x => templateIds.Contains(x.Id));
+ var templateMap = templates.ToDictionary(x => x.Id);
+
+ foreach (var instance in expiring)
+ {
+ templateMap.TryGetValue(instance.TemplateId, out var template);
+ var name = template?.TemplateName ?? "优惠券";
+ await _noticeRepository.InsertAsync(new Notice
+ {
+ NoticeType = "message",
+ Title = "优惠券即将到期",
+ Content = $"您的「{name}」将于 {instance.ValidEndTime:yyyy-MM-dd HH:mm} 过期,请尽快下单使用。",
+ IsPopup = 0,
+ ReceiverType = "customer",
+ ReceiverIds = JsonSerializer.Serialize(new[] { instance.CustomerId })
+ }, cancellationToken);
+ instance.ExpiryNoticeSent = 1;
+ await _instanceRepository.UpdateAsync(instance, cancellationToken);
+ }
+ }
+
public async Task ResolveCouponDiscountInputAsync(
int? couponInstanceId,
int mainCustomerId,
+ string? destinationCountry = null,
CancellationToken cancellationToken = default)
{
if (!couponInstanceId.HasValue)
@@ -177,6 +327,10 @@ public class MarketingCouponManager : IScopedDependency
EnsureUsable(instance);
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken)
?? throw new BusinessException("券模板不存在");
+ if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry))
+ {
+ throw new BusinessException("该优惠券不适用于当前线路");
+ }
return new CouponDiscountInput
{
@@ -185,7 +339,8 @@ public class MarketingCouponManager : IScopedDependency
MinOrderAmount = template.MinOrderAmount,
FaceValue = template.FaceValue,
DiscountRate = template.DiscountRate,
- MaxDiscountAmount = template.MaxDiscountAmount
+ MaxDiscountAmount = template.MaxDiscountAmount,
+ ApplyScopeJson = template.ApplyScopeJson
};
}
@@ -198,7 +353,8 @@ public class MarketingCouponManager : IScopedDependency
public async Task> BuildAvailableCouponsAsync(
int mainCustomerId,
- decimal? amountAfterLevel,
+ decimal? baseFreightAmount,
+ string? destinationCountry = null,
CancellationToken cancellationToken = default)
{
var instances = await _instanceRepository.GetAvailableByCustomerAsync(mainCustomerId, cancellationToken);
@@ -220,12 +376,17 @@ public class MarketingCouponManager : IScopedDependency
string? unavailableReason = null;
decimal? estimatedDiscount = null;
- if (amountAfterLevel.HasValue)
+ if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry))
{
- if (amountAfterLevel.Value < template.MinOrderAmount)
+ canUse = false;
+ unavailableReason = "不适用于当前线路";
+ }
+ else if (baseFreightAmount.HasValue)
+ {
+ if (baseFreightAmount.Value < template.MinOrderAmount)
{
canUse = false;
- unavailableReason = $"未满 {template.MinOrderAmount:0.##} 元门槛";
+ unavailableReason = $"实付基础运费未满 {template.MinOrderAmount:0.##} 元";
}
else
{
@@ -236,15 +397,16 @@ public class MarketingCouponManager : IScopedDependency
MinOrderAmount = template.MinOrderAmount,
FaceValue = template.FaceValue,
DiscountRate = template.DiscountRate,
- MaxDiscountAmount = template.MaxDiscountAmount
+ MaxDiscountAmount = template.MaxDiscountAmount,
+ ApplyScopeJson = template.ApplyScopeJson
};
estimatedDiscount = MarketingBillingCalculator.CalculateCouponDiscount(
discountInput,
- amountAfterLevel.Value);
+ baseFreightAmount.Value);
if (estimatedDiscount <= 0)
{
canUse = false;
- unavailableReason = "当前金额不可用";
+ unavailableReason = "当前基础运费不可用";
}
}
}
@@ -267,6 +429,35 @@ public class MarketingCouponManager : IScopedDependency
}).ToList();
}
+ private async Task UnlockPendingCouponsAsync(int mainCustomerId, CancellationToken cancellationToken)
+ {
+ var pending = await _instanceRepository.GetPendingUnlockByCustomerAsync(mainCustomerId, cancellationToken);
+ if (pending.Count == 0)
+ {
+ return;
+ }
+
+ var templateIds = pending.Select(x => x.TemplateId).Distinct().ToArray();
+ var templates = await _templateRepository.GetListAsync(x => templateIds.Contains(x.Id));
+ var templateMap = templates.ToDictionary(x => x.Id);
+ var now = DateTime.Now;
+ foreach (var instance in pending)
+ {
+ templateMap.TryGetValue(instance.TemplateId, out var template);
+ var days = template?.ValidDaysAfterReceive ?? 30;
+ instance.Status = CouponInstanceStatus.Available;
+ instance.ValidStartTime = now;
+ instance.ValidEndTime = now.AddDays(days);
+ await _instanceRepository.UpdateAsync(instance, cancellationToken);
+ }
+ }
+
+ private async Task ResolveFamilyCustomerIdsAsync(int mainCustomerId, CancellationToken cancellationToken)
+ {
+ var children = await _customerRepository.GetListAsync(x => x.ParentCustomerId == mainCustomerId);
+ return children.Select(x => x.Id).Append(mainCustomerId).ToArray();
+ }
+
private async Task GetOwnedInstanceAsync(int id, int mainCustomerId, CancellationToken cancellationToken)
{
var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == id, cancellationToken)
@@ -282,6 +473,11 @@ public class MarketingCouponManager : IScopedDependency
private static void EnsureUsable(CouponInstance instance)
{
var now = DateTime.Now;
+ if (instance.Status == CouponInstanceStatus.PendingUnlock)
+ {
+ throw new BusinessException("优惠券未解锁,完成首单派送后可用");
+ }
+
if (instance.Status == CouponInstanceStatus.Used)
{
throw new BusinessException("优惠券已使用");
@@ -305,9 +501,24 @@ public class MarketingCouponManager : IScopedDependency
}
}
+ private static bool RequiresUnlock(CouponTemplate template)
+ {
+ if (template.ValidType == CouponValidType.DaysAfterUnlock)
+ {
+ return true;
+ }
+
+ return string.Equals(
+ template.TemplateCode,
+ MarketingCouponTemplateCodes.NewcomerSecond,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
private static (DateTime Start, DateTime End) ResolveValidRange(CouponTemplate template)
{
- if (template.ValidType == CouponValidType.DaysAfterReceive && template.ValidDaysAfterReceive.HasValue)
+ if ((template.ValidType == CouponValidType.DaysAfterReceive
+ || template.ValidType == CouponValidType.DaysAfterUnlock)
+ && template.ValidDaysAfterReceive.HasValue)
{
var start = DateTime.Now;
return (start, start.AddDays(template.ValidDaysAfterReceive.Value));
diff --git a/src/Oms.Application/Shared/MarketingCouponScope.cs b/src/Oms.Application/Shared/MarketingCouponScope.cs
new file mode 100644
index 0000000..b26e4fe
--- /dev/null
+++ b/src/Oms.Application/Shared/MarketingCouponScope.cs
@@ -0,0 +1,52 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Oms.Application.Shared;
+
+public class CouponApplyScope
+{
+ [JsonPropertyName("countryCodes")]
+ public List