对接 Lazada/Shopee/TikTok/Temu OAuth 与回调落库,移除手工店;地址管理支持按类型默认与揽收校验。 Co-authored-by: Cursor <cursoragent@cursor.com>
482 lines
20 KiB
C#
482 lines
20 KiB
C#
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)}×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<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}×tamp={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}×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<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}×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<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;
|
||
}
|
||
}
|