feat: API中心其他配置与面单 COS 流代理
All checks were successful
Build and Deploy / deploy (push) Successful in 1m52s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m52s
将 itfx 网关优先读其他配置 api_center(含 AppKey/AppSecret 回退),面单预览经服务端代理拉取 COS。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
87abcecb65
commit
0fdc9e29f4
@ -3,13 +3,12 @@ using System.Net.Http.Json;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace Oms.Application.Integrations.Itfx;
|
namespace Oms.Application.Integrations.Itfx;
|
||||||
|
|
||||||
internal sealed class ItfxForwardClient(
|
internal sealed class ItfxForwardClient(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptions<ItfxGatewayOptions> options,
|
IItfxGatewayConfigProvider configProvider,
|
||||||
IHttpContextAccessor httpContextAccessor) : IItfxForwardClient, IScopedDependency
|
IHttpContextAccessor httpContextAccessor) : IItfxForwardClient, IScopedDependency
|
||||||
{
|
{
|
||||||
public const string HttpClientName = nameof(ItfxForwardClient);
|
public const string HttpClientName = nameof(ItfxForwardClient);
|
||||||
@ -45,10 +44,10 @@ internal sealed class ItfxForwardClient(
|
|||||||
object body,
|
object body,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var settings = options.Value;
|
var settings = await configProvider.GetAsync(cancellationToken);
|
||||||
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
|
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Itfx BaseUrl not configured");
|
throw new InvalidOperationException("API 中心服务地址未配置,请在「其他配置」中启用 api_center");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(standardCode))
|
if (string.IsNullOrWhiteSpace(standardCode))
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
using Fake.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Oms.Domain.SystemAggregate.Consts;
|
||||||
|
using Oms.Domain.SystemAggregate.Repositories;
|
||||||
|
|
||||||
|
namespace Oms.Application.Integrations.Itfx;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// itfx / API 中心运行时配置(优先「其他配置」api_center,回退 appsettings Oms:Itfx)。
|
||||||
|
/// </summary>
|
||||||
|
public interface IItfxGatewayConfigProvider
|
||||||
|
{
|
||||||
|
Task<ItfxGatewayOptions> GetAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ItfxGatewayConfigProvider(
|
||||||
|
ISystemOtherConfigRepository repository,
|
||||||
|
IOptions<ItfxGatewayOptions> options) : IItfxGatewayConfigProvider, IScopedDependency
|
||||||
|
{
|
||||||
|
public async Task<ItfxGatewayOptions> GetAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var fallback = options.Value;
|
||||||
|
var entity = await repository.GetByConfigCodeAsync(SystemOtherConfigCodes.ApiCenter, cancellationToken);
|
||||||
|
if (entity == null || !entity.Enabled)
|
||||||
|
{
|
||||||
|
return Clone(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ItfxGatewayOptions
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
Customs9610StandardCode = fallback.Customs9610StandardCode,
|
||||||
|
Customs9610ManifestStandardCode = fallback.Customs9610ManifestStandardCode,
|
||||||
|
Customs9610DeclarationFormStandardCode = fallback.Customs9610DeclarationFormStandardCode,
|
||||||
|
Customs9610SupplierCode = fallback.Customs9610SupplierCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItfxGatewayOptions Clone(ItfxGatewayOptions src) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
BaseUrl = src.BaseUrl,
|
||||||
|
AppKey = src.AppKey,
|
||||||
|
AppSecret = src.AppSecret,
|
||||||
|
TimeoutSeconds = src.TimeoutSeconds,
|
||||||
|
Customs9610StandardCode = src.Customs9610StandardCode,
|
||||||
|
Customs9610ManifestStandardCode = src.Customs9610ManifestStandardCode,
|
||||||
|
Customs9610DeclarationFormStandardCode = src.Customs9610DeclarationFormStandardCode,
|
||||||
|
Customs9610SupplierCode = src.Customs9610SupplierCode
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Coalesce(string? primary, string fallback) =>
|
||||||
|
string.IsNullOrWhiteSpace(primary) ? fallback : primary.Trim();
|
||||||
|
}
|
||||||
@ -12,6 +12,16 @@ public class ItfxGatewayOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string BaseUrl { get; set; } = "https://api.ideship.com/itfx";
|
public string BaseUrl { get; set; } = "https://api.ideship.com/itfx";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 默认应用 AppKey(其他配置 api_center;GetInfo 无 Secret 时回退)。
|
||||||
|
/// </summary>
|
||||||
|
public string AppKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 默认应用 AppSecret(其他配置 api_center;GetInfo 无 Secret 时回退)。
|
||||||
|
/// </summary>
|
||||||
|
public string AppSecret { get; set; } = string.Empty;
|
||||||
|
|
||||||
public int TimeoutSeconds { get; set; } = 30;
|
public int TimeoutSeconds { get; set; } = 30;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -3,13 +3,12 @@ using System.Text.Json;
|
|||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace Oms.Application.Integrations.Itfx;
|
namespace Oms.Application.Integrations.Itfx;
|
||||||
|
|
||||||
internal sealed class ItfxMetadataClient(
|
internal sealed class ItfxMetadataClient(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptions<ItfxGatewayOptions> options,
|
IItfxGatewayConfigProvider configProvider,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
ILogger<ItfxMetadataClient> logger) : IItfxMetadataClient, IScopedDependency
|
ILogger<ItfxMetadataClient> logger) : IItfxMetadataClient, IScopedDependency
|
||||||
{
|
{
|
||||||
@ -67,7 +66,7 @@ internal sealed class ItfxMetadataClient(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
var settings = options.Value;
|
var settings = await configProvider.GetAsync(cancellationToken);
|
||||||
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
|
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
|
||||||
{
|
{
|
||||||
logger.LogDebug("Itfx BaseUrl not configured, skip {Url}", relativeUrl);
|
logger.LogDebug("Itfx BaseUrl not configured, skip {Url}", relativeUrl);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ public static class ItfxServiceExtensions
|
|||||||
services.Configure<ItfxGatewayOptions>(configuration.GetSection(ItfxGatewayOptions.SectionName));
|
services.Configure<ItfxGatewayOptions>(configuration.GetSection(ItfxGatewayOptions.SectionName));
|
||||||
services.AddHttpClient(ItfxMetadataClient.HttpClientName);
|
services.AddHttpClient(ItfxMetadataClient.HttpClientName);
|
||||||
services.AddHttpClient(ItfxForwardClient.HttpClientName);
|
services.AddHttpClient(ItfxForwardClient.HttpClientName);
|
||||||
|
services.AddScoped<IItfxGatewayConfigProvider, ItfxGatewayConfigProvider>();
|
||||||
services.AddScoped<IItfxMetadataClient, ItfxMetadataClient>();
|
services.AddScoped<IItfxMetadataClient, ItfxMetadataClient>();
|
||||||
services.AddScoped<IItfxForwardClient, ItfxForwardClient>();
|
services.AddScoped<IItfxForwardClient, ItfxForwardClient>();
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Oms.Application.Storage;
|
using Oms.Application.Storage;
|
||||||
|
|
||||||
namespace Oms.Application.Services.Storage;
|
namespace Oms.Application.Services.Storage;
|
||||||
@ -12,7 +14,12 @@ namespace Oms.Application.Services.Storage;
|
|||||||
[ApiExplorerSettings(GroupName = "OMS-Storage")]
|
[ApiExplorerSettings(GroupName = "OMS-Storage")]
|
||||||
public class FileStorageService : ApplicationService
|
public class FileStorageService : ApplicationService
|
||||||
{
|
{
|
||||||
|
public const string PrintProxyHttpClientName = "FileStoragePrintProxy";
|
||||||
|
|
||||||
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
|
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
|
||||||
|
private IHttpClientFactory HttpClientFactory => LazyServiceProvider.GetRequiredService<IHttpClientFactory>();
|
||||||
|
private ILogger<FileStorageService> FileLogger =>
|
||||||
|
LazyServiceProvider.GetRequiredService<ILogger<FileStorageService>>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 是否支持前端直传(Cos=true)。
|
/// 是否支持前端直传(Cos=true)。
|
||||||
@ -111,6 +118,7 @@ public class FileStorageService : ApplicationService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按 key / URL 流式读取文件(供面单预览等场景,避免前端跨域 iframe 空白)。
|
/// 按 key / URL 流式读取文件(供面单预览等场景,避免前端跨域 iframe 空白)。
|
||||||
|
/// 若对象键解析失败,但对受管 COS 的预签名绝对 URL 仍可服务端代拉(绕开浏览器 CORS)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("/file-storage/stream")]
|
[HttpGet("/file-storage/stream")]
|
||||||
public async Task<IActionResult> StreamAsync([FromQuery] string key, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> StreamAsync([FromQuery] string key, CancellationToken cancellationToken = default)
|
||||||
@ -120,43 +128,189 @@ public class FileStorageService : ApplicationService
|
|||||||
throw new BusinessException("key 不能为空");
|
throw new BusinessException("key 不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
string storageKey;
|
var raw = key.Trim();
|
||||||
|
Exception? storageError = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
storageKey = FileStorage.ToStorageKey(key) ?? FileStoragePath.NormalizeKey(key);
|
var storageKey = FileStorage.ToStorageKey(raw) ?? FileStoragePath.NormalizeKey(raw);
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
throw new BusinessException("无法解析文件 key");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!await FileStorage.ExistsAsync(storageKey, cancellationToken))
|
|
||||||
{
|
|
||||||
throw new BusinessException("文件不存在或已过期");
|
|
||||||
}
|
|
||||||
|
|
||||||
var stream = await FileStorage.OpenReadAsync(storageKey, cancellationToken);
|
var stream = await FileStorage.OpenReadAsync(storageKey, cancellationToken);
|
||||||
return new FileStreamResult(stream, GuessStreamContentType(storageKey))
|
return new FileStreamResult(stream, GuessStreamContentType(storageKey))
|
||||||
{
|
{
|
||||||
EnableRangeProcessing = true,
|
EnableRangeProcessing = true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
storageError = ex;
|
||||||
|
FileLogger.LogWarning(ex, "file-storage/stream 按 key 读取失败,尝试签名 URL 代拉: {Key}", TruncateForLog(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsAllowedPrintProxyUrl(raw))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await ProxySignedUrlAsync(raw, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
FileLogger.LogWarning(ex, "file-storage/stream 签名 URL 代拉失败: {Key}", TruncateForLog(raw));
|
||||||
|
storageError ??= ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storageError is BusinessException business)
|
||||||
|
{
|
||||||
|
throw business;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BusinessException("文件不存在或已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> ProxySignedUrlAsync(string absoluteUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var client = HttpClientFactory.CreateClient(PrintProxyHttpClientName);
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, absoluteUrl);
|
||||||
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*"));
|
||||||
|
|
||||||
|
var response = await client.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
FileLogger.LogWarning(
|
||||||
|
"签名 URL 代拉 HTTP {Status}: {Url}",
|
||||||
|
(int)response.StatusCode,
|
||||||
|
TruncateForLog(absoluteUrl));
|
||||||
|
throw new BusinessException($"面单代拉失败 HTTP {(int)response.StatusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(contentType))
|
||||||
|
{
|
||||||
|
contentType = GuessStreamContentType(absoluteUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
// 由 FileStreamResult 在响应结束时释放;HttpResponseMessage 需保持至流读完
|
||||||
|
return new FileStreamResult(new HttpResponseStream(response, stream), contentType)
|
||||||
|
{
|
||||||
|
EnableRangeProcessing = false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAllowedPrintProxyUrl(string url)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = uri.Host.ToLowerInvariant();
|
||||||
|
return host is "cos.ideship.com" or "cos.nanafa.com"
|
||||||
|
|| host.EndsWith(".myqcloud.com", StringComparison.Ordinal)
|
||||||
|
|| host.StartsWith("cos.", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TruncateForLog(string value) =>
|
||||||
|
value.Length <= 160 ? value : value[..160] + "…";
|
||||||
|
|
||||||
private static string GuessStreamContentType(string storageKey)
|
private static string GuessStreamContentType(string storageKey)
|
||||||
{
|
{
|
||||||
if (storageKey.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
|
var path = storageKey;
|
||||||
|
if (FileStoragePath.IsAbsoluteUrl(storageKey)
|
||||||
|
&& Uri.TryCreate(storageKey, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
path = uri.AbsolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.Contains(".pdf?", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return "application/pdf";
|
return "application/pdf";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storageKey.EndsWith(".html", StringComparison.OrdinalIgnoreCase)
|
if (path.EndsWith(".html", StringComparison.OrdinalIgnoreCase)
|
||||||
|| storageKey.EndsWith(".htm", StringComparison.OrdinalIgnoreCase))
|
|| path.EndsWith(".htm", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.Contains(".html?", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.Contains(".htm?", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return "text/html; charset=utf-8";
|
return "text/html; charset=utf-8";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "application/octet-stream";
|
return "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 包装 HttpResponseMessage + 响应流,确保 FileStreamResult 读完后释放连接。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class HttpResponseStream : Stream
|
||||||
|
{
|
||||||
|
private readonly HttpResponseMessage _response;
|
||||||
|
private readonly Stream _inner;
|
||||||
|
|
||||||
|
public HttpResponseStream(HttpResponseMessage response, Stream inner)
|
||||||
|
{
|
||||||
|
_response = response;
|
||||||
|
_inner = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanRead => _inner.CanRead;
|
||||||
|
public override bool CanSeek => false;
|
||||||
|
public override bool CanWrite => false;
|
||||||
|
public override long Length => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public override long Position
|
||||||
|
{
|
||||||
|
get => throw new NotSupportedException();
|
||||||
|
set => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Flush() => _inner.Flush();
|
||||||
|
|
||||||
|
public override int Read(byte[] buffer, int offset, int count) =>
|
||||||
|
_inner.Read(buffer, offset, count);
|
||||||
|
|
||||||
|
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
|
||||||
|
_inner.ReadAsync(buffer, offset, count, cancellationToken);
|
||||||
|
|
||||||
|
public override ValueTask<int> ReadAsync(
|
||||||
|
Memory<byte> buffer,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
_inner.ReadAsync(buffer, cancellationToken);
|
||||||
|
|
||||||
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public override void SetLength(long value) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public override void Write(byte[] buffer, int offset, int count) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _inner.DisposeAsync();
|
||||||
|
_response.Dispose();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_inner.Dispose();
|
||||||
|
_response.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PresignUploadInput
|
public class PresignUploadInput
|
||||||
|
|||||||
@ -171,6 +171,23 @@ public class SystemOtherConfigService : ApplicationService
|
|||||||
throw new BusinessException("轨迹服务 AppSecret 不能为空");
|
throw new BusinessException("轨迹服务 AppSecret 不能为空");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (string.Equals(configCode, SystemOtherConfigCodes.ApiCenter, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input.BaseUrl))
|
||||||
|
{
|
||||||
|
throw new BusinessException("API 中心服务地址不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(input.AppKey))
|
||||||
|
{
|
||||||
|
throw new BusinessException("API 中心应用 AppKey 不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludeId == null && ShouldKeepExistingSecret(input.AppSecret))
|
||||||
|
{
|
||||||
|
throw new BusinessException("API 中心应用 AppSecret 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (string.Equals(configCode, SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase))
|
else if (string.Equals(configCode, SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(input.AppKey))
|
if (string.IsNullOrWhiteSpace(input.AppKey))
|
||||||
|
|||||||
@ -22,7 +22,8 @@ public sealed class OrderDownstreamCredentials
|
|||||||
public class OrderDownstreamCredentialResolver(
|
public class OrderDownstreamCredentialResolver(
|
||||||
IServiceChannelRepository serviceChannelRepository,
|
IServiceChannelRepository serviceChannelRepository,
|
||||||
IServiceChannelGroupRepository serviceChannelGroupRepository,
|
IServiceChannelGroupRepository serviceChannelGroupRepository,
|
||||||
IItfxMetadataClient itfxMetadataClient) : IScopedDependency
|
IItfxMetadataClient itfxMetadataClient,
|
||||||
|
IItfxGatewayConfigProvider itfxGatewayConfigProvider) : IScopedDependency
|
||||||
{
|
{
|
||||||
public async Task<OrderDownstreamCredentials> ResolveAsync(Order order, CancellationToken cancellationToken = default)
|
public async Task<OrderDownstreamCredentials> ResolveAsync(Order order, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@ -40,7 +41,19 @@ public class OrderDownstreamCredentialResolver(
|
|||||||
var appSecret = app?.AppSecret?.Trim();
|
var appSecret = app?.AppSecret?.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(appSecret))
|
if (string.IsNullOrWhiteSpace(appSecret))
|
||||||
{
|
{
|
||||||
throw new BusinessException("应用 AppSecret 未配置");
|
appSecret = await TryResolveAppSecretFromApiCenterAsync(order.DownstreamAppKey, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(appSecret))
|
||||||
|
{
|
||||||
|
if (app == null)
|
||||||
|
{
|
||||||
|
throw new BusinessException(
|
||||||
|
"无法从 API 中心获取应用信息,请检查「其他配置-API中心服务」地址/AppKey/AppSecret,以及下游应用是否存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BusinessException(
|
||||||
|
"应用 AppSecret 未配置,请在「其他配置-API中心服务」填写与下游一致的 AppKey/AppSecret");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OrderDownstreamCredentials
|
return new OrderDownstreamCredentials
|
||||||
@ -84,9 +97,28 @@ public class OrderDownstreamCredentialResolver(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var appInfo = await itfxMetadataClient.GetAppAsync(group.AppId, cancellationToken);
|
var appInfo = await itfxMetadataClient.GetAppAsync(group.AppId, cancellationToken);
|
||||||
if (appInfo == null || string.IsNullOrWhiteSpace(appInfo.AppKey) || string.IsNullOrWhiteSpace(appInfo.AppSecret))
|
var resolvedAppKey = appInfo?.AppKey?.Trim();
|
||||||
|
var resolvedAppSecret = appInfo?.AppSecret?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedAppKey) || string.IsNullOrWhiteSpace(resolvedAppSecret))
|
||||||
{
|
{
|
||||||
throw new BusinessException("应用 AppKey / AppSecret 未配置");
|
var fromCenter = await TryResolveCredentialsFromApiCenterAsync(cancellationToken);
|
||||||
|
if (fromCenter != null)
|
||||||
|
{
|
||||||
|
resolvedAppKey = string.IsNullOrWhiteSpace(resolvedAppKey) ? fromCenter.AppKey : resolvedAppKey;
|
||||||
|
resolvedAppSecret = string.IsNullOrWhiteSpace(resolvedAppSecret) ? fromCenter.AppSecret : resolvedAppSecret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedAppKey) || string.IsNullOrWhiteSpace(resolvedAppSecret))
|
||||||
|
{
|
||||||
|
if (appInfo == null)
|
||||||
|
{
|
||||||
|
throw new BusinessException(
|
||||||
|
"无法从 API 中心获取应用信息,请检查「其他配置-API中心服务」地址/AppKey/AppSecret,以及服务商应用是否存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BusinessException(
|
||||||
|
"应用 AppKey / AppSecret 未配置,请在「其他配置-API中心服务」填写应用凭证");
|
||||||
}
|
}
|
||||||
|
|
||||||
var sp = await itfxMetadataClient.GetServiceProviderAsync(group.ApiServiceProviderId, cancellationToken);
|
var sp = await itfxMetadataClient.GetServiceProviderAsync(group.ApiServiceProviderId, cancellationToken);
|
||||||
@ -98,8 +130,42 @@ public class OrderDownstreamCredentialResolver(
|
|||||||
return new OrderDownstreamCredentials
|
return new OrderDownstreamCredentials
|
||||||
{
|
{
|
||||||
SupplierCode = sp.Code.Trim(),
|
SupplierCode = sp.Code.Trim(),
|
||||||
AppKey = appInfo.AppKey.Trim(),
|
AppKey = resolvedAppKey,
|
||||||
AppSecret = appInfo.AppSecret.Trim()
|
AppSecret = resolvedAppSecret
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> TryResolveAppSecretFromApiCenterAsync(
|
||||||
|
string expectedAppKey,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cfg = await itfxGatewayConfigProvider.GetAsync(cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.AppKey) || string.IsNullOrWhiteSpace(cfg.AppSecret))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(cfg.AppKey.Trim(), expectedAppKey.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg.AppSecret.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrderDownstreamCredentials?> TryResolveCredentialsFromApiCenterAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cfg = await itfxGatewayConfigProvider.GetAsync(cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.AppKey) || string.IsNullOrWhiteSpace(cfg.AppSecret))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OrderDownstreamCredentials
|
||||||
|
{
|
||||||
|
AppKey = cfg.AppKey.Trim(),
|
||||||
|
AppSecret = cfg.AppSecret.Trim()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Oms.Application.Services.Storage;
|
||||||
|
|
||||||
namespace Oms.Application.Storage;
|
namespace Oms.Application.Storage;
|
||||||
|
|
||||||
@ -25,6 +26,12 @@ public static class FileStorageServiceExtensions
|
|||||||
services.AddSingleton<IFileStorage, LocalFileStorage>();
|
services.AddSingleton<IFileStorage, LocalFileStorage>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 面单打印:服务端代拉 COS 预签名 URL(浏览器无 CORS)
|
||||||
|
services.AddHttpClient(FileStorageService.PrintProxyHttpClientName, client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromMinutes(2);
|
||||||
|
});
|
||||||
|
|
||||||
services.AddHostedService<FileStorageStartupLogger>();
|
services.AddHostedService<FileStorageStartupLogger>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,4 +49,9 @@ public static class SystemOtherConfigCodes
|
|||||||
/// 全局默认面单模板(AppKey=模板 Id,Enabled 控制是否生效)。
|
/// 全局默认面单模板(AppKey=模板 Id,Enabled 控制是否生效)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string LabelDefaultTemplate = "label_default_template";
|
public const string LabelDefaultTemplate = "label_default_template";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API 中心(接口通 / itfx)网关:BaseUrl=itfx 根地址,AppKey/AppSecret=默认应用凭证,TimeoutSeconds=超时。
|
||||||
|
/// </summary>
|
||||||
|
public const string ApiCenter = "api_center";
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user