feat: API中心其他配置与面单 COS 流代理
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:
zzh 2026-09-12 17:40:52 +08:00
parent 87abcecb65
commit 0fdc9e29f4
10 changed files with 340 additions and 25 deletions

View File

@ -3,13 +3,12 @@ using System.Net.Http.Json;
using System.Text.Json;
using Fake.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace Oms.Application.Integrations.Itfx;
internal sealed class ItfxForwardClient(
IHttpClientFactory httpClientFactory,
IOptions<ItfxGatewayOptions> options,
IItfxGatewayConfigProvider configProvider,
IHttpContextAccessor httpContextAccessor) : IItfxForwardClient, IScopedDependency
{
public const string HttpClientName = nameof(ItfxForwardClient);
@ -45,10 +44,10 @@ internal sealed class ItfxForwardClient(
object body,
CancellationToken cancellationToken = default)
{
var settings = options.Value;
var settings = await configProvider.GetAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
{
throw new InvalidOperationException("Itfx BaseUrl not configured");
throw new InvalidOperationException("API 中心服务地址未配置,请在「其他配置」中启用 api_center");
}
if (string.IsNullOrWhiteSpace(standardCode))

View File

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

View File

@ -12,6 +12,16 @@ public class ItfxGatewayOptions
/// </summary>
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;
/// <summary>

View File

@ -3,13 +3,12 @@ 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,
IItfxGatewayConfigProvider configProvider,
IHttpContextAccessor httpContextAccessor,
ILogger<ItfxMetadataClient> logger) : IItfxMetadataClient, IScopedDependency
{
@ -67,7 +66,7 @@ internal sealed class ItfxMetadataClient(
CancellationToken cancellationToken)
where T : class
{
var settings = options.Value;
var settings = await configProvider.GetAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
{
logger.LogDebug("Itfx BaseUrl not configured, skip {Url}", relativeUrl);

View File

@ -10,6 +10,7 @@ public static class ItfxServiceExtensions
services.Configure<ItfxGatewayOptions>(configuration.GetSection(ItfxGatewayOptions.SectionName));
services.AddHttpClient(ItfxMetadataClient.HttpClientName);
services.AddHttpClient(ItfxForwardClient.HttpClientName);
services.AddScoped<IItfxGatewayConfigProvider, ItfxGatewayConfigProvider>();
services.AddScoped<IItfxMetadataClient, ItfxMetadataClient>();
services.AddScoped<IItfxForwardClient, ItfxForwardClient>();
return services;

View File

@ -1,6 +1,8 @@
using System.Net.Http.Headers;
using Fake.Application;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Oms.Application.Storage;
namespace Oms.Application.Services.Storage;
@ -12,7 +14,12 @@ namespace Oms.Application.Services.Storage;
[ApiExplorerSettings(GroupName = "OMS-Storage")]
public class FileStorageService : ApplicationService
{
public const string PrintProxyHttpClientName = "FileStoragePrintProxy";
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
private IHttpClientFactory HttpClientFactory => LazyServiceProvider.GetRequiredService<IHttpClientFactory>();
private ILogger<FileStorageService> FileLogger =>
LazyServiceProvider.GetRequiredService<ILogger<FileStorageService>>();
/// <summary>
/// 是否支持前端直传(Cos=true)。
@ -111,6 +118,7 @@ public class FileStorageService : ApplicationService
/// <summary>
/// 按 key / URL 流式读取文件(供面单预览等场景,避免前端跨域 iframe 空白)。
/// 若对象键解析失败,但对受管 COS 的预签名绝对 URL 仍可服务端代拉(绕开浏览器 CORS)。
/// </summary>
[HttpGet("/file-storage/stream")]
public async Task<IActionResult> StreamAsync([FromQuery] string key, CancellationToken cancellationToken = default)
@ -120,43 +128,189 @@ public class FileStorageService : ApplicationService
throw new BusinessException("key 不能为空");
}
string storageKey;
var raw = key.Trim();
Exception? storageError = null;
try
{
storageKey = FileStorage.ToStorageKey(key) ?? FileStoragePath.NormalizeKey(key);
var storageKey = FileStorage.ToStorageKey(raw) ?? FileStoragePath.NormalizeKey(raw);
var stream = await FileStorage.OpenReadAsync(storageKey, cancellationToken);
return new FileStreamResult(stream, GuessStreamContentType(storageKey))
{
EnableRangeProcessing = true,
};
}
catch (Exception)
catch (Exception ex)
{
throw new BusinessException("无法解析文件 key");
storageError = ex;
FileLogger.LogWarning(ex, "file-storage/stream 按 key 读取失败,尝试签名 URL 代拉: {Key}", TruncateForLog(raw));
}
if (!await FileStorage.ExistsAsync(storageKey, cancellationToken))
if (IsAllowedPrintProxyUrl(raw))
{
throw new BusinessException("文件不存在或已过期");
try
{
return await ProxySignedUrlAsync(raw, cancellationToken);
}
catch (Exception ex)
{
FileLogger.LogWarning(ex, "file-storage/stream 签名 URL 代拉失败: {Key}", TruncateForLog(raw));
storageError ??= ex;
}
}
var stream = await FileStorage.OpenReadAsync(storageKey, cancellationToken);
return new FileStreamResult(stream, GuessStreamContentType(storageKey))
if (storageError is BusinessException business)
{
EnableRangeProcessing = true,
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)
{
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";
}
if (storageKey.EndsWith(".html", StringComparison.OrdinalIgnoreCase)
|| storageKey.EndsWith(".htm", StringComparison.OrdinalIgnoreCase))
if (path.EndsWith(".html", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".htm", StringComparison.OrdinalIgnoreCase)
|| path.Contains(".html?", StringComparison.OrdinalIgnoreCase)
|| path.Contains(".htm?", StringComparison.OrdinalIgnoreCase))
{
return "text/html; charset=utf-8";
}
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

View File

@ -171,6 +171,23 @@ public class SystemOtherConfigService : ApplicationService
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))
{
if (string.IsNullOrWhiteSpace(input.AppKey))

View File

@ -22,7 +22,8 @@ public sealed class OrderDownstreamCredentials
public class OrderDownstreamCredentialResolver(
IServiceChannelRepository serviceChannelRepository,
IServiceChannelGroupRepository serviceChannelGroupRepository,
IItfxMetadataClient itfxMetadataClient) : IScopedDependency
IItfxMetadataClient itfxMetadataClient,
IItfxGatewayConfigProvider itfxGatewayConfigProvider) : IScopedDependency
{
public async Task<OrderDownstreamCredentials> ResolveAsync(Order order, CancellationToken cancellationToken = default)
{
@ -40,7 +41,19 @@ public class OrderDownstreamCredentialResolver(
var appSecret = app?.AppSecret?.Trim();
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
@ -84,9 +97,28 @@ public class OrderDownstreamCredentialResolver(
}
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);
@ -98,8 +130,42 @@ public class OrderDownstreamCredentialResolver(
return new OrderDownstreamCredentials
{
SupplierCode = sp.Code.Trim(),
AppKey = appInfo.AppKey.Trim(),
AppSecret = appInfo.AppSecret.Trim()
AppKey = resolvedAppKey,
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()
};
}

View File

@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Oms.Application.Services.Storage;
namespace Oms.Application.Storage;
@ -25,6 +26,12 @@ public static class FileStorageServiceExtensions
services.AddSingleton<IFileStorage, LocalFileStorage>();
}
// 面单打印:服务端代拉 COS 预签名 URL(浏览器无 CORS)
services.AddHttpClient(FileStorageService.PrintProxyHttpClientName, client =>
{
client.Timeout = TimeSpan.FromMinutes(2);
});
services.AddHostedService<FileStorageStartupLogger>();
return services;
}

View File

@ -49,4 +49,9 @@ public static class SystemOtherConfigCodes
/// 全局默认面单模板(AppKey=模板 Id,Enabled 控制是否生效)。
/// </summary>
public const string LabelDefaultTemplate = "label_default_template";
/// <summary>
/// API 中心(接口通 / itfx)网关:BaseUrl=itfx 根地址,AppKey/AppSecret=默认应用凭证,TimeoutSeconds=超时。
/// </summary>
public const string ApiCenter = "api_center";
}