diff --git a/sql/migrate_customer_openapi_credential.sql b/sql/migrate_customer_openapi_credential.sql new file mode 100644 index 0000000..da9e138 --- /dev/null +++ b/sql/migrate_customer_openapi_credential.sql @@ -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; diff --git a/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs new file mode 100644 index 0000000..e9b8d70 --- /dev/null +++ b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs @@ -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; + + /// 默认掩码;仅 ensure(新建)/ reset-secret 返回完整明文。 + public string AppSecret { get; set; } = string.Empty; + + public bool Enabled { get; set; } + + public bool HasCredential { get; set; } + + /// 本次响应是否包含完整 AppSecret(前端可提示用户复制保存)。 + 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; } +} diff --git a/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs new file mode 100644 index 0000000..1381ab6 --- /dev/null +++ b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs @@ -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; + +/// +/// 客户 OpenAPI 凭证管理(门户当前用户 + 后台代管)。 +/// +[ApiExplorerSettings(GroupName = "OMS-Customer")] +public class CustomerOpenApiCredentialService : ApplicationService +{ + private const string SecretMask = "********"; + + private ICustomerRepository CustomerRepository => + LazyServiceProvider.GetRequiredService(); + + private ICustomerOpenApiCredentialRepository CredentialRepository => + LazyServiceProvider.GetRequiredService(); + + /// 当前登录主客户查看凭证(Secret 掩码)。 + [HttpGet("/customer/openapi-credential")] + public async Task GetMineAsync() + { + var customer = await GetCurrentMainCustomerOrThrowAsync(); + return await MapGetAsync(customer.Id); + } + + /// 当前登录主客户:无则生成并返回完整 Secret;已有则掩码返回。 + [HttpPost("/customer/openapi-credential/ensure")] + public async Task EnsureMineAsync() + { + var customer = await GetCurrentMainCustomerOrThrowAsync(); + return await EnsureAsync(customer.Id); + } + + /// 当前登录主客户重置 Secret。 + [HttpPost("/customer/openapi-credential/reset-secret")] + public async Task ResetMineSecretAsync() + { + var customer = await GetCurrentMainCustomerOrThrowAsync(); + return await ResetSecretAsync(customer.Id); + } + + /// 当前登录主客户启停。 + [HttpPost("/customer/openapi-credential/set-enabled")] + public async Task SetMineEnabledAsync( + [FromBody] CustomerOpenApiCredentialSetEnabledInput input) + { + var customer = await GetCurrentMainCustomerOrThrowAsync(); + return await SetEnabledAsync(customer.Id, input.Enabled); + } + + /// 后台按客户查看凭证。 + [HttpGet("/customer/openapi-credential/admin/get")] + public async Task AdminGetAsync([FromQuery] int customerId) + { + EnsureAdminSession(); + await EnsureMainCustomerAsync(customerId); + return await MapGetAsync(customerId); + } + + /// 后台确保生成凭证。 + [HttpPost("/customer/openapi-credential/admin/ensure")] + public async Task AdminEnsureAsync( + [FromBody] CustomerOpenApiCredentialAdminInput input) + { + EnsureAdminSession(); + await EnsureMainCustomerAsync(input.CustomerId); + return await EnsureAsync(input.CustomerId); + } + + /// 后台重置 Secret。 + [HttpPost("/customer/openapi-credential/admin/reset-secret")] + public async Task AdminResetSecretAsync( + [FromBody] CustomerOpenApiCredentialAdminInput input) + { + EnsureAdminSession(); + await EnsureMainCustomerAsync(input.CustomerId); + return await ResetSecretAsync(input.CustomerId); + } + + /// 后台启停。 + [HttpPost("/customer/openapi-credential/admin/set-enabled")] + public async Task AdminSetEnabledAsync( + [FromBody] CustomerOpenApiCredentialAdminSetEnabledInput input) + { + EnsureAdminSession(); + await EnsureMainCustomerAsync(input.CustomerId); + return await SetEnabledAsync(input.CustomerId, input.Enabled); + } + + private async Task 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 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 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 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 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 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) + }; + } +} diff --git a/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs b/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs new file mode 100644 index 0000000..3b44e75 --- /dev/null +++ b/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs @@ -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; + +/// +/// 客户 OpenAPI 机机入口(AllowAnonymous,AppKey + AppSecret)。 +/// +[ApiExplorerSettings(GroupName = "OMS-Customer")] +public class CustomerOpenApiService : ApplicationService +{ + private CustomerOpenApiAuthHelper AuthHelper => + LazyServiceProvider.GetRequiredService(); + + /// + /// 探针:校验凭证并返回客户身份。 + /// + [AllowAnonymous] + [HttpPost("/openapi/customer/whoami")] + public async Task 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 + }; +} diff --git a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs new file mode 100644 index 0000000..94e7458 --- /dev/null +++ b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs @@ -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; + +/// +/// 客户 OpenAPI 机机鉴权:按 AppKey+AppSecret 解析主客户,供 whoami 及后续业务接口复用。 +/// +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(); + } +} diff --git a/src/Oms.Domain/CustomersAggregate/Entities/CustomerOpenApiCredential.cs b/src/Oms.Domain/CustomersAggregate/Entities/CustomerOpenApiCredential.cs new file mode 100644 index 0000000..551c61f --- /dev/null +++ b/src/Oms.Domain/CustomersAggregate/Entities/CustomerOpenApiCredential.cs @@ -0,0 +1,30 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.CustomersAggregate.Entities; + +/// +/// 客户 OpenAPI 机机凭证(一主客户一对密钥,独立于 itfx)。 +/// +[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; } +} diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerOpenApiCredentialRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerOpenApiCredentialRepository.cs new file mode 100644 index 0000000..aefc686 --- /dev/null +++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerOpenApiCredentialRepository.cs @@ -0,0 +1,15 @@ +using Fake.Domain.Repositories; +using Oms.Domain.CustomersAggregate.Entities; + +namespace Oms.Domain.CustomersAggregate.Repositories; + +public interface ICustomerOpenApiCredentialRepository : IRepository +{ + Task GetByCustomerIdAsync( + int customerId, + CancellationToken cancellationToken = default); + + Task GetByAppKeyAsync( + string appKey, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.SqlSugarCore/Customers/CustomerOpenApiCredentialRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerOpenApiCredentialRepository.cs new file mode 100644 index 0000000..a002c28 --- /dev/null +++ b/src/Oms.SqlSugarCore/Customers/CustomerOpenApiCredentialRepository.cs @@ -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, + ICustomerOpenApiCredentialRepository, + IScopedDependency +{ + public Task GetByCustomerIdAsync( + int customerId, + CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.CustomerId == customerId, cancellationToken); + } + + public Task GetByAppKeyAsync( + string appKey, + CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.AppKey == appKey, cancellationToken); + } +}