feat(order): 规则快照下游元数据、订单绑定字段与系统其他配置

排货发布 enrich Providers 并确认渠道时写入 t_order 下游四字段;接入 itfx 元数据客户端;系统其他配置与小包轨迹 DB 配置;手动轨迹 UpsertSmallBag 接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-29 11:31:15 +08:00
parent fe30d071e6
commit 545f0825f8
37 changed files with 1349 additions and 43 deletions

26
db.sql
View File

@ -10,6 +10,7 @@
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `t_system_other_config`;
DROP TABLE IF EXISTS `t_warehouse_address`;
DROP TABLE IF EXISTS `t_notice_read`;
DROP TABLE IF EXISTS `t_notice`;
@ -58,6 +59,10 @@ CREATE TABLE `t_order` (
`TrackNo` VARCHAR(64) NULL COMMENT '跟踪号',
`ProductCode` VARCHAR(128) NULL COMMENT '产品编码',
`ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码',
`DownstreamSupplierCode` VARCHAR(64) NULL COMMENT '下游 API 服务商编码 supplierCode',
`DownstreamAppKey` VARCHAR(128) NULL COMMENT '下游 itfx 应用 AppKey',
`DownstreamAppId` INT NULL COMMENT '下游 itfx 应用 Id',
`ChannelForecastCode` VARCHAR(128) NULL COMMENT '渠道预报编码 ForecastCode',
`ConsigneeCountry` VARCHAR(8) NULL COMMENT '国家二字码',
`ConsigneeCountryName` VARCHAR(64) NULL COMMENT '国家中文名称',
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
@ -828,6 +833,27 @@ CREATE TABLE `t_dict` (
KEY `idx_dict_type_sort` (`DictType`, `DictSort`, `Id`)
) COMMENT='系统字典表';
CREATE TABLE `t_system_other_config` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`ConfigCode` VARCHAR(64) NOT NULL COMMENT '配置编码,如 track_service',
`ConfigName` VARCHAR(128) NOT NULL COMMENT '配置名称',
`Enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用:0-否,1-是',
`BaseUrl` VARCHAR(512) NULL COMMENT '服务地址',
`AppKey` VARCHAR(128) NULL COMMENT 'AppKey',
`AppSecret` VARCHAR(256) NULL COMMENT 'AppSecret',
`TimeoutSeconds` INT NOT NULL DEFAULT 30 COMMENT '超时秒数',
`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_system_other_config_code` (`ConfigCode`),
KEY `idx_system_other_config_enabled` (`Enabled`, `Id`)
) COMMENT='系统其他配置表';
CREATE TABLE `t_notice` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`NoticeType` VARCHAR(16) NOT NULL COMMENT '通知类型:notice/message',

View File

@ -0,0 +1,77 @@
# 单独调试 POST /OrderV2/UpsertSmallBag(与 SmallBagTrackOrderClient 一致)
param(
[string]$BaseUrl = "http://api.nanafa.com/track",
[string]$AppKey = "W2606001",
[string]$AppSecret = "ziK8UzlQ39m3kdL346XEBg8nljY8os52",
[string]$OrderNo = "",
[string]$Client = "TEST001"
)
function Get-SmallBagSign {
param([string]$Key, [long]$Ts, [string]$Secret)
$signString = "appKey=$Key&timestamp=$Ts&appSecret=$Secret"
$md5 = [System.Security.Cryptography.MD5]::Create()
$bytes = [System.Text.Encoding]::UTF8.GetBytes($signString)
$hash = $md5.ComputeHash($bytes)
-join ($hash | ForEach-Object { $_.ToString("X2") })
}
if ([string]::IsNullOrWhiteSpace($OrderNo)) {
$OrderNo = "LP-DEBUG-" + (Get-Date -Format "yyyyMMddHHmmss")
}
$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$sign = Get-SmallBagSign -Key $AppKey -Ts $timestamp -Secret $AppSecret
$body = @{
appKey = $AppKey
timestamp = $timestamp
sign = $sign
client = $Client
orderNo = $OrderNo
trackNo = ""
otherNo = "DEBUG-CLIENT-001"
channel = "test01"
product = "test01"
apiCode = ""
consignee = @{
countryCode = "US"
province = "California"
postcode = "90001"
city = "Los Angeles"
email = "debug@example.com"
}
} | ConvertTo-Json -Depth 5 -Compress
$url = ($BaseUrl.TrimEnd('/')) + "/OrderV2/UpsertSmallBag"
Write-Host "========== UpsertSmallBag Debug ==========" -ForegroundColor Cyan
Write-Host "URL: $url"
Write-Host "Timestamp: $timestamp"
Write-Host "Sign: $sign"
Write-Host "Body:"
Write-Host ($body | ConvertFrom-Json | ConvertTo-Json -Depth 5)
Write-Host "========================================" -ForegroundColor Cyan
try {
$response = Invoke-WebRequest -Uri $url -Method POST `
-ContentType "application/json; charset=utf-8" `
-Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
-UseBasicParsing
Write-Host "HTTP $($response.StatusCode)" -ForegroundColor Green
Write-Host $response.Content
}
catch {
if ($_.Exception.Response) {
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$errBody = $reader.ReadToEnd()
Write-Host "HTTP $([int]$_.Exception.Response.StatusCode)" -ForegroundColor Red
if ($errBody) { Write-Host $errBody } else { Write-Host "(empty body — 多为签名/鉴权失败,请核对 appKey/appSecret 或联系轨迹平台)" }
}
else {
Write-Host $_.Exception.Message -ForegroundColor Red
}
Write-Host ""
Write-Host "Tip: start OMS and POST /order/debug/track-upsert-small-bag with optional body fields." -ForegroundColor Yellow
exit 1
}

View File

@ -0,0 +1,6 @@
-- 订单下游对接字段(排货确认渠道时固化,供 itfx 预报/轨迹使用)
ALTER TABLE `t_order`
ADD COLUMN `DownstreamSupplierCode` VARCHAR(64) NULL COMMENT '下游 API 服务商编码 supplierCode' AFTER `ChannelCode`,
ADD COLUMN `DownstreamAppKey` VARCHAR(128) NULL COMMENT '下游 itfx 应用 AppKey' AFTER `DownstreamSupplierCode`,
ADD COLUMN `DownstreamAppId` INT NULL COMMENT '下游 itfx 应用 Id' AFTER `DownstreamAppKey`,
ADD COLUMN `ChannelForecastCode` VARCHAR(128) NULL COMMENT '渠道预报编码 ForecastCode' AFTER `DownstreamAppId`;

