admin/src/Oms.Application/Integrations/PlatformShop/PlatformShopApiClient.cs
zzh 674cbdd011 feat: 店铺真授权跳转与地址类型(发件/退件/收件)
对接 Lazada/Shopee/TikTok/Temu OAuth 与回调落库,移除手工店;地址管理支持按类型默认与揽收校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 10:25:13 +08:00

482 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<PlatformShopTokenResult> ExchangeTokenAsync(PlatformShopTokenExchangeRequest request, CancellationToken ct = default);
Task<PlatformShopTokenResult> 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<PlatformShopBoundShop> 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<PlatformShopOptions> options,
ILogger<PlatformShopApiClient> 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<PlatformShopTokenResult> 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<PlatformShopTokenResult> CreateTemuStoreTokenAsync(string authorizationCode, CancellationToken ct = default)
{
EnsureTemuConfigured();
var temu = _options.Temu;
var body = new Dictionary<string, string>
{
["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)}&timestamp={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<PlatformShopTokenResult> 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<string, string>(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<PlatformShopTokenResult> 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}&timestamp={ts}";
var body = new Dictionary<string, object?>
{
["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<PlatformShopBoundShop>();
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<PlatformShopBoundShop> 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}&timestamp={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<PlatformShopTokenResult> 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<List<PlatformShopBoundShop>> GetTiktokAuthorizedShopsAsync(string accessToken, CancellationToken ct)
{
var tiktok = _options.Tiktok;
var apiPath = $"/authorization/202309/shops?app_key={tiktok.AppKey}&timestamp={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<PlatformShopBoundShop>();
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<string, string> 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;
}
}