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);
+ }
+}