feat: 店铺平台 OAuth 配置迁入其他配置页

运行时优先读 t_system_other_config,修正管理端客户提示文案,并提供配置种子 SQL。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-16 11:03:02 +08:00
parent 674cbdd011
commit c2c03f63e3
8 changed files with 294 additions and 21 deletions

View File

@ -0,0 +1,47 @@
-- 店铺平台 OAuth 配置种子(其他配置页可改;已存在则跳过)
INSERT INTO t_system_other_config
(ConfigCode, ConfigName, Enabled, BaseUrl, AppKey, AppSecret, TimeoutSeconds, Remark, IsDeleted, CreateTime)
SELECT 'platform_shop', '店铺授权-公共', 1, 'http://localhost:5173', NULL, NULL, 30, NULL, 0, NOW()
WHERE NOT EXISTS (SELECT 1 FROM t_system_other_config WHERE ConfigCode = 'platform_shop' AND IsDeleted = 0);
INSERT INTO t_system_other_config
(ConfigCode, ConfigName, Enabled, BaseUrl, AppKey, AppSecret, TimeoutSeconds, Remark, IsDeleted, CreateTime)
SELECT 'platform_shop_tiktok', '店铺授权-TikTok', 1,
'https://auth.tiktok-shops.com',
'6hc64lc6vl312',
'5a691a96ee6d844534769bab5c21de1f848fb678',
30,
'https://open-api.tiktokglobalshop.com',
0, NOW()
WHERE NOT EXISTS (SELECT 1 FROM t_system_other_config WHERE ConfigCode = 'platform_shop_tiktok' AND IsDeleted = 0);
INSERT INTO t_system_other_config
(ConfigCode, ConfigName, Enabled, BaseUrl, AppKey, AppSecret, TimeoutSeconds, Remark, IsDeleted, CreateTime)
SELECT 'platform_shop_temu', '店铺授权-Temu', 1,
'https://openapi-b-us.temu.com/openapi/router',
'4ebbc9190ae410443d65b4c2faca981f',
'4782d2d827276688bf4758bed55dbdd4bbe79a79',
30, NULL, 0, NOW()
WHERE NOT EXISTS (SELECT 1 FROM t_system_other_config WHERE ConfigCode = 'platform_shop_temu' AND IsDeleted = 0);
INSERT INTO t_system_other_config
(ConfigCode, ConfigName, Enabled, BaseUrl, AppKey, AppSecret, TimeoutSeconds, Remark, IsDeleted, CreateTime)
SELECT 'platform_shop_shopee', '店铺授权-Shopee', 1,
'https://partner.shopeemobile.com',
'2004112',
'2f385d7ea38b087d5d71f7292e06a09c9f19022ca6c3f60ec0110a8214ef7d65',
30,
'https://openplatform.shopee.cn',
0, NOW()
WHERE NOT EXISTS (SELECT 1 FROM t_system_other_config WHERE ConfigCode = 'platform_shop_shopee' AND IsDeleted = 0);
INSERT INTO t_system_other_config
(ConfigCode, ConfigName, Enabled, BaseUrl, AppKey, AppSecret, TimeoutSeconds, Remark, IsDeleted, CreateTime)
SELECT 'platform_shop_lazada', '店铺授权-Lazada', 1,
'https://auth.lazada.com/oauth/authorize',
'123631',
'7vBz2EaD6rIupeGEfwrgkabsTNsXt2ix',
30,
'https://auth.lazada.com/rest',
0, NOW()
WHERE NOT EXISTS (SELECT 1 FROM t_system_other_config WHERE ConfigCode = 'platform_shop_lazada' AND IsDeleted = 0);

View File

