20261008-ide1.0发布 #1

Merged
zzh merged 42 commits from test into master 2026-10-08 09:18:00 +08:00
4 changed files with 121 additions and 28 deletions
Showing only changes of commit 1b6e91b09d - Show all commits

View File

@ -8,9 +8,6 @@ namespace Oms.Application.Dtos.Orders;
/// </summary>
public class OpenApiParcelCreateInput
{
/// <summary>请求标识(幂等,可选)。</summary>
public string? RequestId { get; set; }
/// <summary>客户单号。</summary>
public string? ClientNo { get; set; }
@ -107,7 +104,7 @@ public class OpenApiParcelCreateInput
public OrderParcelCreateInput ToOrderParcelCreateInput(int customerId) =>
new()
{
RequestId = RequestId,
RequestId = null,
ClientNo = ClientNo,
TrackNo = TrackNo,
ServiceNo = ServiceNo,

View File

@ -1,5 +1,8 @@
using System.Text;
using System.Text.Json;
using Fake.Application;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.OpenApi;
using Oms.Application.Dtos.Orders;
@ -11,11 +14,16 @@ namespace Oms.Application.Services.OpenApi;
/// <summary>
/// 对外开放 API(Swagger 文档分组:openApi)。
/// 统一响应:{ code, msg, data }。
/// 客户机机鉴权:请求头 appKey + appSecret(勿放 Body,避免落日志)。
/// 客户下单鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。
/// </summary>
[ApiExplorerSettings(GroupName = "openApi")]
public class OpenApiService : ApplicationService
{
private static readonly JsonSerializerOptions BodyJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private CustomerOpenApiAuthHelper AuthHelper =>
LazyServiceProvider.GetRequiredService<CustomerOpenApiAuthHelper>();
@ -25,6 +33,9 @@ public class OpenApiService : ApplicationService
private OrderService OrderService =>
LazyServiceProvider.GetRequiredService<OrderService>();
private IHttpContextAccessor HttpContextAccessor =>
LazyServiceProvider.GetRequiredService<IHttpContextAccessor>();
/// <summary>
/// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需客户凭证)。
/// </summary>
@ -52,20 +63,27 @@ public class OpenApiService : ApplicationService
/// <summary>
/// 创建订单包裹(仅自发货)。
/// 鉴权:Header appKey、appSecret;客户归属强制取自凭证,Body 不可指定 customerId。
/// Header:appKey、timestamp(Unix 秒)、sign。
/// sign = hex(HMAC-SHA256(appSecret, appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)))。
/// </summary>
[AllowAnonymous]
[HttpPost("/openapi/parcel/create")]
public async Task<OpenApiEnvelope<OrderParcelCreateOutput>> CreateParcelAsync(
[FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
[FromHeader(Name = CustomerOpenApiAuthHelper.AppSecretHeaderName)] string? appSecret,
[FromBody] OpenApiParcelCreateInput input)
[FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp,
[FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign)
{
try
{
ArgumentNullException.ThrowIfNull(input);
var rawBody = await ReadRawBodyAsync();
var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody);
var input = JsonSerializer.Deserialize<OpenApiParcelCreateInput>(rawBody, BodyJsonOptions);
if (input == null)
{
throw new BusinessException("请求体不能为空");
}
var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret);
var data = await OrderService.CreateParcelAsync(input.ToOrderParcelCreateInput(customer.Id));
return OpenApiEnvelope<OrderParcelCreateOutput>.Ok(data);
}
@ -73,6 +91,10 @@ public class OpenApiService : ApplicationService
{
return OpenApiEnvelope<OrderParcelCreateOutput>.Fail(ex.Message);
}
catch (JsonException)
{
return OpenApiEnvelope<OrderParcelCreateOutput>.Fail("请求体格式错误");
}
catch (Exception ex)
{
return OpenApiEnvelope<OrderParcelCreateOutput>.Fail(
@ -80,4 +102,25 @@ public class OpenApiService : ApplicationService
"500");
}
}
private async Task<string> ReadRawBodyAsync()
{
var http = HttpContextAccessor.HttpContext
?? throw new BusinessException("凭证无效");
var request = http.Request;
request.EnableBuffering();
if (request.Body.CanSeek)
{
request.Body.Position = 0;
}
using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
var raw = await reader.ReadToEndAsync();
if (request.Body.CanSeek)
{
request.Body.Position = 0;
}
return raw;
}
}

View File

@ -10,12 +10,16 @@ using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 客户 OpenAPI 机机鉴权:按请求头 AppKey + AppSecret 解析主客户。
/// 客户 OpenAPI 机机鉴权:appKey + timestamp + HMAC-SHA256(sign),Secret 仅本地参与签名。
/// </summary>
public class CustomerOpenApiAuthHelper : IScopedDependency
{
public const string AppKeyHeaderName = "appKey";
public const string AppSecretHeaderName = "appSecret";
public const string TimestampHeaderName = "timestamp";
public const string SignHeaderName = "sign";
/// <summary>签名时间窗(秒)。</summary>
public const int TimestampSkewSeconds = 300;
private readonly ICustomerOpenApiCredentialRepository _credentialRepository;
private readonly ICustomerRepository _customerRepository;
@ -32,38 +36,52 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
}
/// <summary>
/// 从当前请求头读取 appKey / appSecret 并鉴权。
/// 校验 Header 签名并解析主客户。
/// stringToSign = appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)
/// sign = hex(HMAC-SHA256(appSecret, stringToSign))
/// </summary>
public Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveFromHeadersAsync(
CancellationToken cancellationToken = default)
{
var http = _httpContextAccessor.HttpContext
?? throw new BusinessException("凭证无效");
var appKey = http.Request.Headers[AppKeyHeaderName].FirstOrDefault();
var appSecret = http.Request.Headers[AppSecretHeaderName].FirstOrDefault();
return ResolveAsync(appKey, appSecret, cancellationToken);
}
public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveAsync(
public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveSignedAsync(
string? appKey,
string? appSecret,
string? timestamp,
string? sign,
string? rawBody,
CancellationToken cancellationToken = default)
{
var key = appKey?.Trim();
var secret = appSecret?.Trim();
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret))
var tsRaw = timestamp?.Trim();
var signRaw = sign?.Trim();
if (string.IsNullOrWhiteSpace(key)
|| string.IsNullOrWhiteSpace(tsRaw)
|| string.IsNullOrWhiteSpace(signRaw))
{
throw new BusinessException("凭证无效");
}
if (!long.TryParse(tsRaw, out var unixSeconds))
{
throw new BusinessException("请求已过期");
}
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - unixSeconds) > TimestampSkewSeconds)
{
throw new BusinessException("请求已过期");
}
var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken);
if (credential == null
|| !credential.Enabled
|| !FixedTimeEqualsUtf8(credential.AppSecret, secret))
|| string.IsNullOrWhiteSpace(credential.AppSecret))
{
throw new BusinessException("凭证无效");
}
var expectedSign = ComputeSign(key, tsRaw, rawBody ?? string.Empty, credential.AppSecret);
if (!FixedTimeEqualsUtf8(expectedSign, signRaw.ToLowerInvariant()))
{
throw new BusinessException("签名无效");
}
var customer = await _customerRepository.FirstOrDefaultAsync(
x => x.Id == credential.CustomerId,
cancellationToken);
@ -81,6 +99,30 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
return (customer, credential);
}
/// <summary>
/// 客户端可复用:计算 sign(小写 hex)。
/// </summary>
public static string ComputeSign(string appKey, string timestamp, string rawBody, string appSecret)
{
var bodyHash = Sha256Hex(rawBody ?? string.Empty);
var stringToSign = $"{appKey}\n{timestamp}\n{bodyHash}";
return HmacSha256Hex(appSecret, stringToSign);
}
private static string Sha256Hex(string text)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(text));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static string HmacSha256Hex(string secret, string content)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
var contentBytes = Encoding.UTF8.GetBytes(content);
var hash = HMACSHA256.HashData(keyBytes, contentBytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static bool FixedTimeEqualsUtf8(string? expected, string actual)
{
if (string.IsNullOrEmpty(expected))

View File

@ -167,6 +167,17 @@ public class SimpleAdminApiModule : FakeModule
// 路由 - 必须在认证和授权之前
app.UseRouting();
// OpenAPI 验签需读取原始 Body,允许二次读流
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path.StartsWithSegments("/openapi"))
{
ctx.Request.EnableBuffering();
}
await next();
});
// 认证和授权
app.UseAuthentication();
app.UseAuthorization();