View File

@ -0,0 +1,21 @@
-- 系统其他配置表(含 OMS 小包轨迹服务配置)
CREATE TABLE IF NOT EXISTS `t_system_other_config` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`ConfigCode` VARCHAR(64) NOT NULL COMMENT '配置编码,如 track_service',
`ConfigName` VARCHAR(128) NOT NULL COMMENT '配置名称',
`Enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用:0-否,1-是',
`BaseUrl` VARCHAR(512) NULL COMMENT '服务地址',
`AppKey` VARCHAR(128) NULL COMMENT 'AppKey',
`AppSecret` VARCHAR(256) NULL COMMENT 'AppSecret',
`TimeoutSeconds` INT NOT NULL DEFAULT 30 COMMENT '超时秒数',
`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_system_other_config_code` (`ConfigCode`),
KEY `idx_system_other_config_enabled` (`Enabled`, `Id`)
) COMMENT='系统其他配置表';

View File

@ -44,4 +44,39 @@ public class SalesProductAllocationProviderSnapshotDto
/// 指定客户主键集合,空表示全部。
/// </summary>
public List<int> CustomerIds { get; set; } = [];
/// <summary>
/// 服务渠道编码(发布快照固化)。
/// </summary>
public string? ChannelCode { get; set; }
/// <summary>
/// 渠道预报编码 ForecastCode(发布快照固化)。
/// </summary>
public string? ForecastCode { get; set; }
/// <summary>
/// 服务商分组 Id。
/// </summary>
public int? GroupId { get; set; }
/// <summary>
/// itfx API 服务商编码 supplierCode。
/// </summary>
public string? SupplierCode { get; set; }
/// <summary>
/// itfx 应用 AppKey(AppSecret 不落快照,运行时按 AppId 读取)。
/// </summary>
public string? AppKey { get; set; }
/// <summary>
/// itfx 应用 Id。
/// </summary>
public int? AppId { get; set; }
/// <summary>
/// itfx API 服务商 Id。
/// </summary>
public int? ApiServiceProviderId { get; set; }
}

View File

@ -40,6 +40,26 @@ public class OrderOutput
/// </summary>
public string? ChannelCode { get; set; }
/// <summary>
/// 下游 API 服务商编码 supplierCode。
/// </summary>
public string? DownstreamSupplierCode { get; set; }
/// <summary>
/// 下游 itfx 应用 AppKey。
/// </summary>
public string? DownstreamAppKey { get; set; }
/// <summary>
/// 下游 itfx 应用 Id(用于运行时读取 AppSecret)。
/// </summary>
public int? DownstreamAppId { get; set; }
/// <summary>
/// 渠道预报编码 ForecastCode。
/// </summary>
public string? ChannelForecastCode { get; set; }
/// <summary>
/// 服务渠道名称。
/// </summary>
@ -75,6 +95,31 @@ public class OrderOutput
/// </summary>
public int? CustomerId { get; set; }
/// <summary>
/// 幂等请求标识。
/// </summary>
public string? RequestId { get; set; }
/// <summary>
/// 出口报关方式。
/// </summary>
public string? ExportCustomsMode { get; set; }
/// <summary>
/// 申报币种。
/// </summary>
public string? DeclaredCurrency { get; set; }
/// <summary>
/// 申报总金额。
/// </summary>
public decimal? DeclaredSum { get; set; }
/// <summary>
/// 包裹数量。
/// </summary>
public int? Quantity { get; set; }
/// <summary>
/// 是否 COD。
/// </summary>

View File

@ -0,0 +1,31 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 调试 UpsertSmallBag 入参(字段均可选,缺省使用示例值)。
/// </summary>
public class OrderTrackUpsertDebugInput
{
public string? Client { get; set; }
public string? OrderNo { get; set; }
public string? TrackNo { get; set; }
public string? OtherNo { get; set; }
public string? Channel { get; set; }
public string? Product { get; set; }
public string? ApiCode { get; set; }
public string? CountryCode { get; set; }
public string? Province { get; set; }
public string? Postcode { get; set; }
public string? City { get; set; }
public string? Email { get; set; }
}

View File

@ -0,0 +1,47 @@
namespace Oms.Application.Dtos.System;
/// <summary>
/// 系统其他配置新增入参。
/// </summary>
public class SystemOtherConfigCreateInput
{
/// <summary>
/// 配置编码。
/// </summary>
public string ConfigCode { get; set; } = string.Empty;
/// <summary>
/// 配置名称。
/// </summary>
public string ConfigName { get; set; } = string.Empty;
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 服务地址。
/// </summary>
public string? BaseUrl { get; set; }
/// <summary>
/// AppKey。
/// </summary>
public string? AppKey { get; set; }
/// <summary>
/// AppSecret。
/// </summary>
public string? AppSecret { get; set; }
/// <summary>
/// 超时秒数。
/// </summary>
public int TimeoutSeconds { get; set; } = 30;
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; set; }
}

View File

@ -0,0 +1,22 @@
namespace Oms.Application.Dtos.System;
/// <summary>
/// 系统其他配置列表查询入参。
/// </summary>
public class SystemOtherConfigListInput
{
/// <summary>
/// 页码,从 1 开始。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 30;
/// <summary>
/// 关键词,匹配配置编码或名称。
/// </summary>
public string? Keyword { get; set; }
}

View File

@ -0,0 +1,62 @@
namespace Oms.Application.Dtos.System;
/// <summary>
/// 系统其他配置列表项出参。
/// </summary>
public class SystemOtherConfigOutput
{
/// <summary>
/// 主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 配置编码。
/// </summary>
public string ConfigCode { get; set; } = string.Empty;
/// <summary>
/// 配置名称。
/// </summary>
public string ConfigName { get; set; } = string.Empty;
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 服务地址。
/// </summary>
public string? BaseUrl { get; set; }
/// <summary>
/// AppKey。
/// </summary>
public string? AppKey { get; set; }
/// <summary>
/// AppSecret(列表已脱敏)。
/// </summary>
public string? AppSecret { get; set; }
/// <summary>
/// 超时秒数。
/// </summary>
public int TimeoutSeconds { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; set; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdateTime { get; set; }
}

View File

@ -0,0 +1,12 @@
namespace Oms.Application.Dtos.System;
/// <summary>
/// 系统其他配置更新入参。
/// </summary>
public class SystemOtherConfigUpdateInput : SystemOtherConfigCreateInput
{
/// <summary>
/// 主键。
/// </summary>
public int Id { get; set; }
}

View File

@ -0,0 +1,20 @@
namespace Oms.Application.Integrations.Itfx;
public sealed class ItfxAppMetadata
{
public string AppKey { get; set; } = string.Empty;
}
public sealed class ItfxServiceProviderMetadata
{
public string Code { get; set; } = string.Empty;
}
public interface IItfxMetadataClient
{
Task<ItfxAppMetadata?> GetAppAsync(int appId, CancellationToken cancellationToken = default);
Task<ItfxServiceProviderMetadata?> GetServiceProviderAsync(
int serviceProviderId,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,16 @@
namespace Oms.Application.Integrations.Itfx;
/// <summary>
/// itfx 网关配置。
/// </summary>
public class ItfxGatewayOptions
{
public const string SectionName = "Oms:Itfx";
/// <summary>
/// itfx 服务根地址,如 http://api.nanafa.com/itfx。
/// </summary>
public string BaseUrl { get; set; } = "http://api.nanafa.com/itfx";
public int TimeoutSeconds { get; set; } = 30;
}

View File

@ -0,0 +1,135 @@
using System.Net.Http.Headers;
using System.Text.Json;
using Fake.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Oms.Application.Integrations.Itfx;
internal sealed class ItfxMetadataClient(
IHttpClientFactory httpClientFactory,
IOptions<ItfxGatewayOptions> options,
IHttpContextAccessor httpContextAccessor,
ILogger<ItfxMetadataClient> logger) : IItfxMetadataClient, IScopedDependency
{
public const string HttpClientName = nameof(ItfxMetadataClient);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public Task<ItfxAppMetadata?> GetAppAsync(int appId, CancellationToken cancellationToken = default) =>
GetAsync<ItfxAppMetadata>($"/App/GetInfo?id={appId}", payload =>
{
var appKey = ReadString(payload, "appKey", "AppKey");
return string.IsNullOrWhiteSpace(appKey)
? null
: new ItfxAppMetadata { AppKey = appKey.Trim() };
}, cancellationToken);
public Task<ItfxServiceProviderMetadata?> GetServiceProviderAsync(
int serviceProviderId,
CancellationToken cancellationToken = default) =>
GetAsync<ItfxServiceProviderMetadata>(
$"/ServiceProvider/GetServiceProvider?serviceProviderId={serviceProviderId}",
payload =>
{
var code = ReadString(payload, "code", "Code", "serviceProviderCode", "ServiceProviderCode");
return string.IsNullOrWhiteSpace(code)
? null
: new ItfxServiceProviderMetadata { Code = code.Trim() };
},
cancellationToken);
private async Task<T?> GetAsync<T>(
string relativeUrl,
Func<JsonElement, T?> map,
CancellationToken cancellationToken)
where T : class
{
var settings = options.Value;
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
{
logger.LogDebug("Itfx BaseUrl not configured, skip {Url}", relativeUrl);
return null;
}
var client = httpClientFactory.CreateClient(HttpClientName);
client.Timeout = TimeSpan.FromSeconds(Math.Max(5, settings.TimeoutSeconds));
ForwardAuthorization(client);
var url = $"{settings.BaseUrl.Trim().TrimEnd('/')}{relativeUrl}";
try
{
using var response = await client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("Itfx metadata request failed {Url}: {StatusCode}", url, (int)response.StatusCode);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
var payload = UnwrapPayload(document.RootElement);
return payload.HasValue ? map(payload.Value) : null;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Itfx metadata request error {Url}", url);
return null;
}
}
private void ForwardAuthorization(HttpClient client)
{
var authorization = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
if (!string.IsNullOrWhiteSpace(authorization))
{
client.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(authorization);
}
}
private static JsonElement? UnwrapPayload(JsonElement root)
{
if (root.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return null;
}
if (root.TryGetProperty("data", out var data) && data.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
{
return data;
}
if (root.TryGetProperty("Data", out var dataPascal) && dataPascal.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
{
return dataPascal;
}
return root;
}
private static string? ReadString(JsonElement payload, params string[] names)
{
foreach (var name in names)
{
if (payload.TryGetProperty(name, out var property))
{
var value = property.ValueKind switch
{
JsonValueKind.String => property.GetString(),
JsonValueKind.Number => property.GetRawText(),
_ => null
};
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
}
return null;
}
}

View File

@ -0,0 +1,15 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Oms.Application.Integrations.Itfx;
public static class ItfxServiceExtensions
{
public static IServiceCollection AddOmsItfxIntegration(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<ItfxGatewayOptions>(configuration.GetSection(ItfxGatewayOptions.SectionName));
services.AddHttpClient(ItfxMetadataClient.HttpClientName);
services.AddScoped<IItfxMetadataClient, ItfxMetadataClient>();
return services;
}
}

View File

@ -3,4 +3,9 @@ namespace Oms.Application.Integrations.SmallBagTrack;
public interface ISmallBagTrackOrderClient
{
Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 同步调用并返回完整请求/响应(仅调试)。
/// </summary>
Task<SmallBagTrackUpsertResult> UpsertDebugAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,41 @@
using Fake.DependencyInjection;
using Microsoft.Extensions.Options;
using Oms.Domain.SystemAggregate.Consts;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Integrations.SmallBagTrack;
/// <summary>
/// 小包轨迹运行时配置读取。
/// </summary>
public interface ISmallBagTrackConfigProvider
{
Task<SmallBagTrackOptions> GetAsync(CancellationToken cancellationToken = default);
}
internal sealed class SmallBagTrackConfigProvider(
ISystemOtherConfigRepository repository,
IOptions<SmallBagTrackOptions> options) : ISmallBagTrackConfigProvider, IScopedDependency
{
public async Task<SmallBagTrackOptions> GetAsync(CancellationToken cancellationToken = default)
{
var fallback = options.Value;
var entity = await repository.GetByConfigCodeAsync(SystemOtherConfigCodes.TrackService, cancellationToken);
if (entity == null)
{
return fallback;
}
return new SmallBagTrackOptions
{
Enabled = entity.Enabled,
BaseUrl = Coalesce(entity.BaseUrl, fallback.BaseUrl),
AppKey = Coalesce(entity.AppKey, fallback.AppKey),
AppSecret = Coalesce(entity.AppSecret, fallback.AppSecret),
TimeoutSeconds = entity.TimeoutSeconds > 0 ? entity.TimeoutSeconds : fallback.TimeoutSeconds
};
}
private static string Coalesce(string? primary, string fallback) =>
string.IsNullOrWhiteSpace(primary) ? fallback : primary.Trim();
}

View File

@ -17,6 +17,16 @@ public class SmallBagTrackOptions
/// </summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>
/// 应用唯一标识(appKey)。
/// </summary>
public string AppKey { get; set; } = string.Empty;
/// <summary>
/// 应用密钥(appSecret),用于签名,勿泄露。
/// </summary>
public string AppSecret { get; set; } = string.Empty;
/// <summary>
/// HTTP 超时(秒)。
/// </summary>

View File

@ -1,41 +1,95 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Oms.Application.Integrations.SmallBagTrack;
internal sealed class SmallBagTrackOrderClient(
IHttpClientFactory httpClientFactory,
IOptions<SmallBagTrackOptions> options,
ISmallBagTrackConfigProvider configProvider,
ILogger<SmallBagTrackOrderClient> logger) : ISmallBagTrackOrderClient
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public const string HttpClientName = nameof(SmallBagTrackOrderClient);
public async Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default)
{
var settings = options.Value;
if (!settings.Enabled || string.IsNullOrWhiteSpace(settings.BaseUrl))
var result = await UpsertDebugAsync(request, cancellationToken);
if (result.Skipped)
{
logger.LogDebug("UpsertSmallBag skipped: TrackService disabled or BaseUrl empty");
if (!string.IsNullOrWhiteSpace(result.SkipReason))
{
logger.LogDebug("UpsertSmallBag skipped for order {OrderNo}: {Reason}", request.OrderNo, result.SkipReason);
}
return;
}
var url = $"{settings.BaseUrl.Trim().TrimEnd('/')}/OrderV2/UpsertSmallBag";
var client = httpClientFactory.CreateClient(HttpClientName);
client.Timeout = TimeSpan.FromSeconds(Math.Max(5, settings.TimeoutSeconds));
using var response = await client.PostAsJsonAsync(url, request, cancellationToken);
if (!response.IsSuccessStatusCode)
if (!result.Success)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
logger.LogWarning(
"UpsertSmallBag failed for order {OrderNo}: {StatusCode} {Body}",
request.OrderNo,
(int)response.StatusCode,
body);
result.StatusCode,
result.ResponseBody);
return;
}
logger.LogInformation("UpsertSmallBag succeeded for order {OrderNo}", request.OrderNo);
}
public async Task<SmallBagTrackUpsertResult> UpsertDebugAsync(
SmallBagTrackUpsertRequest request,
CancellationToken cancellationToken = default)
{
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 timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var sign = SmallBagTrackSignHelper.GenerateSign(appKey, timestamp, settings.AppSecret.Trim());
var payload = SmallBagTrackUpsertApiRequest.FromBusinessRequest(request, appKey, timestamp, sign);
var url = $"{settings.BaseUrl.Trim().TrimEnd('/')}/OrderV2/UpsertSmallBag";
var requestJson = JsonSerializer.Serialize(payload, JsonOptions);
var client = httpClientFactory.CreateClient(HttpClientName);
client.Timeout = TimeSpan.FromSeconds(Math.Max(5, settings.TimeoutSeconds));
using var response = await client.PostAsJsonAsync(url, payload, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
return new SmallBagTrackUpsertResult
{
Skipped = false,
Success = response.IsSuccessStatusCode,
Url = url,
RequestJson = requestJson,
StatusCode = (int)response.StatusCode,
ResponseBody = body
};
}
private static SmallBagTrackUpsertResult Skipped(string reason) =>
new()
{
Skipped = true,
SkipReason = reason
};
}

View File

@ -9,7 +9,8 @@ public static class SmallBagTrackServiceExtensions
{
services.Configure<SmallBagTrackOptions>(configuration.GetSection(SmallBagTrackOptions.SectionName));
services.AddHttpClient(SmallBagTrackOrderClient.HttpClientName);
services.AddSingleton<ISmallBagTrackOrderClient, SmallBagTrackOrderClient>();
services.AddScoped<ISmallBagTrackConfigProvider, SmallBagTrackConfigProvider>();
services.AddScoped<ISmallBagTrackOrderClient, SmallBagTrackOrderClient>();
services.AddSingleton<ISmallBagTrackOrderNotifier, SmallBagTrackOrderNotifier>();
return services;
}

View File

@ -0,0 +1,29 @@
using System.Security.Cryptography;
using System.Text;
namespace Oms.Application.Integrations.SmallBagTrack;
/// <summary>
/// 轨迹服务 API 签名:appKey={}&amp;timestamp={}&amp;appSecret={} → MD5 大写。
/// </summary>
internal static class SmallBagTrackSignHelper
{
public static string GenerateSign(string appKey, long timestamp, string appSecret)
{
var signString = $"appKey={appKey}&timestamp={timestamp}&appSecret={appSecret}";
return CalculateMd5Hash(signString);
}
private static string CalculateMd5Hash(string input)
{
var inputBytes = Encoding.UTF8.GetBytes(input);
var hashBytes = MD5.HashData(inputBytes);
var sb = new StringBuilder(hashBytes.Length * 2);
foreach (var b in hashBytes)
{
sb.Append(b.ToString("X2"));
}
return sb.ToString();
}
}

View File

@ -0,0 +1,49 @@
namespace Oms.Application.Integrations.SmallBagTrack;
/// <summary>
/// POST /OrderV2/UpsertSmallBag 完整请求体(含公共参数 + 业务字段)。
/// </summary>
internal sealed class SmallBagTrackUpsertApiRequest
{
public string AppKey { get; set; } = string.Empty;
public long Timestamp { get; set; }
public string Sign { get; set; } = string.Empty;
public string Client { get; set; } = string.Empty;
public string OrderNo { get; set; } = string.Empty;
public string TrackNo { 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 ApiCode { get; set; } = string.Empty;
public SmallBagTrackConsigneeRequest Consignee { get; set; } = new();
public static SmallBagTrackUpsertApiRequest FromBusinessRequest(
SmallBagTrackUpsertRequest request,
string appKey,
long timestamp,
string sign) =>
new()
{
AppKey = appKey,
Timestamp = timestamp,
Sign = sign,
Client = request.Client,
OrderNo = request.OrderNo,
TrackNo = request.TrackNo,
OtherNo = request.OtherNo,
Channel = request.Channel,
Product = request.Product,
ApiCode = request.ApiCode,
Consignee = request.Consignee
};
}

View File

@ -0,0 +1,21 @@
namespace Oms.Application.Integrations.SmallBagTrack;
/// <summary>
/// UpsertSmallBag 调用结果(调试用)。
/// </summary>
public class SmallBagTrackUpsertResult
{
public bool Skipped { get; set; }
public string? SkipReason { get; set; }
public bool Success { get; set; }
public string Url { get; set; } = string.Empty;
public string RequestJson { get; set; } = string.Empty;
public int? StatusCode { get; set; }
public string ResponseBody { get; set; } = string.Empty;
}

View File

@ -3,6 +3,7 @@ using Fake.ObjectMapping.AutoMapper;
using Fake.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Integrations.Itfx;
using Oms.Domain;
namespace Oms.Application;
@ -24,5 +25,6 @@ public class OmsApplicationModule : FakeModule
options.ScanProfiles<OmsApplicationModule>();
});
context.Services.AddOmsSmallBagTrack(context.Services.GetConfiguration());
context.Services.AddOmsItfxIntegration(context.Services.GetConfiguration());
}
}

View File

@ -175,6 +175,7 @@ public partial class SalesProductService
KpiAreaRepository,
WeightRuleRepository,
WeightRoundingRepository);
await AllocationProviderSnapshotEnricher.EnrichAsync(rulesSnapshot.Providers);
return rulesSnapshot;
}

View File

@ -23,6 +23,8 @@ public partial class SalesProductService : ApplicationService
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
/// <summary>
/// 查询销售产品列表。

View File

@ -93,6 +93,10 @@ public partial class OrderService
? product.ProductName
: null,
ChannelCode = order.ChannelCode,
DownstreamSupplierCode = order.DownstreamSupplierCode,
DownstreamAppKey = order.DownstreamAppKey,
DownstreamAppId = order.DownstreamAppId,
ChannelForecastCode = order.ChannelForecastCode,
ChannelName = !string.IsNullOrWhiteSpace(order.ChannelCode)
&& channelMap.TryGetValue(order.ChannelCode.Trim(), out var channel)
? channel.ChannelName
@ -105,6 +109,11 @@ public partial class OrderService
? warehouse.WarehouseName
: null,
CustomerId = order.CustomerId,
RequestId = order.RequestId,
ExportCustomsMode = order.ExportCustomsMode,
DeclaredCurrency = order.DeclaredCurrency,
DeclaredSum = order.DeclaredSum,
Quantity = order.Quantity,
IsCod = order.IsCod > 0,
CodCurrency = order.CodCurrency,
CodSum = order.CodSum,

View File

@ -1,4 +1,5 @@
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.SmallBagTrack;
@ -28,6 +29,8 @@ public partial class OrderService
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(OrderParcelCreateInput input)
{
@ -471,6 +474,7 @@ public partial class OrderService
}
var selectedChannel = AllocationResolver.SelectChannel(scheme, candidates);
var selectedCandidate = candidates.First(x => x.Channel.Id == selectedChannel.Id);
var channelPriceBindingFinal = await ResolveChannelPriceBindingAsync(selectedChannel.Id, bindTime);
var payableFees = ParcelBillingCalculator.CalculatePayableFees(
channelPriceBindingFinal.Prices,
@ -489,6 +493,10 @@ public partial class OrderService
}
order.ChannelCode = selectedChannel.ChannelCode;
AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder(
order,
selectedCandidate.Provider,
selectedChannel);
if (payableFees.Count > 0)
{
await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage);
@ -500,29 +508,39 @@ public partial class OrderService
SalesProduct product,
SalesProductRulesPublishSnapshotDto rulesSnapshot)
{
List<SalesProductAllocationProviderSnapshotDto> providers;
if (rulesSnapshot.Providers.Count > 0)
{
return rulesSnapshot.Providers;
providers = rulesSnapshot.Providers;
if (providers.Any(AllocationProviderSnapshotEnricher.NeedsDownstreamEnrichment))
{
await AllocationProviderSnapshotEnricher.EnrichAsync(providers);
}
}
else
{
var entities = await SalesProductRepository.GetAllocationProvidersAsync(product.Id);
var channelIds = entities.Where(x => x.ServiceChannelId.HasValue).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray();
var channels = channelIds.Length == 0
? []
: await ServiceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id));
var channelMap = channels.ToDictionary(x => x.Id, x => x.ChannelName);
providers = entities.Select(provider => PublishSnapshotSlimmer.SlimAllocationProvider(new SalesProductAllocationProviderOutput
{
Id = provider.Id,
SalesProductId = provider.SalesProductId,
SerialNumber = provider.SerialNumber,
ProviderName = provider.ProviderName,
ServiceChannelId = provider.ServiceChannelId,
ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null,
Weight = provider.Weight,
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? []
})).ToList();
await AllocationProviderSnapshotEnricher.EnrichAsync(providers);
}
var entities = await SalesProductRepository.GetAllocationProvidersAsync(product.Id);
var channelIds = entities.Where(x => x.ServiceChannelId.HasValue).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray();
var channels = channelIds.Length == 0
? []
: await ServiceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id));
var channelMap = channels.ToDictionary(x => x.Id, x => x.ChannelName);
return entities.Select(provider => PublishSnapshotSlimmer.SlimAllocationProvider(new SalesProductAllocationProviderOutput
{
Id = provider.Id,
SalesProductId = provider.SalesProductId,
SerialNumber = provider.SerialNumber,
ProviderName = provider.ProviderName,
ServiceChannelId = provider.ServiceChannelId,
ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null,
Weight = provider.Weight,
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? []
})).ToList();
return providers;
}
private sealed class ChannelPriceBinding
@ -1425,14 +1443,47 @@ public partial class OrderService
Customer? customer,
OrderParcelReceiverInput? receiver)
{
var apiCode = string.Empty;
if (!string.IsNullOrWhiteSpace(order.ChannelCode))
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver);
SmallBagTrackOrderNotifier.Enqueue(request);
}
/// <summary>
/// 按订单同步轨迹(UpsertSmallBag),返回完整请求与响应便于联调。
/// </summary>
[HttpPost("/order/track-upsert-small-bag")]
public async Task<SmallBagTrackUpsertResult> TrackUpsertSmallBagAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
if (string.IsNullOrWhiteSpace(order.ServiceNo))
{
throw new BusinessException("订单运单号为空,无法同步轨迹");
}
var request = await BuildSmallBagTrackUpsertRequestAsync(order);
return await SmallBagTrackOrderClient.UpsertDebugAsync(request);
}
private async Task<SmallBagTrackUpsertRequest> BuildSmallBagTrackUpsertRequestAsync(
Order order,
OrderParcelReceiverInput? receiverInput = null)
{
Customer? customer = null;
if (order.CustomerId.HasValue)
{
customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
}
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
var recipient = recipients.FirstOrDefault();
var apiCode = order.ChannelForecastCode?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(apiCode) && !string.IsNullOrWhiteSpace(order.ChannelCode))
{
var channel = await ServiceChannelRepository.GetByChannelCodeAsync(order.ChannelCode.Trim());
apiCode = channel?.ForecastCode?.Trim() ?? string.Empty;
}
var request = new SmallBagTrackUpsertRequest
return new SmallBagTrackUpsertRequest
{
Client = customer?.CustomerCode?.Trim() ?? string.Empty,
OrderNo = order.ServiceNo,
@ -1443,15 +1494,46 @@ public partial class OrderService
ApiCode = apiCode,
Consignee = new SmallBagTrackConsigneeRequest
{
CountryCode = receiver?.CountryCode?.Trim() ?? order.ConsigneeCountry?.Trim() ?? string.Empty,
Province = receiver?.Province?.Trim() ?? string.Empty,
Postcode = receiver?.ZipCode?.Trim() ?? string.Empty,
City = receiver?.City?.Trim() ?? string.Empty,
Email = receiver?.Email?.Trim() ?? string.Empty
CountryCode = receiverInput?.CountryCode?.Trim() ?? order.ConsigneeCountry?.Trim() ?? string.Empty,
Province = receiverInput?.Province?.Trim() ?? recipient?.Province?.Trim() ?? string.Empty,
Postcode = receiverInput?.ZipCode?.Trim() ?? recipient?.ZipCode?.Trim() ?? string.Empty,
City = receiverInput?.City?.Trim() ?? recipient?.City?.Trim() ?? string.Empty,
Email = receiverInput?.Email?.Trim() ?? recipient?.Email?.Trim() ?? string.Empty
}
};
}
private ISmallBagTrackOrderClient SmallBagTrackOrderClient =>
LazyServiceProvider.GetRequiredService<ISmallBagTrackOrderClient>();
/// <summary>
/// 调试:同步调用轨迹 UpsertSmallBag,返回完整请求与响应(Swagger 可测)。
/// </summary>
[HttpPost("/order/debug/track-upsert-small-bag")]
public Task<SmallBagTrackUpsertResult> DebugTrackUpsertSmallBagAsync([FromBody] OrderTrackUpsertDebugInput? input)
{
input ??= new OrderTrackUpsertDebugInput();
var suffix = DateTime.Now.ToString("yyyyMMddHHmmss");
var request = new SmallBagTrackUpsertRequest
{
Client = string.IsNullOrWhiteSpace(input.Client) ? "TEST001" : input.Client.Trim(),
OrderNo = string.IsNullOrWhiteSpace(input.OrderNo) ? $"LP-DEBUG-{suffix}" : input.OrderNo.Trim(),
TrackNo = input.TrackNo?.Trim() ?? string.Empty,
OtherNo = string.IsNullOrWhiteSpace(input.OtherNo) ? $"DEBUG-{suffix}" : input.OtherNo.Trim(),
Channel = string.IsNullOrWhiteSpace(input.Channel) ? "test01" : input.Channel.Trim(),
Product = string.IsNullOrWhiteSpace(input.Product) ? "test01" : input.Product.Trim(),
ApiCode = input.ApiCode?.Trim() ?? string.Empty,
Consignee = new SmallBagTrackConsigneeRequest
{
CountryCode = string.IsNullOrWhiteSpace(input.CountryCode) ? "US" : input.CountryCode.Trim(),
Province = input.Province?.Trim() ?? "California",
Postcode = input.Postcode?.Trim() ?? "90001",
City = input.City?.Trim() ?? "Los Angeles",
Email = input.Email?.Trim() ?? "debug@example.com"
}
};
SmallBagTrackOrderNotifier.Enqueue(request);
return SmallBagTrackOrderClient.UpsertDebugAsync(request);
}
/// <summary>

View File

@ -0,0 +1,197 @@
using System.Linq.Expressions;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.System;
using Oms.Application.Shared;
using Oms.Domain.SystemAggregate.Consts;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.System;
/// <summary>
/// 系统其他配置 API 服务。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-System")]
public class SystemOtherConfigService : ApplicationService
{
private const string SecretMask = "******";
private ISystemOtherConfigRepository SystemOtherConfigRepository =>
LazyServiceProvider.GetRequiredService<ISystemOtherConfigRepository>();
/// <summary>
/// 查询系统其他配置列表。
/// </summary>
[HttpGet("/system/other-config/list")]
public async Task<OmsPageResult<SystemOtherConfigOutput>> GetListAsync([FromQuery] SystemOtherConfigListInput input)
{
var predicate = BuildListPredicate(input);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var items = await SystemOtherConfigRepository.GetPagedListAsync(
predicate,
pageIndex,
pageSize,
new Dictionary<string, bool> { [nameof(SystemOtherConfig.Id)] = false });
var total = await SystemOtherConfigRepository.CountAsync(predicate);
return new OmsPageResult<SystemOtherConfigOutput>
{
Items = items.Select(x => MapOutput(x, maskSecret: true)).ToList(),
Total = total
};
}
/// <summary>
/// 查询系统其他配置详情。
/// </summary>
[HttpGet("/system/other-config/detail")]
public async Task<SystemOtherConfigOutput> GetDetailAsync([FromQuery] int id)
{
var entity = await GetConfigOrThrowAsync(id);
return MapOutput(entity, maskSecret: false);
}
/// <summary>
/// 新增系统其他配置。
/// </summary>
[HttpPost("/system/other-config/create")]
public async Task CreateAsync(SystemOtherConfigCreateInput input)
{
await ValidateInputAsync(input, null);
await SystemOtherConfigRepository.InsertAsync(new SystemOtherConfig
{
ConfigCode = input.ConfigCode.Trim(),
ConfigName = input.ConfigName.Trim(),
Enabled = input.Enabled,
BaseUrl = NormalizeOptional(input.BaseUrl),
AppKey = NormalizeOptional(input.AppKey),
AppSecret = NormalizeOptional(input.AppSecret),
TimeoutSeconds = NormalizeTimeoutSeconds(input.TimeoutSeconds),
Remark = NormalizeOptional(input.Remark)
});
}
/// <summary>
/// 更新系统其他配置。
/// </summary>
[HttpPost("/system/other-config/update")]
public async Task UpdateAsync(SystemOtherConfigUpdateInput input)
{
var entity = await GetConfigOrThrowAsync(input.Id);
await ValidateInputAsync(input, entity.Id);
entity.ConfigCode = input.ConfigCode.Trim();
entity.ConfigName = input.ConfigName.Trim();
entity.Enabled = input.Enabled;
entity.BaseUrl = NormalizeOptional(input.BaseUrl);
entity.AppKey = NormalizeOptional(input.AppKey);
if (!ShouldKeepExistingSecret(input.AppSecret))
{
entity.AppSecret = NormalizeOptional(input.AppSecret);
}
entity.TimeoutSeconds = NormalizeTimeoutSeconds(input.TimeoutSeconds);
entity.Remark = NormalizeOptional(input.Remark);
await SystemOtherConfigRepository.UpdateAsync(entity);
}
/// <summary>
/// 删除系统其他配置。
/// </summary>
[HttpPost("/system/other-config/delete")]
public async Task DeleteAsync(IdInput input)
{
var entity = await GetConfigOrThrowAsync(input.Id);
await SystemOtherConfigRepository.DeleteAsync(entity);
}
private static Expression<Func<SystemOtherConfig, bool>> BuildListPredicate(SystemOtherConfigListInput input)
{
var keyword = input.Keyword?.Trim();
return x =>
string.IsNullOrWhiteSpace(keyword) ||
x.ConfigCode.Contains(keyword) ||
x.ConfigName.Contains(keyword);
}
private async Task<SystemOtherConfig> GetConfigOrThrowAsync(int id)
{
var entity = await SystemOtherConfigRepository.FirstOrDefaultAsync(x => x.Id == id);
if (entity == null)
{
throw new BusinessException("配置不存在");
}
return entity;
}
private async Task ValidateInputAsync(SystemOtherConfigCreateInput input, int? excludeId)
{
if (string.IsNullOrWhiteSpace(input.ConfigCode))
{
throw new BusinessException("配置编码不能为空");
}
if (string.IsNullOrWhiteSpace(input.ConfigName))
{
throw new BusinessException("配置名称不能为空");
}
var configCode = input.ConfigCode.Trim();
var existed = await SystemOtherConfigRepository.GetByConfigCodeAsync(configCode);
if (existed != null && existed.Id != excludeId)
{
throw new BusinessException("配置编码已存在");
}
if (string.Equals(configCode, SystemOtherConfigCodes.TrackService, StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(input.BaseUrl))
{
throw new BusinessException("轨迹服务地址不能为空");
}
if (string.IsNullOrWhiteSpace(input.AppKey))
{
throw new BusinessException("轨迹服务 AppKey 不能为空");
}
if (excludeId == null && ShouldKeepExistingSecret(input.AppSecret))
{
throw new BusinessException("轨迹服务 AppSecret 不能为空");
}
}
}
private static SystemOtherConfigOutput MapOutput(SystemOtherConfig entity, bool maskSecret)
{
return new SystemOtherConfigOutput
{
Id = entity.Id,
ConfigCode = entity.ConfigCode,
ConfigName = entity.ConfigName,
Enabled = entity.Enabled,
BaseUrl = entity.BaseUrl,
AppKey = entity.AppKey,
AppSecret = maskSecret ? MaskSecret(entity.AppSecret) : entity.AppSecret,
TimeoutSeconds = entity.TimeoutSeconds,
Remark = entity.Remark,
CreateTime = entity.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
}
private static string? MaskSecret(string? secret) =>
string.IsNullOrWhiteSpace(secret) ? secret : SecretMask;
private static bool ShouldKeepExistingSecret(string? secret) =>
string.IsNullOrWhiteSpace(secret) || string.Equals(secret.Trim(), SecretMask, StringComparison.Ordinal);
private static string? NormalizeOptional(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static int NormalizeTimeoutSeconds(int timeoutSeconds) =>
timeoutSeconds <= 0 ? 30 : timeoutSeconds;
}

View File

@ -0,0 +1,122 @@
using Fake.DependencyInjection;
using Oms.Application.Dtos.Channels;
using Oms.Application.Integrations.Itfx;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 为排货 Provider 快照补充渠道与 itfx 下游对接元数据。
/// </summary>
public class AllocationProviderSnapshotEnricher(
IServiceChannelRepository serviceChannelRepository,
IServiceChannelGroupRepository serviceChannelGroupRepository,
IItfxMetadataClient itfxMetadataClient) : IScopedDependency
{
public async Task EnrichAsync(
IList<SalesProductAllocationProviderSnapshotDto> providers,
CancellationToken cancellationToken = default)
{
if (providers.Count == 0)
{
return;
}
var channelIds = providers
.Where(x => x.ServiceChannelId.HasValue && x.ServiceChannelId.Value > 0)
.Select(x => x.ServiceChannelId!.Value)
.Distinct()
.ToArray();
if (channelIds.Length == 0)
{
return;
}
var channels = await serviceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id));
var channelMap = channels.ToDictionary(x => x.Id);
var groupIds = channels
.Where(x => x.GroupId.HasValue && x.GroupId.Value > 0)
.Select(x => x.GroupId!.Value)
.Distinct()
.ToArray();
var groups = groupIds.Length == 0
? []
: await serviceChannelGroupRepository.GetListAsync(x => groupIds.Contains(x.Id));
var groupMap = groups.ToDictionary(x => x.Id);
foreach (var provider in providers)
{
if (!provider.ServiceChannelId.HasValue
|| !channelMap.TryGetValue(provider.ServiceChannelId.Value, out var channel))
{
continue;
}
ApplyChannelFields(provider, channel);
if (!channel.GroupId.HasValue || !groupMap.TryGetValue(channel.GroupId.Value, out var group))
{
continue;
}
provider.GroupId = group.Id;
provider.AppId = group.AppId > 0 ? group.AppId : null;
provider.ApiServiceProviderId = group.ApiServiceProviderId > 0 ? group.ApiServiceProviderId : null;
if (provider.AppId is > 0)
{
var app = await itfxMetadataClient.GetAppAsync(provider.AppId.Value, cancellationToken);
if (!string.IsNullOrWhiteSpace(app?.AppKey))
{
provider.AppKey = app.AppKey;
}
}
if (provider.ApiServiceProviderId is > 0)
{
var sp = await itfxMetadataClient.GetServiceProviderAsync(
provider.ApiServiceProviderId.Value,
cancellationToken);
if (!string.IsNullOrWhiteSpace(sp?.Code))
{
provider.SupplierCode = sp.Code;
}
}
}
}
public static void ApplyChannelFields(SalesProductAllocationProviderSnapshotDto provider, ServiceChannel channel)
{
provider.ChannelCode = NullIfEmpty(channel.ChannelCode);
provider.ForecastCode = NullIfEmpty(channel.ForecastCode);
provider.GroupId = channel.GroupId;
if (string.IsNullOrWhiteSpace(provider.ServiceChannelName))
{
provider.ServiceChannelName = NullIfEmpty(channel.ChannelName);
}
}
public static void ApplyDownstreamFieldsToOrder(
Domain.OrdersAggregate.Entities.Order order,
SalesProductAllocationProviderSnapshotDto provider,
ServiceChannel? channelFallback = null)
{
order.DownstreamSupplierCode = NullIfEmpty(provider.SupplierCode);
order.DownstreamAppKey = NullIfEmpty(provider.AppKey);
order.DownstreamAppId = provider.AppId;
order.ChannelForecastCode = NullIfEmpty(provider.ForecastCode)
?? NullIfEmpty(channelFallback?.ForecastCode);
}
private static string? NullIfEmpty(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
public static bool NeedsDownstreamEnrichment(SalesProductAllocationProviderSnapshotDto provider) =>
provider.ServiceChannelId is > 0
&& (string.IsNullOrWhiteSpace(provider.ChannelCode)
|| string.IsNullOrWhiteSpace(provider.ForecastCode)
|| string.IsNullOrWhiteSpace(provider.SupplierCode)
|| string.IsNullOrWhiteSpace(provider.AppKey));
}

View File

@ -18,6 +18,26 @@ public class Order : OmsAuditedAggregateRoot
public string? ChannelCode { get; set; }
/// <summary>
/// 下游 API 服务商编码(itfx supplierCode)。
/// </summary>
public string? DownstreamSupplierCode { get; set; }
/// <summary>
/// 下游 itfx 应用 AppKey。
/// </summary>
public string? DownstreamAppKey { get; set; }
/// <summary>
/// 下游 itfx 应用 Id。
/// </summary>
public int? DownstreamAppId { get; set; }
/// <summary>
/// 渠道预报编码(ForecastCode / ApiCode)。
/// </summary>
public string? ChannelForecastCode { get; set; }
public string? ConsigneeCountry { get; set; }
public string? ConsigneeCountryName { get; set; }

View File

@ -0,0 +1,12 @@
namespace Oms.Domain.SystemAggregate.Consts;
/// <summary>
/// 系统其他配置编码常量。
/// </summary>
public static class SystemOtherConfigCodes
{
/// <summary>
/// 小包轨迹服务配置。
/// </summary>
public const string TrackService = "track_service";
}

View File

@ -0,0 +1,24 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.SystemAggregate.Entities;
[SugarTable("t_system_other_config")]
public class SystemOtherConfig : OmsAuditedAggregateRoot
{
public string ConfigCode { get; set; } = string.Empty;
public string ConfigName { get; set; } = string.Empty;
public bool Enabled { get; set; }
public string? BaseUrl { get; set; }
public string? AppKey { get; set; }
public string? AppSecret { get; set; }
public int TimeoutSeconds { get; set; } = 30;
public string? Remark { get; set; }
}

View File

@ -0,0 +1,9 @@
using Fake.Domain.Repositories;
using Oms.Domain.SystemAggregate.Entities;
namespace Oms.Domain.SystemAggregate.Repositories;
public interface ISystemOtherConfigRepository : IRepository<SystemOtherConfig>
{
Task<SystemOtherConfig?> GetByConfigCodeAsync(string configCode, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,14 @@
using Fake.DependencyInjection;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
namespace Oms.SqlSugarCore.System;
public class SystemOtherConfigRepository : OmsSqlSugarRepository<SystemOtherConfig>, ISystemOtherConfigRepository, IScopedDependency
{
public Task<SystemOtherConfig?> GetByConfigCodeAsync(string configCode, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.ConfigCode == configCode, cancellationToken);
}
}

View File

@ -0,0 +1,19 @@
using FluentAssertions;
using Oms.Application.Integrations.SmallBagTrack;
namespace Oms.Tests;
public class SmallBagTrackSignHelperTests
{
[Fact]
public void GenerateSign_ShouldMatchMd5UppercaseRule()
{
const string appKey = "W2606001";
const long timestamp = 1700000000;
const string appSecret = "ziK8UzlQ39m3kdL346XEBg8nljY8os52";
var sign = SmallBagTrackSignHelper.GenerateSign(appKey, timestamp, appSecret);
sign.Should().Be("A744D764DEE7C925AD6750257A4DEEE7");
}
}

View File

@ -50,5 +50,18 @@
"EncodingAesKey": "",
"OAuthRedirectBase": "https://oms.nanafa.com"
}
},
"Oms": {
"TrackService": {
"Enabled": true,
"BaseUrl": "http://api.nanafa.com/track",
"AppKey": "W2606001",
"AppSecret": "ziK8UzlQ39m3kdL346XEBg8nljY8os52",
"TimeoutSeconds": 30
},
"Itfx": {
"BaseUrl": "http://api.nanafa.com/itfx",
"TimeoutSeconds": 30
}
}
}