@ -2,13 +2,12 @@ using System.Net.Http.Json;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Oms.Application.Integrations.PlatformShop; namespace Oms.Application.Integrations.PlatformShop;
public interface IPlatformShopApiClient public interface IPlatformShopApiClient
{ {
string BuildAuthUrl(string platformCode, string redirectUri, string state, string? siteCountry); Task<string> BuildAuthUrlAsync(string platformCode, string redirectUri, string state, string? siteCountry, CancellationToken ct = default);
Task<PlatformShopTokenResult> ExchangeTokenAsync(PlatformShopTokenExchangeRequest request, CancellationToken ct = default); Task<PlatformShopTokenResult> ExchangeTokenAsync(PlatformShopTokenExchangeRequest request, CancellationToken ct = default);
Task<PlatformShopTokenResult> CreateTemuStoreTokenAsync(string authorizationCode, CancellationToken ct = default); Task<PlatformShopTokenResult> CreateTemuStoreTokenAsync(string authorizationCode, CancellationToken ct = default);
} }
@ -45,15 +44,26 @@ public sealed class PlatformShopBoundShop
internal sealed class PlatformShopApiClient( internal sealed class PlatformShopApiClient(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<PlatformShopOptions> options, IPlatformShopConfigProvider configProvider,
ILogger<PlatformShopApiClient> logger) : IPlatformShopApiClient ILogger<PlatformShopApiClient> logger) : IPlatformShopApiClient
{ {
public const string HttpClientName = nameof(PlatformShopApiClient); public const string HttpClientName = nameof(PlatformShopApiClient);
private readonly PlatformShopOptions _options = options.Value; private PlatformShopOptions _options = new();
public string BuildAuthUrl(string platformCode, string redirectUri, string state, string? siteCountry) private async Task LoadOptionsAsync(CancellationToken ct = default)
{ {
_options = await configProvider.GetAsync(ct);
}
public async Task<string> BuildAuthUrlAsync(
string platformCode,
string redirectUri,
string state,
string? siteCountry,
CancellationToken ct = default)
{
await LoadOptionsAsync(ct);
return platformCode.ToLowerInvariant() switch return platformCode.ToLowerInvariant() switch
{ {
"lazada" => BuildLazadaAuthUrl(redirectUri, siteCountry), "lazada" => BuildLazadaAuthUrl(redirectUri, siteCountry),
@ -67,6 +77,7 @@ internal sealed class PlatformShopApiClient(
PlatformShopTokenExchangeRequest request, PlatformShopTokenExchangeRequest request,
CancellationToken ct = default) CancellationToken ct = default)
{ {
await LoadOptionsAsync(ct);
return request.PlatformCode.ToLowerInvariant() switch return request.PlatformCode.ToLowerInvariant() switch
{ {
"lazada" => await ExchangeLazadaAsync(request.Code, ct), "lazada" => await ExchangeLazadaAsync(request.Code, ct),
@ -78,6 +89,7 @@ internal sealed class PlatformShopApiClient(
public async Task<PlatformShopTokenResult> CreateTemuStoreTokenAsync(string authorizationCode, CancellationToken ct = default) public async Task<PlatformShopTokenResult> CreateTemuStoreTokenAsync(string authorizationCode, CancellationToken ct = default)
{ {
await LoadOptionsAsync(ct);
EnsureTemuConfigured(); EnsureTemuConfigured();
var temu = _options.Temu; var temu = _options.Temu;
var body = new Dictionary<string, string> var body = new Dictionary<string, string>

View File

@ -0,0 +1,147 @@
using Fake.DependencyInjection;
using Microsoft.Extensions.Options;
using Oms.Domain.SystemAggregate.Consts;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Integrations.PlatformShop;
/// <summary>
/// 平台店铺 OAuth 运行时配置:优先读「其他配置」,回退 appsettings。
/// </summary>
public interface IPlatformShopConfigProvider
{
Task<PlatformShopOptions> GetAsync(CancellationToken cancellationToken = default);
}
internal sealed class PlatformShopConfigProvider(
ISystemOtherConfigRepository repository,
IOptions<PlatformShopOptions> options) : IPlatformShopConfigProvider, IScopedDependency
{
public async Task<PlatformShopOptions> GetAsync(CancellationToken cancellationToken = default)
{
var fallback = options.Value ?? new PlatformShopOptions();
var common = await repository.GetByConfigCodeAsync(SystemOtherConfigCodes.PlatformShop, cancellationToken);
var lazada = await repository.GetByConfigCodeAsync(SystemOtherConfigCodes.PlatformShopLazada, cancellationToken);
var shopee = await repository.GetByConfigCodeAsync(SystemOtherConfigCodes.PlatformShopShopee, cancellationToken);
var tiktok = await repository.GetByConfigCodeAsync(SystemOtherConfigCodes.PlatformShopTiktok, cancellationToken);
var temu = await repository.GetByConfigCodeAsync(SystemOtherConfigCodes.PlatformShopTemu, cancellationToken);
return new PlatformShopOptions
{
FrontendBaseUrl = Coalesce(common?.BaseUrl, fallback.FrontendBaseUrl),
StateSecret = Coalesce(common?.Remark, fallback.StateSecret),
Lazada = MergeLazada(lazada, fallback.Lazada),
Shopee = MergeShopee(shopee, fallback.Shopee),
Tiktok = MergeTiktok(tiktok, fallback.Tiktok),
Temu = MergeTemu(temu, fallback.Temu)
};
}
private static LazadaPlatformOptions MergeLazada(SystemOtherConfig? entity, LazadaPlatformOptions fallback)
{
fallback ??= new LazadaPlatformOptions();
if (entity == null || !entity.Enabled)
{
return CloneLazada(fallback);
}
return new LazadaPlatformOptions
{
AppKey = Coalesce(entity.AppKey, fallback.AppKey),
AppSecret = Coalesce(entity.AppSecret, fallback.AppSecret),
AuthorizeUrl = Coalesce(entity.BaseUrl, fallback.AuthorizeUrl),
AccessTokenUrl = Coalesce(entity.Remark, fallback.AccessTokenUrl)
};
}
private static ShopeePlatformOptions MergeShopee(SystemOtherConfig? entity, ShopeePlatformOptions fallback)
{
fallback ??= new ShopeePlatformOptions();
if (entity == null || !entity.Enabled)
{
return CloneShopee(fallback);
}
var partnerId = fallback.PartnerId;
if (int.TryParse(entity.AppKey?.Trim(), out var parsed) && parsed > 0)
{
partnerId = parsed;
}
return new ShopeePlatformOptions
{
PartnerId = partnerId,
PartnerKey = Coalesce(entity.AppSecret, fallback.PartnerKey),
HostAuthUrl = Coalesce(entity.BaseUrl, fallback.HostAuthUrl),
HostUrl = Coalesce(entity.Remark, fallback.HostUrl)
};
}
private static TiktokPlatformOptions MergeTiktok(SystemOtherConfig? entity, TiktokPlatformOptions fallback)
{
fallback ??= new TiktokPlatformOptions();
if (entity == null || !entity.Enabled)
{
return CloneTiktok(fallback);
}
return new TiktokPlatformOptions
{
AppKey = Coalesce(entity.AppKey, fallback.AppKey),
AppSecret = Coalesce(entity.AppSecret, fallback.AppSecret),
HostAuth = Coalesce(entity.BaseUrl, fallback.HostAuth),
HostApi = Coalesce(entity.Remark, fallback.HostApi)
};
}
private static TemuPlatformOptions MergeTemu(SystemOtherConfig? entity, TemuPlatformOptions fallback)
{
fallback ??= new TemuPlatformOptions();
if (entity == null || !entity.Enabled)
{
return CloneTemu(fallback);
}
return new TemuPlatformOptions
{
AppKey = Coalesce(entity.AppKey, fallback.AppKey),
AppSecret = Coalesce(entity.AppSecret, fallback.AppSecret),
HostAuthUrl = Coalesce(entity.BaseUrl, fallback.HostAuthUrl)
};
}
private static LazadaPlatformOptions CloneLazada(LazadaPlatformOptions src) => new()
{
AppKey = src.AppKey,
AppSecret = src.AppSecret,
AuthorizeUrl = src.AuthorizeUrl,
AccessTokenUrl = src.AccessTokenUrl
};
private static ShopeePlatformOptions CloneShopee(ShopeePlatformOptions src) => new()
{
PartnerId = src.PartnerId,
PartnerKey = src.PartnerKey,
HostUrl = src.HostUrl,
HostAuthUrl = src.HostAuthUrl
};
private static TiktokPlatformOptions CloneTiktok(TiktokPlatformOptions src) => new()
{
AppKey = src.AppKey,
AppSecret = src.AppSecret,
HostAuth = src.HostAuth,
HostApi = src.HostApi
};
private static TemuPlatformOptions CloneTemu(TemuPlatformOptions src) => new()
{
AppKey = src.AppKey,
AppSecret = src.AppSecret,
HostAuthUrl = src.HostAuthUrl
};
private static string Coalesce(string? primary, string? fallback) =>
!string.IsNullOrWhiteSpace(primary) ? primary.Trim() : (fallback ?? string.Empty);
}

View File

@ -14,6 +14,7 @@ public static class PlatformShopServiceExtensions
{ {
client.Timeout = TimeSpan.FromSeconds(60); client.Timeout = TimeSpan.FromSeconds(60);
}); });
services.AddScoped<IPlatformShopConfigProvider, PlatformShopConfigProvider>();
services.AddScoped<IPlatformShopApiClient, PlatformShopApiClient>(); services.AddScoped<IPlatformShopApiClient, PlatformShopApiClient>();
return services; return services;
} }

View File

@ -2,7 +2,6 @@ using System.Linq.Expressions;
using Fake.Application; using Fake.Application;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Oms.Application.Dtos.PlatformStore; using Oms.Application.Dtos.PlatformStore;
using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Shared;
using Oms.Application.Integrations.PlatformShop; using Oms.Application.Integrations.PlatformShop;
@ -23,8 +22,8 @@ public class PlatformStoreService : ApplicationService
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private ISalesStoreRepository StoreRepository => LazyServiceProvider.GetRequiredService<ISalesStoreRepository>(); private ISalesStoreRepository StoreRepository => LazyServiceProvider.GetRequiredService<ISalesStoreRepository>();
private IPlatformShopApiClient PlatformShopApi => LazyServiceProvider.GetRequiredService<IPlatformShopApiClient>(); private IPlatformShopApiClient PlatformShopApi => LazyServiceProvider.GetRequiredService<IPlatformShopApiClient>();
private PlatformShopOptions PlatformShopOptions => private IPlatformShopConfigProvider PlatformShopConfig =>
LazyServiceProvider.GetRequiredService<IOptions<PlatformShopOptions>>().Value; LazyServiceProvider.GetRequiredService<IPlatformShopConfigProvider>();
private IConfiguration Configuration => LazyServiceProvider.GetRequiredService<IConfiguration>(); private IConfiguration Configuration => LazyServiceProvider.GetRequiredService<IConfiguration>();
[HttpPost("/order/platform-store/list")] [HttpPost("/order/platform-store/list")]
@ -71,7 +70,7 @@ public class PlatformStoreService : ApplicationService
return platform switch return platform switch
{ {
"temu" => await CreateTemuStoreAsync(customerId, nickname, input), "temu" => await CreateTemuStoreAsync(customerId, nickname, input),
"lazada" or "shopee" or "tiktok" => BuildOAuthUrl(customerId, platform, nickname, input), "lazada" or "shopee" or "tiktok" => await BuildOAuthUrlAsync(customerId, platform, nickname, input),
_ => throw new BusinessException($"暂不支持平台:{platform}") _ => throw new BusinessException($"暂不支持平台:{platform}")
}; };
} }
@ -100,7 +99,13 @@ public class PlatformStoreService : ApplicationService
var nickname = (input.StoreNickname ?? string.Empty).Trim(); var nickname = (input.StoreNickname ?? string.Empty).Trim();
var siteCountry = input.SiteCountry?.Trim(); var siteCountry = input.SiteCountry?.Trim();
var stateSecret = ResolveStateSecret(); var stateSecret = ResolveStateSecretFallback();
var shopOptions = await PlatformShopConfig.GetAsync();
if (!string.IsNullOrWhiteSpace(shopOptions.StateSecret))
{
stateSecret = shopOptions.StateSecret;
}
if (PlatformShopAuthState.TryDecode(input.State, stateSecret, out var statePayload) if (PlatformShopAuthState.TryDecode(input.State, stateSecret, out var statePayload)
&& statePayload.CustomerId == customerId) && statePayload.CustomerId == customerId)
{ {
@ -277,16 +282,21 @@ public class PlatformStoreService : ApplicationService
}; };
} }
private PlatformStoreAuthUrlOutput BuildOAuthUrl( private async Task<PlatformStoreAuthUrlOutput> BuildOAuthUrlAsync(
int customerId, int customerId,
string platform, string platform,
string nickname, string nickname,
PlatformStoreAuthUrlInput input) PlatformStoreAuthUrlInput input)
{ {
var shopOptions = await PlatformShopConfig.GetAsync();
var redirect = string.IsNullOrWhiteSpace(input.RedirectUri) var redirect = string.IsNullOrWhiteSpace(input.RedirectUri)
? $"{PlatformShopOptions.FrontendBaseUrl.TrimEnd('/')}/oauth/platform-store/{platform}" ? $"{shopOptions.FrontendBaseUrl.TrimEnd('/')}/oauth/platform-store/{platform}"
: input.RedirectUri.Trim(); : input.RedirectUri.Trim();
var stateSecret = !string.IsNullOrWhiteSpace(shopOptions.StateSecret)
? shopOptions.StateSecret
: ResolveStateSecretFallback();
var state = PlatformShopAuthState.Encode(new PlatformShopAuthStatePayload var state = PlatformShopAuthState.Encode(new PlatformShopAuthStatePayload
{ {
CustomerId = customerId, CustomerId = customerId,
@ -294,11 +304,11 @@ public class PlatformStoreService : ApplicationService
StoreNickname = nickname, StoreNickname = nickname,
SiteCountry = input.SiteCountry?.Trim(), SiteCountry = input.SiteCountry?.Trim(),
Exp = DateTimeOffset.UtcNow.AddHours(2).ToUnixTimeSeconds() Exp = DateTimeOffset.UtcNow.AddHours(2).ToUnixTimeSeconds()
}, ResolveStateSecret()); }, stateSecret);
try try
{ {
var authUrl = PlatformShopApi.BuildAuthUrl(platform, redirect, state, input.SiteCountry); var authUrl = await PlatformShopApi.BuildAuthUrlAsync(platform, redirect, state, input.SiteCountry);
return new PlatformStoreAuthUrlOutput return new PlatformStoreAuthUrlOutput
{ {
AuthUrl = authUrl, AuthUrl = authUrl,
@ -374,13 +384,8 @@ public class PlatformStoreService : ApplicationService
return entity; return entity;
} }
private string ResolveStateSecret() private string ResolveStateSecretFallback()
{ {
if (!string.IsNullOrWhiteSpace(PlatformShopOptions.StateSecret))
{
return PlatformShopOptions.StateSecret;
}
return Configuration["Jwt:SecretKey"] return Configuration["Jwt:SecretKey"]
?? Configuration["JwtSettings:SecretKey"] ?? Configuration["JwtSettings:SecretKey"]
?? "oms-platform-shop-state-secret"; ?? "oms-platform-shop-state-secret";

View File

@ -188,8 +188,44 @@ public class SystemOtherConfigService : ApplicationService
throw new BusinessException("微信公众号 AppSecret 不能为空"); throw new BusinessException("微信公众号 AppSecret 不能为空");
} }
} }
else if (string.Equals(configCode, SystemOtherConfigCodes.PlatformShop, StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(input.BaseUrl))
{
throw new BusinessException("店铺授权前端回调根地址不能为空");
}
}
else if (IsPlatformShopAppConfig(configCode))
{
if (string.IsNullOrWhiteSpace(input.AppKey))
{
throw new BusinessException("平台 AppKey / PartnerId 不能为空");
}
if (excludeId == null && ShouldKeepExistingSecret(input.AppSecret))
{
throw new BusinessException("平台 AppSecret / PartnerKey 不能为空");
}
if (string.Equals(configCode, SystemOtherConfigCodes.PlatformShopShopee, StringComparison.OrdinalIgnoreCase)
&& !int.TryParse(input.AppKey.Trim(), out _))
{
throw new BusinessException("Shopee PartnerId 必须为数字");
}
if (string.IsNullOrWhiteSpace(input.BaseUrl))
{
throw new BusinessException("平台授权/API 地址不能为空");
}
}
} }
private static bool IsPlatformShopAppConfig(string configCode) =>
string.Equals(configCode, SystemOtherConfigCodes.PlatformShopLazada, StringComparison.OrdinalIgnoreCase)
|| string.Equals(configCode, SystemOtherConfigCodes.PlatformShopShopee, StringComparison.OrdinalIgnoreCase)
|| string.Equals(configCode, SystemOtherConfigCodes.PlatformShopTiktok, StringComparison.OrdinalIgnoreCase)
|| string.Equals(configCode, SystemOtherConfigCodes.PlatformShopTemu, StringComparison.OrdinalIgnoreCase);
private static SystemOtherConfigOutput MapOutput(SystemOtherConfig entity, bool maskSecret) private static SystemOtherConfigOutput MapOutput(SystemOtherConfig entity, bool maskSecret)
{ {
return new SystemOtherConfigOutput return new SystemOtherConfigOutput

View File

@ -35,7 +35,7 @@ public static class WarehouseCustomerHelper
var customerId = await ResolveListCustomerIdAsync(customerRepository, currentUser, requestedCustomerId); var customerId = await ResolveListCustomerIdAsync(customerRepository, currentUser, requestedCustomerId);
if (!customerId.HasValue || customerId.Value <= 0) if (!customerId.HasValue || customerId.Value <= 0)
{ {
throw new BusinessException("客户ID不能为空(管理端请先在「我的包裹」选择客户后再使用采集插件)"); throw new BusinessException("客户ID不能为空(管理端请先选择客户)");
} }
return customerId.Value; return customerId.Value;

View File

@ -14,4 +14,29 @@ public static class SystemOtherConfigCodes
/// 微信公众号配置(AppKey=AppId,AppSecret=Secret,Remark=Token,BaseUrl=OAuthRedirectBase)。 /// 微信公众号配置(AppKey=AppId,AppSecret=Secret,Remark=Token,BaseUrl=OAuthRedirectBase)。
/// </summary> /// </summary>
public const string WeChatMp = "wechat_mp"; public const string WeChatMp = "wechat_mp";
/// <summary>
/// 店铺授权公共配置(BaseUrl=前端回调根地址 FrontendBaseUrl,Remark=StateSecret 可选)。
/// </summary>
public const string PlatformShop = "platform_shop";
/// <summary>
/// Lazada(AppKey/AppSecret,BaseUrl=AuthorizeUrl,Remark=AccessTokenUrl)。
/// </summary>
public const string PlatformShopLazada = "platform_shop_lazada";
/// <summary>
/// Shopee(AppKey=PartnerId,AppSecret=PartnerKey,BaseUrl=HostAuthUrl,Remark=HostUrl)。
/// </summary>
public const string PlatformShopShopee = "platform_shop_shopee";
/// <summary>
/// TikTok(AppKey/AppSecret,BaseUrl=HostAuth,Remark=HostApi)。
/// </summary>
public const string PlatformShopTiktok = "platform_shop_tiktok";
/// <summary>
/// Temu(AppKey/AppSecret,BaseUrl=HostAuthUrl)。
/// </summary>
public const string PlatformShopTemu = "platform_shop_temu";
} }