using System.Net.Http.Json; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Oms.Application.Integrations.PlatformShop; public interface IPlatformShopApiClient { string BuildAuthUrl(string platformCode, string redirectUri, string state, string? siteCountry); Task ExchangeTokenAsync(PlatformShopTokenExchangeRequest request, CancellationToken ct = default); Task CreateTemuStoreTokenAsync(string authorizationCode, CancellationToken ct = default); } public sealed class PlatformShopTokenExchangeRequest { public string PlatformCode { get; set; } = string.Empty; public string Code { get; set; } = string.Empty; public long? ShopId { get; set; } public long? MainAccountId { get; set; } } public sealed class PlatformShopTokenResult { public string AccessToken { get; set; } = string.Empty; public string? RefreshToken { get; set; } public int ExpiresInSeconds { get; set; } public int RefreshExpiresInSeconds { get; set; } public string? PlatformStoreId { get; set; } public string? PlatformAccount { get; set; } public string? SiteCountry { get; set; } public string? SellerId { get; set; } public string? ExtraJson { get; set; } public List Shops { get; set; } = []; } public sealed class PlatformShopBoundShop { public string PlatformStoreId { get; set; } = string.Empty; public string? PlatformAccount { get; set; } public string? SiteCountry { get; set; } public string? Status { get; set; } } internal sealed class PlatformShopApiClient( IHttpClientFactory httpClientFactory, IOptions options, ILogger logger) : IPlatformShopApiClient { public const string HttpClientName = nameof(PlatformShopApiClient); private readonly PlatformShopOptions _options = options.Value; public string BuildAuthUrl(string platformCode, string redirectUri, string state, string? siteCountry) { return platformCode.ToLowerInvariant() switch { "lazada" => BuildLazadaAuthUrl(redirectUri, siteCountry), "shopee" => BuildShopeeAuthUrl(redirectUri), "tiktok" => BuildTiktokAuthUrl(state), _ => throw new InvalidOperationException($"平台 {platformCode} 不支持 OAuth 跳转") }; } public async Task ExchangeTokenAsync( PlatformShopTokenExchangeRequest request, CancellationToken ct = default) { return request.PlatformCode.ToLowerInvariant() switch { "lazada" => await ExchangeLazadaAsync(request.Code, ct), "shopee" => await ExchangeShopeeAsync(request, ct), "tiktok" => await ExchangeTiktokAsync(request.Code, ct), _ => throw new InvalidOperationException($"平台 {request.PlatformCode} 不支持授权回调") }; } public async Task CreateTemuStoreTokenAsync(string authorizationCode, CancellationToken ct = default) { EnsureTemuConfigured(); var temu = _options.Temu; var body = new Dictionary { ["type"] = "bg.open.accesstoken.create", ["app_key"] = temu.AppKey, ["access_token"] = authorizationCode, ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString(), ["code"] = authorizationCode }; body["sign"] = BuildTemuSign(body, temu.AppSecret); var client = httpClientFactory.CreateClient(HttpClientName); using var response = await client.PostAsJsonAsync(temu.HostAuthUrl, body, ct); var raw = await response.Content.ReadAsStringAsync(ct); logger.LogInformation("Temu accesstoken.create response: {Body}", raw); using var doc = JsonDocument.Parse(raw); var root = doc.RootElement; if (root.TryGetProperty("success", out var success) && success.ValueKind == JsonValueKind.False) { var err = root.TryGetProperty("errorMsg", out var msg) ? msg.GetString() : raw; throw new InvalidOperationException($"Temu 授权失败: {err}"); } var result = root.TryGetProperty("result", out var r) ? r : root; var accessToken = GetString(result, "accessToken") ?? throw new InvalidOperationException("Temu AccessToken 为空"); var mallId = GetString(result, "mallId") ?? accessToken[..Math.Min(16, accessToken.Length)]; var expiredTime = GetLong(result, "expiredTime"); var expiresIn = expiredTime > 0 ? (int)Math.Max(0, expiredTime - PlatformShopCrypto.UnixSeconds()) : 86400 * 30; return new PlatformShopTokenResult { AccessToken = accessToken, RefreshToken = accessToken, ExpiresInSeconds = expiresIn, RefreshExpiresInSeconds = expiresIn, PlatformStoreId = mallId, PlatformAccount = mallId, SiteCountry = "US", Shops = [ new PlatformShopBoundShop { PlatformStoreId = mallId, PlatformAccount = mallId, SiteCountry = "US" } ] }; } private string BuildLazadaAuthUrl(string redirectUri, string? siteCountry) { EnsureLazadaConfigured(); var country = string.IsNullOrWhiteSpace(siteCountry) ? "" : $"&country={Uri.EscapeDataString(siteCountry)}"; return $"{_options.Lazada.AuthorizeUrl}?response_type=code&force_auth=true{country}" + $"&redirect_uri={Uri.EscapeDataString(redirectUri)}&client_id={_options.Lazada.AppKey}"; } private string BuildShopeeAuthUrl(string redirectUri) { EnsureShopeeConfigured(); var shopee = _options.Shopee; var path = "/api/v2/shop/auth_partner"; var ts = PlatformShopCrypto.UnixSeconds(); var sign = PlatformShopCrypto.HmacSha256Hex($"{shopee.PartnerId}{path}{ts}", shopee.PartnerKey); return $"{shopee.HostAuthUrl}{path}?partner_id={shopee.PartnerId}&redirect={Uri.EscapeDataString(redirectUri)}×tamp={ts}&sign={sign}"; } private string BuildTiktokAuthUrl(string state) { EnsureTiktokConfigured(); var tiktok = _options.Tiktok; return $"{tiktok.HostAuth}/oauth/authorize?app_key={Uri.EscapeDataString(tiktok.AppKey)}&state={Uri.EscapeDataString(state)}"; } private async Task ExchangeLazadaAsync(string code, CancellationToken ct) { EnsureLazadaConfigured(); var lazada = _options.Lazada; var apiPath = "/auth/token/create"; var timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:sszzz"); var parameters = new SortedDictionary(StringComparer.Ordinal) { ["app_key"] = lazada.AppKey, ["code"] = code, ["sign_method"] = "sha256", ["timestamp"] = timestamp }; var signBase = new StringBuilder(apiPath); foreach (var kv in parameters) { signBase.Append(kv.Key).Append(kv.Value); } parameters["sign"] = PlatformShopCrypto.HmacSha256Hex(signBase.ToString(), lazada.AppSecret).ToUpperInvariant(); var client = httpClientFactory.CreateClient(HttpClientName); var url = $"{lazada.AccessTokenUrl.TrimEnd('/')}{apiPath}"; using var content = new FormUrlEncodedContent(parameters); using var response = await client.PostAsync(url, content, ct); var raw = await response.Content.ReadAsStringAsync(ct); logger.LogInformation("Lazada token/create response: {Body}", raw); using var doc = JsonDocument.Parse(raw); var root = doc.RootElement; var apiCode = GetString(root, "code"); if (!string.IsNullOrEmpty(apiCode) && apiCode != "0") { throw new InvalidOperationException($"Lazada 获取 Token 失败: {GetString(root, "message") ?? raw}"); } var accessToken = GetString(root, "access_token") ?? throw new InvalidOperationException("Lazada access_token 为空"); var country = GetString(root, "country"); var account = GetString(root, "account"); var expiresIn = GetInt(root, "expires_in"); var refreshExpiresIn = GetInt(root, "refresh_expires_in"); var platformStoreId = account ?? accessToken[..Math.Min(24, accessToken.Length)]; return new PlatformShopTokenResult { AccessToken = accessToken, RefreshToken = GetString(root, "refresh_token"), ExpiresInSeconds = expiresIn > 0 ? expiresIn : 86400, RefreshExpiresInSeconds = refreshExpiresIn > 0 ? refreshExpiresIn : expiresIn, PlatformStoreId = platformStoreId, PlatformAccount = account, SiteCountry = country, ExtraJson = raw, Shops = [ new PlatformShopBoundShop { PlatformStoreId = platformStoreId, PlatformAccount = account, SiteCountry = country } ] }; } private async Task ExchangeShopeeAsync( PlatformShopTokenExchangeRequest request, CancellationToken ct) { EnsureShopeeConfigured(); var shopee = _options.Shopee; var path = "/api/v2/auth/token/get"; var ts = PlatformShopCrypto.UnixSeconds(); var sign = PlatformShopCrypto.HmacSha256Hex($"{shopee.PartnerId}{path}{ts}", shopee.PartnerKey); var url = $"{shopee.HostAuthUrl}{path}?sign={sign}&partner_id={shopee.PartnerId}×tamp={ts}"; var body = new Dictionary { ["code"] = request.Code, ["partner_id"] = shopee.PartnerId }; var isMain = request.MainAccountId is > 0; if (isMain) { body["main_account_id"] = request.MainAccountId; } else if (request.ShopId is > 0) { body["shop_id"] = request.ShopId; } else { throw new InvalidOperationException("Shopee 回调缺少 shop_id 或 main_account_id"); } var client = httpClientFactory.CreateClient(HttpClientName); using var response = await client.PostAsJsonAsync(url, body, ct); var raw = await response.Content.ReadAsStringAsync(ct); logger.LogInformation("Shopee token/get response: {Body}", raw); using var doc = JsonDocument.Parse(raw); var root = doc.RootElement; if (root.TryGetProperty("error", out var err) && err.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(err.GetString())) { throw new InvalidOperationException($"Shopee 获取 Token 失败: {err.GetString()} {GetString(root, "message")}"); } var accessToken = GetString(root, "access_token") ?? throw new InvalidOperationException("Shopee access_token 为空"); var expireIn = GetInt(root, "expire_in"); var shops = new List(); if (isMain && root.TryGetProperty("shop_id_list", out var list) && list.ValueKind == JsonValueKind.Array) { foreach (var item in list.EnumerateArray()) { var sid = item.GetInt64(); var info = await GetShopeeShopInfoAsync(accessToken, sid, ct); shops.Add(info); } } else { var sid = request.ShopId!.Value; shops.Add(await GetShopeeShopInfoAsync(accessToken, sid, ct)); } var first = shops.FirstOrDefault(); return new PlatformShopTokenResult { AccessToken = accessToken, RefreshToken = GetString(root, "refresh_token"), ExpiresInSeconds = expireIn > 0 ? expireIn : 14400, RefreshExpiresInSeconds = expireIn > 0 ? expireIn * 30 : 86400 * 30, PlatformStoreId = first?.PlatformStoreId, PlatformAccount = first?.PlatformAccount, SiteCountry = first?.SiteCountry, ExtraJson = raw, Shops = shops }; } private async Task GetShopeeShopInfoAsync(string accessToken, long shopId, CancellationToken ct) { var shopee = _options.Shopee; var path = "/api/v2/shop/get_shop_info"; var ts = PlatformShopCrypto.UnixSeconds(); var sign = PlatformShopCrypto.HmacSha256Hex( $"{shopee.PartnerId}{path}{ts}{accessToken}{shopId}", shopee.PartnerKey); var url = $"{shopee.HostAuthUrl}{path}?partner_id={shopee.PartnerId}&shop_id={shopId}&access_token={Uri.EscapeDataString(accessToken)}&sign={sign}×tamp={ts}"; var client = httpClientFactory.CreateClient(HttpClientName); using var response = await client.GetAsync(url, ct); var raw = await response.Content.ReadAsStringAsync(ct); logger.LogInformation("Shopee get_shop_info shop={ShopId} response: {Body}", shopId, raw); using var doc = JsonDocument.Parse(raw); var root = doc.RootElement; return new PlatformShopBoundShop { PlatformStoreId = shopId.ToString(), PlatformAccount = GetString(root, "shop_name") ?? shopId.ToString(), SiteCountry = GetString(root, "region"), Status = GetString(root, "status") }; } private async Task ExchangeTiktokAsync(string code, CancellationToken ct) { EnsureTiktokConfigured(); var tiktok = _options.Tiktok; var url = $"{tiktok.HostAuth}/api/v2/token/get" + $"?app_key={Uri.EscapeDataString(tiktok.AppKey)}" + $"&app_secret={Uri.EscapeDataString(tiktok.AppSecret)}" + $"&auth_code={Uri.EscapeDataString(code)}" + "&grant_type=authorized_code"; var client = httpClientFactory.CreateClient(HttpClientName); using var response = await client.GetAsync(url, ct); var raw = await response.Content.ReadAsStringAsync(ct); logger.LogInformation("TikTok token/get response: {Body}", raw); using var doc = JsonDocument.Parse(raw); var root = doc.RootElement; var data = root.TryGetProperty("data", out var d) ? d : root; var accessToken = GetString(data, "access_token") ?? throw new InvalidOperationException("TikTok access_token 为空"); var openId = GetString(data, "open_id"); var sellerName = GetString(data, "seller_name"); var expiresIn = GetInt(data, "access_token_expire_in"); if (expiresIn <= 0) expiresIn = GetInt(data, "expires_in"); var refreshExpire = GetInt(data, "refresh_token_expire_in"); var shops = await GetTiktokAuthorizedShopsAsync(accessToken, ct); if (shops.Count == 0) { shops.Add(new PlatformShopBoundShop { PlatformStoreId = openId ?? accessToken[..Math.Min(24, accessToken.Length)], PlatformAccount = sellerName ?? openId }); } var first = shops[0]; return new PlatformShopTokenResult { AccessToken = accessToken, RefreshToken = GetString(data, "refresh_token"), ExpiresInSeconds = expiresIn > 0 ? expiresIn : 86400, RefreshExpiresInSeconds = refreshExpire > 0 ? refreshExpire : expiresIn, PlatformStoreId = first.PlatformStoreId, PlatformAccount = first.PlatformAccount ?? sellerName, SellerId = openId, ExtraJson = raw, Shops = shops }; } private async Task> GetTiktokAuthorizedShopsAsync(string accessToken, CancellationToken ct) { var tiktok = _options.Tiktok; var apiPath = $"/authorization/202309/shops?app_key={tiktok.AppKey}×tamp={PlatformShopCrypto.UnixSeconds()}"; var signInput = apiPath.Replace("?", "").Replace("&", "").Replace("=", ""); var sign = PlatformShopCrypto.HmacSha256Hex(tiktok.AppSecret + signInput + tiktok.AppSecret, tiktok.AppSecret); var url = $"{tiktok.HostApi}{apiPath}&sign={sign}"; var client = httpClientFactory.CreateClient(HttpClientName); using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.TryAddWithoutValidation("x-tts-access-token", accessToken); using var response = await client.SendAsync(request, ct); var raw = await response.Content.ReadAsStringAsync(ct); logger.LogInformation("TikTok authorized shops response: {Body}", raw); var shops = new List(); try { using var doc = JsonDocument.Parse(raw); var root = doc.RootElement; var data = root.TryGetProperty("data", out var d) ? d : root; if (data.TryGetProperty("shops", out var arr) && arr.ValueKind == JsonValueKind.Array) { foreach (var item in arr.EnumerateArray()) { var id = GetString(item, "id") ?? GetString(item, "shop_id"); if (string.IsNullOrWhiteSpace(id)) continue; shops.Add(new PlatformShopBoundShop { PlatformStoreId = id, PlatformAccount = GetString(item, "name") ?? GetString(item, "shop_name"), SiteCountry = GetString(item, "region") ?? GetString(item, "code"), Status = GetString(item, "status") }); } } } catch (Exception ex) { logger.LogWarning(ex, "Parse TikTok shops failed"); } return shops; } private static string BuildTemuSign(Dictionary body, string appSecret) { var concat = new StringBuilder(); foreach (var key in body.Keys.OrderBy(x => x, StringComparer.Ordinal)) { concat.Append(key).Append(body[key]); } return PlatformShopCrypto.Md5Upper(appSecret + concat + appSecret); } private void EnsureLazadaConfigured() { if (string.IsNullOrWhiteSpace(_options.Lazada.AppKey) || string.IsNullOrWhiteSpace(_options.Lazada.AppSecret)) { throw new InvalidOperationException("未配置 Lazada AppKey/AppSecret(Oms:PlatformShop:Lazada)"); } } private void EnsureShopeeConfigured() { if (_options.Shopee.PartnerId <= 0 || string.IsNullOrWhiteSpace(_options.Shopee.PartnerKey)) { throw new InvalidOperationException("未配置 Shopee PartnerId/PartnerKey(Oms:PlatformShop:Shopee)"); } } private void EnsureTiktokConfigured() { if (string.IsNullOrWhiteSpace(_options.Tiktok.AppKey) || string.IsNullOrWhiteSpace(_options.Tiktok.AppSecret)) { throw new InvalidOperationException("未配置 TikTok AppKey/AppSecret(Oms:PlatformShop:Tiktok)"); } } private void EnsureTemuConfigured() { if (string.IsNullOrWhiteSpace(_options.Temu.AppKey) || string.IsNullOrWhiteSpace(_options.Temu.AppSecret)) { throw new InvalidOperationException("未配置 Temu AppKey/AppSecret(Oms:PlatformShop:Temu)"); } } private static string? GetString(JsonElement el, string name) => el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : el.TryGetProperty(name, out p) && p.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False ? p.ToString() : null; private static int GetInt(JsonElement el, string name) { if (!el.TryGetProperty(name, out var p)) return 0; if (p.ValueKind == JsonValueKind.Number && p.TryGetInt32(out var i)) return i; return int.TryParse(p.ToString(), out var parsed) ? parsed : 0; } private static long GetLong(JsonElement el, string name) { if (!el.TryGetProperty(name, out var p)) return 0; if (p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var i)) return i; return long.TryParse(p.ToString(), out var parsed) ? parsed : 0; } }