feat(order): 规则快照下游元数据、订单绑定字段与系统其他配置
排货发布 enrich Providers 并确认渠道时写入 t_order 下游四字段;接入 itfx 元数据客户端;系统其他配置与小包轨迹 DB 配置;手动轨迹 UpsertSmallBag 接口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
fe30d071e6
commit
545f0825f8
26
db.sql
26
db.sql
@ -10,6 +10,7 @@
|
|||||||
SET NAMES utf8mb4;
|
SET NAMES utf8mb4;
|
||||||
SET FOREIGN_KEY_CHECKS = 0;
|
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_warehouse_address`;
|
||||||
DROP TABLE IF EXISTS `t_notice_read`;
|
DROP TABLE IF EXISTS `t_notice_read`;
|
||||||
DROP TABLE IF EXISTS `t_notice`;
|
DROP TABLE IF EXISTS `t_notice`;
|
||||||
@ -58,6 +59,10 @@ CREATE TABLE `t_order` (
|
|||||||
`TrackNo` VARCHAR(64) NULL COMMENT '跟踪号',
|
`TrackNo` VARCHAR(64) NULL COMMENT '跟踪号',
|
||||||
`ProductCode` VARCHAR(128) NULL COMMENT '产品编码',
|
`ProductCode` VARCHAR(128) NULL COMMENT '产品编码',
|
||||||
`ChannelCode` 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 '国家二字码',
|
`ConsigneeCountry` VARCHAR(8) NULL COMMENT '国家二字码',
|
||||||
`ConsigneeCountryName` VARCHAR(64) NULL COMMENT '国家中文名称',
|
`ConsigneeCountryName` VARCHAR(64) NULL COMMENT '国家中文名称',
|
||||||
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
|
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
|
||||||
@ -828,6 +833,27 @@ CREATE TABLE `t_dict` (
|
|||||||
KEY `idx_dict_type_sort` (`DictType`, `DictSort`, `Id`)
|
KEY `idx_dict_type_sort` (`DictType`, `DictSort`, `Id`)
|
||||||
) COMMENT='系统字典表';
|
) 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` (
|
CREATE TABLE `t_notice` (
|
||||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
|
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
|
||||||
`NoticeType` VARCHAR(16) NOT NULL COMMENT '通知类型:notice/message',
|
`NoticeType` VARCHAR(16) NOT NULL COMMENT '通知类型:notice/message',
|
||||||
|
|||||||
77
scripts/debug-upsert-small-bag.ps1
Normal file
77
scripts/debug-upsert-small-bag.ps1
Normal 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×tamp=$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
|
||||||
|
}
|
||||||
6
sql/migrate_t_order_downstream_integration.sql
Normal file
6
sql/migrate_t_order_downstream_integration.sql
Normal 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`;
|
||||||
21
sql/migrate_t_system_other_config.sql
Normal file
21
sql/migrate_t_system_other_config.sql
Normal 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='系统其他配置表';
|
||||||
@ -44,4 +44,39 @@ public class SalesProductAllocationProviderSnapshotDto
|
|||||||
/// 指定客户主键集合,空表示全部。
|
/// 指定客户主键集合,空表示全部。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<int> CustomerIds { get; set; } = [];
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,26 @@ public class OrderOutput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? ChannelCode { get; set; }
|
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>
|
||||||
/// 服务渠道名称。
|
/// 服务渠道名称。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -75,6 +95,31 @@ public class OrderOutput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int? CustomerId { get; set; }
|
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>
|
/// <summary>
|
||||||
/// 是否 COD。
|
/// 是否 COD。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
62
src/Oms.Application/Dtos/System/SystemOtherConfigOutput.cs
Normal file
62
src/Oms.Application/Dtos/System/SystemOtherConfigOutput.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
namespace Oms.Application.Dtos.System;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 系统其他配置更新入参。
|
||||||
|
/// </summary>
|
||||||
|
public class SystemOtherConfigUpdateInput : SystemOtherConfigCreateInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 主键。
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
|
}
|
||||||
20
src/Oms.Application/Integrations/Itfx/IItfxMetadataClient.cs
Normal file
20
src/Oms.Application/Integrations/Itfx/IItfxMetadataClient.cs
Normal 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);
|
||||||
|
}
|
||||||
16
src/Oms.Application/Integrations/Itfx/ItfxGatewayOptions.cs
Normal file
16
src/Oms.Application/Integrations/Itfx/ItfxGatewayOptions.cs
Normal 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;
|
||||||
|
}
|
||||||
135
src/Oms.Application/Integrations/Itfx/ItfxMetadataClient.cs
Normal file
135
src/Oms.Application/Integrations/Itfx/ItfxMetadataClient.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,4 +3,9 @@ namespace Oms.Application.Integrations.SmallBagTrack;
|
|||||||
public interface ISmallBagTrackOrderClient
|
public interface ISmallBagTrackOrderClient
|
||||||
{
|
{
|
||||||
Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
|
Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 同步调用并返回完整请求/响应(仅调试)。
|
||||||
|
/// </summary>
|
||||||
|
Task<SmallBagTrackUpsertResult> UpsertDebugAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
@ -17,6 +17,16 @@ public class SmallBagTrackOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string BaseUrl { get; set; } = string.Empty;
|
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>
|
/// <summary>
|
||||||
/// HTTP 超时(秒)。
|
/// HTTP 超时(秒)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -1,41 +1,95 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace Oms.Application.Integrations.SmallBagTrack;
|
namespace Oms.Application.Integrations.SmallBagTrack;
|
||||||
|
|
||||||
internal sealed class SmallBagTrackOrderClient(
|
internal sealed class SmallBagTrackOrderClient(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptions<SmallBagTrackOptions> options,
|
ISmallBagTrackConfigProvider configProvider,
|
||||||
ILogger<SmallBagTrackOrderClient> logger) : ISmallBagTrackOrderClient
|
ILogger<SmallBagTrackOrderClient> logger) : ISmallBagTrackOrderClient
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
public const string HttpClientName = nameof(SmallBagTrackOrderClient);
|
public const string HttpClientName = nameof(SmallBagTrackOrderClient);
|
||||||
|
|
||||||
public async Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default)
|
public async Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var settings = options.Value;
|
var result = await UpsertDebugAsync(request, cancellationToken);
|
||||||
if (!settings.Enabled || string.IsNullOrWhiteSpace(settings.BaseUrl))
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var url = $"{settings.BaseUrl.Trim().TrimEnd('/')}/OrderV2/UpsertSmallBag";
|
if (!result.Success)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"UpsertSmallBag failed for order {OrderNo}: {StatusCode} {Body}",
|
"UpsertSmallBag failed for order {OrderNo}: {StatusCode} {Body}",
|
||||||
request.OrderNo,
|
request.OrderNo,
|
||||||
(int)response.StatusCode,
|
result.StatusCode,
|
||||||
body);
|
result.ResponseBody);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("UpsertSmallBag succeeded for order {OrderNo}", request.OrderNo);
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,8 @@ public static class SmallBagTrackServiceExtensions
|
|||||||
{
|
{
|
||||||
services.Configure<SmallBagTrackOptions>(configuration.GetSection(SmallBagTrackOptions.SectionName));
|
services.Configure<SmallBagTrackOptions>(configuration.GetSection(SmallBagTrackOptions.SectionName));
|
||||||
services.AddHttpClient(SmallBagTrackOrderClient.HttpClientName);
|
services.AddHttpClient(SmallBagTrackOrderClient.HttpClientName);
|
||||||
services.AddSingleton<ISmallBagTrackOrderClient, SmallBagTrackOrderClient>();
|
services.AddScoped<ISmallBagTrackConfigProvider, SmallBagTrackConfigProvider>();
|
||||||
|
services.AddScoped<ISmallBagTrackOrderClient, SmallBagTrackOrderClient>();
|
||||||
services.AddSingleton<ISmallBagTrackOrderNotifier, SmallBagTrackOrderNotifier>();
|
services.AddSingleton<ISmallBagTrackOrderNotifier, SmallBagTrackOrderNotifier>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Oms.Application.Integrations.SmallBagTrack;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 轨迹服务 API 签名:appKey={}&timestamp={}&appSecret={} → MD5 大写。
|
||||||
|
/// </summary>
|
||||||
|
internal static class SmallBagTrackSignHelper
|
||||||
|
{
|
||||||
|
public static string GenerateSign(string appKey, long timestamp, string appSecret)
|
||||||
|
{
|
||||||
|
var signString = $"appKey={appKey}×tamp={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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ using Fake.ObjectMapping.AutoMapper;
|
|||||||
using Fake.Security.Claims;
|
using Fake.Security.Claims;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Oms.Application.Integrations.SmallBagTrack;
|
using Oms.Application.Integrations.SmallBagTrack;
|
||||||
|
using Oms.Application.Integrations.Itfx;
|
||||||
using Oms.Domain;
|
using Oms.Domain;
|
||||||
|
|
||||||
namespace Oms.Application;
|
namespace Oms.Application;
|
||||||
@ -24,5 +25,6 @@ public class OmsApplicationModule : FakeModule
|
|||||||
options.ScanProfiles<OmsApplicationModule>();
|
options.ScanProfiles<OmsApplicationModule>();
|
||||||
});
|
});
|
||||||
context.Services.AddOmsSmallBagTrack(context.Services.GetConfiguration());
|
context.Services.AddOmsSmallBagTrack(context.Services.GetConfiguration());
|
||||||
|
context.Services.AddOmsItfxIntegration(context.Services.GetConfiguration());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -175,6 +175,7 @@ public partial class SalesProductService
|
|||||||
KpiAreaRepository,
|
KpiAreaRepository,
|
||||||
WeightRuleRepository,
|
WeightRuleRepository,
|
||||||
WeightRoundingRepository);
|
WeightRoundingRepository);
|
||||||
|
await AllocationProviderSnapshotEnricher.EnrichAsync(rulesSnapshot.Providers);
|
||||||
return rulesSnapshot;
|
return rulesSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,8 @@ public partial class SalesProductService : ApplicationService
|
|||||||
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
||||||
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
|
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
|
||||||
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
|
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
|
||||||
|
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
|
||||||
|
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询销售产品列表。
|
/// 查询销售产品列表。
|
||||||
|
|||||||
@ -93,6 +93,10 @@ public partial class OrderService
|
|||||||
? product.ProductName
|
? product.ProductName
|
||||||
: null,
|
: null,
|
||||||
ChannelCode = order.ChannelCode,
|
ChannelCode = order.ChannelCode,
|
||||||
|
DownstreamSupplierCode = order.DownstreamSupplierCode,
|
||||||
|
DownstreamAppKey = order.DownstreamAppKey,
|
||||||
|
DownstreamAppId = order.DownstreamAppId,
|
||||||
|
ChannelForecastCode = order.ChannelForecastCode,
|
||||||
ChannelName = !string.IsNullOrWhiteSpace(order.ChannelCode)
|
ChannelName = !string.IsNullOrWhiteSpace(order.ChannelCode)
|
||||||
&& channelMap.TryGetValue(order.ChannelCode.Trim(), out var channel)
|
&& channelMap.TryGetValue(order.ChannelCode.Trim(), out var channel)
|
||||||
? channel.ChannelName
|
? channel.ChannelName
|
||||||
@ -105,6 +109,11 @@ public partial class OrderService
|
|||||||
? warehouse.WarehouseName
|
? warehouse.WarehouseName
|
||||||
: null,
|
: null,
|
||||||
CustomerId = order.CustomerId,
|
CustomerId = order.CustomerId,
|
||||||
|
RequestId = order.RequestId,
|
||||||
|
ExportCustomsMode = order.ExportCustomsMode,
|
||||||
|
DeclaredCurrency = order.DeclaredCurrency,
|
||||||
|
DeclaredSum = order.DeclaredSum,
|
||||||
|
Quantity = order.Quantity,
|
||||||
IsCod = order.IsCod > 0,
|
IsCod = order.IsCod > 0,
|
||||||
CodCurrency = order.CodCurrency,
|
CodCurrency = order.CodCurrency,
|
||||||
CodSum = order.CodSum,
|
CodSum = order.CodSum,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Oms.Application.Dtos.Channels;
|
using Oms.Application.Dtos.Channels;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Integrations.SmallBagTrack;
|
using Oms.Application.Integrations.SmallBagTrack;
|
||||||
@ -28,6 +29,8 @@ public partial class OrderService
|
|||||||
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
|
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
|
||||||
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
|
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
|
||||||
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
||||||
|
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
|
||||||
|
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
|
||||||
|
|
||||||
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(OrderParcelCreateInput input)
|
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(OrderParcelCreateInput input)
|
||||||
{
|
{
|
||||||
@ -471,6 +474,7 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var selectedChannel = AllocationResolver.SelectChannel(scheme, candidates);
|
var selectedChannel = AllocationResolver.SelectChannel(scheme, candidates);
|
||||||
|
var selectedCandidate = candidates.First(x => x.Channel.Id == selectedChannel.Id);
|
||||||
var channelPriceBindingFinal = await ResolveChannelPriceBindingAsync(selectedChannel.Id, bindTime);
|
var channelPriceBindingFinal = await ResolveChannelPriceBindingAsync(selectedChannel.Id, bindTime);
|
||||||
var payableFees = ParcelBillingCalculator.CalculatePayableFees(
|
var payableFees = ParcelBillingCalculator.CalculatePayableFees(
|
||||||
channelPriceBindingFinal.Prices,
|
channelPriceBindingFinal.Prices,
|
||||||
@ -489,6 +493,10 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
order.ChannelCode = selectedChannel.ChannelCode;
|
order.ChannelCode = selectedChannel.ChannelCode;
|
||||||
|
AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder(
|
||||||
|
order,
|
||||||
|
selectedCandidate.Provider,
|
||||||
|
selectedChannel);
|
||||||
if (payableFees.Count > 0)
|
if (payableFees.Count > 0)
|
||||||
{
|
{
|
||||||
await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage);
|
await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage);
|
||||||
@ -500,11 +508,17 @@ public partial class OrderService
|
|||||||
SalesProduct product,
|
SalesProduct product,
|
||||||
SalesProductRulesPublishSnapshotDto rulesSnapshot)
|
SalesProductRulesPublishSnapshotDto rulesSnapshot)
|
||||||
{
|
{
|
||||||
|
List<SalesProductAllocationProviderSnapshotDto> providers;
|
||||||
if (rulesSnapshot.Providers.Count > 0)
|
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 entities = await SalesProductRepository.GetAllocationProvidersAsync(product.Id);
|
||||||
var channelIds = entities.Where(x => x.ServiceChannelId.HasValue).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray();
|
var channelIds = entities.Where(x => x.ServiceChannelId.HasValue).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray();
|
||||||
var channels = channelIds.Length == 0
|
var channels = channelIds.Length == 0
|
||||||
@ -512,7 +526,7 @@ public partial class OrderService
|
|||||||
: await ServiceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id));
|
: await ServiceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id));
|
||||||
var channelMap = channels.ToDictionary(x => x.Id, x => x.ChannelName);
|
var channelMap = channels.ToDictionary(x => x.Id, x => x.ChannelName);
|
||||||
|
|
||||||
return entities.Select(provider => PublishSnapshotSlimmer.SlimAllocationProvider(new SalesProductAllocationProviderOutput
|
providers = entities.Select(provider => PublishSnapshotSlimmer.SlimAllocationProvider(new SalesProductAllocationProviderOutput
|
||||||
{
|
{
|
||||||
Id = provider.Id,
|
Id = provider.Id,
|
||||||
SalesProductId = provider.SalesProductId,
|
SalesProductId = provider.SalesProductId,
|
||||||
@ -523,6 +537,10 @@ public partial class OrderService
|
|||||||
Weight = provider.Weight,
|
Weight = provider.Weight,
|
||||||
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? []
|
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? []
|
||||||
})).ToList();
|
})).ToList();
|
||||||
|
await AllocationProviderSnapshotEnricher.EnrichAsync(providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ChannelPriceBinding
|
private sealed class ChannelPriceBinding
|
||||||
@ -1425,14 +1443,47 @@ public partial class OrderService
|
|||||||
Customer? customer,
|
Customer? customer,
|
||||||
OrderParcelReceiverInput? receiver)
|
OrderParcelReceiverInput? receiver)
|
||||||
{
|
{
|
||||||
var apiCode = string.Empty;
|
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver);
|
||||||
if (!string.IsNullOrWhiteSpace(order.ChannelCode))
|
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());
|
var channel = await ServiceChannelRepository.GetByChannelCodeAsync(order.ChannelCode.Trim());
|
||||||
apiCode = channel?.ForecastCode?.Trim() ?? string.Empty;
|
apiCode = channel?.ForecastCode?.Trim() ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = new SmallBagTrackUpsertRequest
|
return new SmallBagTrackUpsertRequest
|
||||||
{
|
{
|
||||||
Client = customer?.CustomerCode?.Trim() ?? string.Empty,
|
Client = customer?.CustomerCode?.Trim() ?? string.Empty,
|
||||||
OrderNo = order.ServiceNo,
|
OrderNo = order.ServiceNo,
|
||||||
@ -1443,15 +1494,46 @@ public partial class OrderService
|
|||||||
ApiCode = apiCode,
|
ApiCode = apiCode,
|
||||||
Consignee = new SmallBagTrackConsigneeRequest
|
Consignee = new SmallBagTrackConsigneeRequest
|
||||||
{
|
{
|
||||||
CountryCode = receiver?.CountryCode?.Trim() ?? order.ConsigneeCountry?.Trim() ?? string.Empty,
|
CountryCode = receiverInput?.CountryCode?.Trim() ?? order.ConsigneeCountry?.Trim() ?? string.Empty,
|
||||||
Province = receiver?.Province?.Trim() ?? string.Empty,
|
Province = receiverInput?.Province?.Trim() ?? recipient?.Province?.Trim() ?? string.Empty,
|
||||||
Postcode = receiver?.ZipCode?.Trim() ?? string.Empty,
|
Postcode = receiverInput?.ZipCode?.Trim() ?? recipient?.ZipCode?.Trim() ?? string.Empty,
|
||||||
City = receiver?.City?.Trim() ?? string.Empty,
|
City = receiverInput?.City?.Trim() ?? recipient?.City?.Trim() ?? string.Empty,
|
||||||
Email = receiver?.Email?.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>
|
/// <summary>
|
||||||
|
|||||||
197
src/Oms.Application/Services/System/SystemOtherConfigService.cs
Normal file
197
src/Oms.Application/Services/System/SystemOtherConfigService.cs
Normal 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;
|
||||||
|
}
|
||||||
122
src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs
Normal file
122
src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs
Normal 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));
|
||||||
|
}
|
||||||
@ -18,6 +18,26 @@ public class Order : OmsAuditedAggregateRoot
|
|||||||
|
|
||||||
public string? ChannelCode { get; set; }
|
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? ConsigneeCountry { get; set; }
|
||||||
|
|
||||||
public string? ConsigneeCountryName { get; set; }
|
public string? ConsigneeCountryName { get; set; }
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
namespace Oms.Domain.SystemAggregate.Consts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 系统其他配置编码常量。
|
||||||
|
/// </summary>
|
||||||
|
public static class SystemOtherConfigCodes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 小包轨迹服务配置。
|
||||||
|
/// </summary>
|
||||||
|
public const string TrackService = "track_service";
|
||||||
|
}
|
||||||
24
src/Oms.Domain/SystemAggregate/Entities/SystemOtherConfig.cs
Normal file
24
src/Oms.Domain/SystemAggregate/Entities/SystemOtherConfig.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
14
src/Oms.SqlSugarCore/System/SystemOtherConfigRepository.cs
Normal file
14
src/Oms.SqlSugarCore/System/SystemOtherConfigRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Oms.Tests/SmallBagTrackSignHelperTests.cs
Normal file
19
src/Oms.Tests/SmallBagTrackSignHelperTests.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -50,5 +50,18 @@
|
|||||||
"EncodingAesKey": "",
|
"EncodingAesKey": "",
|
||||||
"OAuthRedirectBase": "https://oms.nanafa.com"
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user