feat: 客户 OpenAPI 机机凭证与 whoami 探针
All checks were successful
Build and Deploy / deploy (push) Successful in 58s

一主客户一对 AppKey/AppSecret,支持门户自助与后台代管启停重置,供应商回调路径不动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-30 10:19:38 +08:00
parent 955a3c8c6d
commit 2688058cb3
8 changed files with 578 additions and 0 deletions

View File

@ -0,0 +1,41 @@
-- 客户 OpenAPI 机机凭证(一客户一对 AppKey/AppSecret,独立于 itfx)
-- 可重复执行。
-- 目标表:t_customer_openapi_credential
SET @db := DATABASE();
SET @exists := (
SELECT COUNT(1)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_customer_openapi_credential'
);
SET @sql := IF(
@exists = 0,
'CREATE TABLE `t_customer_openapi_credential` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
`CustomerId` INT NOT NULL COMMENT ''归属主客户 t_customer.Id'',
`AppKey` VARCHAR(64) NOT NULL COMMENT ''开放接口 AppKey'',
`AppSecret` VARCHAR(128) NOT NULL COMMENT ''开放接口 AppSecret(明文,创建/重置时返回)'',
`Enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:0-否,1-是'',
`LastUsedAt` DATETIME NULL COMMENT ''最近鉴权成功时间'',
`LastUsedIp` VARCHAR(64) NULL COMMENT ''最近鉴权成功 IP'',
`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_coc_customer` (`CustomerId`),
UNIQUE KEY `uk_coc_app_key` (`AppKey`),
KEY `idx_coc_enabled` (`Enabled`, `IsDeleted`)
) COMMENT=''客户 OpenAPI 机机凭证''',
'SELECT ''t_customer_openapi_credential already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,66 @@
namespace Oms.Application.Dtos.Customers;
public class CustomerOpenApiCredentialOutput
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string AppKey { get; set; } = string.Empty;
/// <summary>默认掩码;仅 ensure(新建)/ reset-secret 返回完整明文。</summary>
public string AppSecret { get; set; } = string.Empty;
public bool Enabled { get; set; }
public bool HasCredential { get; set; }
/// <summary>本次响应是否包含完整 AppSecret(前端可提示用户复制保存)。</summary>
public bool SecretRevealed { get; set; }
public DateTime? LastUsedAt { get; set; }
public string? LastUsedIp { get; set; }
public DateTime? CreateTime { get; set; }
public DateTime? UpdateTime { get; set; }
}
public class CustomerOpenApiCredentialAdminInput
{
public int CustomerId { get; set; }
}
public class CustomerOpenApiCredentialSetEnabledInput
{
public bool Enabled { get; set; }
}
public class CustomerOpenApiCredentialAdminSetEnabledInput : CustomerOpenApiCredentialAdminInput
{
public bool Enabled { get; set; }
}
public class CustomerOpenApiWhoAmIInput
{
public string? AppSecret { get; set; }
}
public class CustomerOpenApiWhoAmIResult
{
public int CustomerId { get; set; }
public string CustomerCode { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
}
public class CustomerOpenApiEnvelope
{
public string Code { get; set; } = "200";
public string Msg { get; set; } = "成功";
public CustomerOpenApiWhoAmIResult? Data { get; set; }
}

View File

@ -0,0 +1,255 @@
using System.Security.Cryptography;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Customers;
using Oms.Application.Shared;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Services.Customers;
/// <summary>
/// 客户 OpenAPI 凭证管理(门户当前用户 + 后台代管)。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Customer")]
public class CustomerOpenApiCredentialService : ApplicationService
{
private const string SecretMask = "********";
private ICustomerRepository CustomerRepository =>
LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private ICustomerOpenApiCredentialRepository CredentialRepository =>
LazyServiceProvider.GetRequiredService<ICustomerOpenApiCredentialRepository>();
/// <summary>当前登录主客户查看凭证(Secret 掩码)。</summary>
[HttpGet("/customer/openapi-credential")]
public async Task<CustomerOpenApiCredentialOutput> GetMineAsync()
{
var customer = await GetCurrentMainCustomerOrThrowAsync();
return await MapGetAsync(customer.Id);
}
/// <summary>当前登录主客户:无则生成并返回完整 Secret;已有则掩码返回。</summary>
[HttpPost("/customer/openapi-credential/ensure")]
public async Task<CustomerOpenApiCredentialOutput> EnsureMineAsync()
{
var customer = await GetCurrentMainCustomerOrThrowAsync();
return await EnsureAsync(customer.Id);
}
/// <summary>当前登录主客户重置 Secret。</summary>
[HttpPost("/customer/openapi-credential/reset-secret")]
public async Task<CustomerOpenApiCredentialOutput> ResetMineSecretAsync()
{
var customer = await GetCurrentMainCustomerOrThrowAsync();
return await ResetSecretAsync(customer.Id);
}
/// <summary>当前登录主客户启停。</summary>
[HttpPost("/customer/openapi-credential/set-enabled")]
public async Task<CustomerOpenApiCredentialOutput> SetMineEnabledAsync(
[FromBody] CustomerOpenApiCredentialSetEnabledInput input)
{
var customer = await GetCurrentMainCustomerOrThrowAsync();
return await SetEnabledAsync(customer.Id, input.Enabled);
}
/// <summary>后台按客户查看凭证。</summary>
[HttpGet("/customer/openapi-credential/admin/get")]
public async Task<CustomerOpenApiCredentialOutput> AdminGetAsync([FromQuery] int customerId)
{
EnsureAdminSession();
await EnsureMainCustomerAsync(customerId);
return await MapGetAsync(customerId);
}
/// <summary>后台确保生成凭证。</summary>
[HttpPost("/customer/openapi-credential/admin/ensure")]
public async Task<CustomerOpenApiCredentialOutput> AdminEnsureAsync(
[FromBody] CustomerOpenApiCredentialAdminInput input)
{
EnsureAdminSession();
await EnsureMainCustomerAsync(input.CustomerId);
return await EnsureAsync(input.CustomerId);
}
/// <summary>后台重置 Secret。</summary>
[HttpPost("/customer/openapi-credential/admin/reset-secret")]
public async Task<CustomerOpenApiCredentialOutput> AdminResetSecretAsync(
[FromBody] CustomerOpenApiCredentialAdminInput input)
{
EnsureAdminSession();
await EnsureMainCustomerAsync(input.CustomerId);
return await ResetSecretAsync(input.CustomerId);
}
/// <summary>后台启停。</summary>
[HttpPost("/customer/openapi-credential/admin/set-enabled")]
public async Task<CustomerOpenApiCredentialOutput> AdminSetEnabledAsync(
[FromBody] CustomerOpenApiCredentialAdminSetEnabledInput input)
{
EnsureAdminSession();
await EnsureMainCustomerAsync(input.CustomerId);
return await SetEnabledAsync(input.CustomerId, input.Enabled);
}
private async Task<CustomerOpenApiCredentialOutput> MapGetAsync(int customerId)
{
var entity = await CredentialRepository.GetByCustomerIdAsync(customerId);
if (entity == null)
{
return new CustomerOpenApiCredentialOutput
{
CustomerId = customerId,
HasCredential = false,
Enabled = false,
AppKey = string.Empty,
AppSecret = string.Empty,
SecretRevealed = false
};
}
return MapOutput(entity, revealSecret: false);
}
private async Task<CustomerOpenApiCredentialOutput> EnsureAsync(int customerId)
{
var existing = await CredentialRepository.GetByCustomerIdAsync(customerId);
if (existing != null)
{
return MapOutput(existing, revealSecret: false);
}
var entity = new CustomerOpenApiCredential
{
CustomerId = customerId,
AppKey = await GenerateUniqueAppKeyAsync(),
AppSecret = GenerateAppSecret(),
Enabled = true
};
entity = await CredentialRepository.InsertAsync(entity);
return MapOutput(entity, revealSecret: true);
}
private async Task<CustomerOpenApiCredentialOutput> ResetSecretAsync(int customerId)
{
var entity = await CredentialRepository.GetByCustomerIdAsync(customerId);
if (entity == null)
{
throw new BusinessException("尚未生成开放接口凭证,请先生成");
}
entity.AppSecret = GenerateAppSecret();
await CredentialRepository.UpdateAsync(entity);
return MapOutput(entity, revealSecret: true);
}
private async Task<CustomerOpenApiCredentialOutput> SetEnabledAsync(int customerId, bool enabled)
{
var entity = await CredentialRepository.GetByCustomerIdAsync(customerId);
if (entity == null)
{
throw new BusinessException("尚未生成开放接口凭证,请先生成");
}
entity.Enabled = enabled;
await CredentialRepository.UpdateAsync(entity);
return MapOutput(entity, revealSecret: false);
}
private async Task EnsureMainCustomerAsync(int customerId)
{
if (customerId <= 0)
{
throw new BusinessException("客户ID不能为空");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
if (customer.ParentCustomerId.HasValue)
{
throw new BusinessException("仅主客户可配置开放接口凭证");
}
}
private async Task<Customer> GetCurrentMainCustomerOrThrowAsync()
{
if (!OmsSessionHelper.IsCustomerLogin(CurrentUser))
{
throw new BusinessException("仅客户门户可调用此接口");
}
var mapId = OmsSessionHelper.TryGetCustomerMapId(CurrentUser);
if (!mapId.HasValue || mapId.Value <= 0)
{
throw new BusinessException("客户未登录");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == mapId.Value);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
if (customer.ParentCustomerId.HasValue)
{
throw new BusinessException("仅主客户可管理开放接口凭证");
}
return customer;
}
private void EnsureAdminSession()
{
if (OmsSessionHelper.IsCustomerLogin(CurrentUser))
{
throw new BusinessException("无权限");
}
}
private async Task<string> GenerateUniqueAppKeyAsync()
{
for (var i = 0; i < 8; i++)
{
var key = $"oms_{Guid.NewGuid():N}"[..20];
var existed = await CredentialRepository.GetByAppKeyAsync(key);
if (existed == null)
{
return key;
}
}
throw new BusinessException("生成 AppKey 失败,请重试");
}
private static string GenerateAppSecret()
{
var bytes = RandomNumberGenerator.GetBytes(24);
return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', 'x').Replace('/', 'y');
}
private static CustomerOpenApiCredentialOutput MapOutput(
CustomerOpenApiCredential entity,
bool revealSecret)
{
return new CustomerOpenApiCredentialOutput
{
Id = entity.Id,
CustomerId = entity.CustomerId,
AppKey = entity.AppKey,
AppSecret = revealSecret ? entity.AppSecret : SecretMask,
Enabled = entity.Enabled,
HasCredential = true,
SecretRevealed = revealSecret,
LastUsedAt = entity.LastUsedAt,
LastUsedIp = entity.LastUsedIp,
CreateTime = entity.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
}
}

View File

@ -0,0 +1,63 @@
using Fake.Application;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Customers;
using Oms.Application.Shared;
namespace Oms.Application.Services.Customers;
/// <summary>
/// 客户 OpenAPI 机机入口(AllowAnonymous,AppKey + AppSecret)。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Customer")]
public class CustomerOpenApiService : ApplicationService
{
private CustomerOpenApiAuthHelper AuthHelper =>
LazyServiceProvider.GetRequiredService<CustomerOpenApiAuthHelper>();
/// <summary>
/// 探针:校验凭证并返回客户身份。
/// </summary>
[AllowAnonymous]
[HttpPost("/openapi/customer/whoami")]
public async Task<CustomerOpenApiEnvelope> WhoAmIAsync(
[FromQuery] string? appKey,
[FromHeader(Name = "appSecret")] string? appSecretHeader,
[FromBody] CustomerOpenApiWhoAmIInput? body)
{
try
{
var appSecret = !string.IsNullOrWhiteSpace(appSecretHeader)
? appSecretHeader
: body?.AppSecret;
var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret);
return new CustomerOpenApiEnvelope
{
Code = "200",
Msg = "成功",
Data = new CustomerOpenApiWhoAmIResult
{
CustomerId = customer.Id,
CustomerCode = customer.CustomerCode,
CustomerName = customer.CustomerName
}
};
}
catch (BusinessException ex)
{
return Fail(ex.Message);
}
catch (Exception ex)
{
return Fail(string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message, "500");
}
}
private static CustomerOpenApiEnvelope Fail(string msg, string code = "401") =>
new()
{
Code = code,
Msg = msg,
Data = null
};
}

View File

@ -0,0 +1,82 @@
using Fake.Application;
using Fake.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 客户 OpenAPI 机机鉴权:按 AppKey+AppSecret 解析主客户,供 whoami 及后续业务接口复用。
/// </summary>
public class CustomerOpenApiAuthHelper : IScopedDependency
{
private readonly ICustomerOpenApiCredentialRepository _credentialRepository;
private readonly ICustomerRepository _customerRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
public CustomerOpenApiAuthHelper(
ICustomerOpenApiCredentialRepository credentialRepository,
ICustomerRepository customerRepository,
IHttpContextAccessor httpContextAccessor)
{
_credentialRepository = credentialRepository;
_customerRepository = customerRepository;
_httpContextAccessor = httpContextAccessor;
}
public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveAsync(
string? appKey,
string? appSecret,
CancellationToken cancellationToken = default)
{
var key = appKey?.Trim();
var secret = appSecret?.Trim();
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret))
{
throw new BusinessException("凭证无效");
}
var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken);
if (credential == null
|| !credential.Enabled
|| !string.Equals(credential.AppSecret, secret, StringComparison.Ordinal))
{
throw new BusinessException("凭证无效");
}
var customer = await _customerRepository.FirstOrDefaultAsync(
x => x.Id == credential.CustomerId,
cancellationToken);
if (customer == null
|| customer.ParentCustomerId.HasValue
|| customer.Status != CustomerStatus.Normal)
{
throw new BusinessException("凭证无效");
}
credential.LastUsedAt = DateTime.Now;
credential.LastUsedIp = ResolveClientIp();
await _credentialRepository.UpdateAsync(credential);
return (customer, credential);
}
private string? ResolveClientIp()
{
var http = _httpContextAccessor.HttpContext;
if (http == null)
{
return null;
}
var forwarded = http.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(forwarded))
{
return forwarded.Split(',')[0].Trim();
}
return http.Connection.RemoteIpAddress?.ToString();
}
}

View File

@ -0,0 +1,30 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.CustomersAggregate.Entities;
/// <summary>
/// 客户 OpenAPI 机机凭证(一主客户一对密钥,独立于 itfx)。
/// </summary>
[SugarTable("t_customer_openapi_credential")]
public class CustomerOpenApiCredential : OmsAuditedAggregateRoot
{
public int CustomerId { get; set; }
[SugarColumn(Length = 64)]
public string AppKey { get; set; } = string.Empty;
[SugarColumn(Length = 128)]
public string AppSecret { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
[SugarColumn(IsNullable = true)]
public DateTime? LastUsedAt { get; set; }
[SugarColumn(Length = 64, IsNullable = true)]
public string? LastUsedIp { get; set; }
[SugarColumn(Length = 512, IsNullable = true)]
public string? Remark { get; set; }
}

View File

@ -0,0 +1,15 @@
using Fake.Domain.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
namespace Oms.Domain.CustomersAggregate.Repositories;
public interface ICustomerOpenApiCredentialRepository : IRepository<CustomerOpenApiCredential>
{
Task<CustomerOpenApiCredential?> GetByCustomerIdAsync(
int customerId,
CancellationToken cancellationToken = default);
Task<CustomerOpenApiCredential?> GetByAppKeyAsync(
string appKey,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,26 @@
using Fake.DependencyInjection;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
namespace Oms.SqlSugarCore.Customers;
public class CustomerOpenApiCredentialRepository
: OmsSqlSugarRepository<CustomerOpenApiCredential>,
ICustomerOpenApiCredentialRepository,
IScopedDependency
{
public Task<CustomerOpenApiCredential?> GetByCustomerIdAsync(
int customerId,
CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.CustomerId == customerId, cancellationToken);
}
public Task<CustomerOpenApiCredential?> GetByAppKeyAsync(
string appKey,
CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.AppKey == appKey, cancellationToken);
}
}