feat: 店铺真授权跳转与地址类型(发件/退件/收件)
对接 Lazada/Shopee/TikTok/Temu OAuth 与回调落库,移除手工店;地址管理支持按类型默认与揽收校验。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
341a318f0b
commit
674cbdd011
10
db.sql
10
db.sql
@ -178,8 +178,9 @@ CREATE TABLE `t_order` (
|
|||||||
CREATE TABLE `t_customer_sender_address` (
|
CREATE TABLE `t_customer_sender_address` (
|
||||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
`CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id',
|
`CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id',
|
||||||
|
`AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender' COMMENT '地址类型:sender发件/return退件/receiver收件',
|
||||||
`AddressName` VARCHAR(64) NULL COMMENT '地址别名',
|
`AddressName` VARCHAR(64) NULL COMMENT '地址别名',
|
||||||
`SenderName` VARCHAR(50) NOT NULL COMMENT '发件人姓名',
|
`SenderName` VARCHAR(50) NOT NULL COMMENT '联系人姓名',
|
||||||
`Company` VARCHAR(255) NULL COMMENT '公司',
|
`Company` VARCHAR(255) NULL COMMENT '公司',
|
||||||
`Country` INT NULL COMMENT '国家 t_country.Id',
|
`Country` INT NULL COMMENT '国家 t_country.Id',
|
||||||
`Province` VARCHAR(50) NULL COMMENT '州省',
|
`Province` VARCHAR(50) NULL COMMENT '州省',
|
||||||
@ -192,7 +193,7 @@ CREATE TABLE `t_customer_sender_address` (
|
|||||||
`Phone` VARCHAR(50) NULL COMMENT '手机',
|
`Phone` VARCHAR(50) NULL COMMENT '手机',
|
||||||
`TelePhone` VARCHAR(50) NULL COMMENT '电话',
|
`TelePhone` VARCHAR(50) NULL COMMENT '电话',
|
||||||
`Email` VARCHAR(60) NULL COMMENT '邮箱',
|
`Email` VARCHAR(60) NULL COMMENT '邮箱',
|
||||||
`IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认:0-否,1-是',
|
`IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认:0-否,1-是(同客户同类型仅一条)',
|
||||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
||||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
|
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
|
||||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
@ -201,8 +202,9 @@ CREATE TABLE `t_customer_sender_address` (
|
|||||||
`UpdateTime` DATETIME NULL COMMENT '更新时间',
|
`UpdateTime` DATETIME NULL COMMENT '更新时间',
|
||||||
PRIMARY KEY (`Id`),
|
PRIMARY KEY (`Id`),
|
||||||
KEY `idx_csa_customer` (`CustomerId`, `IsDeleted`, `Id`),
|
KEY `idx_csa_customer` (`CustomerId`, `IsDeleted`, `Id`),
|
||||||
KEY `idx_csa_customer_default` (`CustomerId`, `IsDefault`)
|
KEY `idx_csa_customer_type` (`CustomerId`, `AddressType`, `IsDeleted`, `Id`),
|
||||||
) COMMENT='客户发件地址簿';
|
KEY `idx_csa_customer_default` (`CustomerId`, `AddressType`, `IsDefault`)
|
||||||
|
) COMMENT='客户地址簿(发件/退件/收件)';
|
||||||
|
|
||||||
CREATE TABLE `t_order_template` (
|
CREATE TABLE `t_order_template` (
|
||||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
|
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
-- 已有库增量:客户发件地址簿 t_customer_sender_address
|
-- 已有库增量:客户地址簿 t_customer_sender_address(发件/退件/收件)
|
||||||
-- 对应功能:客户 → 发件地址;揽收订单选址;见 oms系统设计-050801.md §4.2.10
|
-- 对应功能:客户 → 地址管理;揽收订单选发件地址
|
||||||
-- 可重复执行(IF NOT EXISTS)
|
-- 可重复执行(IF NOT EXISTS)
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `t_customer_sender_address` (
|
CREATE TABLE IF NOT EXISTS `t_customer_sender_address` (
|
||||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
`CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id',
|
`CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id',
|
||||||
|
`AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender' COMMENT '地址类型:sender发件/return退件/receiver收件',
|
||||||
`AddressName` VARCHAR(64) NULL COMMENT '地址别名',
|
`AddressName` VARCHAR(64) NULL COMMENT '地址别名',
|
||||||
`SenderName` VARCHAR(50) NOT NULL COMMENT '发件人姓名',
|
`SenderName` VARCHAR(50) NOT NULL COMMENT '联系人姓名',
|
||||||
`Company` VARCHAR(255) NULL COMMENT '公司',
|
`Company` VARCHAR(255) NULL COMMENT '公司',
|
||||||
`Country` INT NULL COMMENT '国家 t_country.Id',
|
`Country` INT NULL COMMENT '国家 t_country.Id',
|
||||||
`Province` VARCHAR(50) NULL COMMENT '州省',
|
`Province` VARCHAR(50) NULL COMMENT '州省',
|
||||||
@ -19,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `t_customer_sender_address` (
|
|||||||
`Phone` VARCHAR(50) NULL COMMENT '手机',
|
`Phone` VARCHAR(50) NULL COMMENT '手机',
|
||||||
`TelePhone` VARCHAR(50) NULL COMMENT '电话',
|
`TelePhone` VARCHAR(50) NULL COMMENT '电话',
|
||||||
`Email` VARCHAR(60) NULL COMMENT '邮箱',
|
`Email` VARCHAR(60) NULL COMMENT '邮箱',
|
||||||
`IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认:0-否,1-是',
|
`IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认:0-否,1-是(同客户同类型仅一条)',
|
||||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
||||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
|
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
|
||||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
@ -28,5 +29,6 @@ CREATE TABLE IF NOT EXISTS `t_customer_sender_address` (
|
|||||||
`UpdateTime` DATETIME NULL COMMENT '更新时间',
|
`UpdateTime` DATETIME NULL COMMENT '更新时间',
|
||||||
PRIMARY KEY (`Id`),
|
PRIMARY KEY (`Id`),
|
||||||
KEY `idx_csa_customer` (`CustomerId`, `IsDeleted`, `Id`),
|
KEY `idx_csa_customer` (`CustomerId`, `IsDeleted`, `Id`),
|
||||||
KEY `idx_csa_customer_default` (`CustomerId`, `IsDefault`)
|
KEY `idx_csa_customer_type` (`CustomerId`, `AddressType`, `IsDeleted`, `Id`),
|
||||||
) COMMENT='客户发件地址簿';
|
KEY `idx_csa_customer_default` (`CustomerId`, `AddressType`, `IsDefault`)
|
||||||
|
) COMMENT='客户地址簿(发件/退件/收件)';
|
||||||
|
|||||||
41
sql/migrate_t_customer_sender_address_AddressType.sql
Normal file
41
sql/migrate_t_customer_sender_address_AddressType.sql
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
-- 已有库增量:客户地址簿增加 AddressType(发件/退件/收件)
|
||||||
|
-- sender=发件,return=退件,receiver=收件;存量默认 sender
|
||||||
|
-- 可重复执行
|
||||||
|
|
||||||
|
SET @col_exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 't_customer_sender_address'
|
||||||
|
AND COLUMN_NAME = 'AddressType'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql := IF(
|
||||||
|
@col_exists = 0,
|
||||||
|
'ALTER TABLE `t_customer_sender_address` ADD COLUMN `AddressType` VARCHAR(16) NOT NULL DEFAULT ''sender'' COMMENT ''地址类型:sender发件/return退件/receiver收件'' AFTER `CustomerId`',
|
||||||
|
'SELECT ''t_customer_sender_address.AddressType already exists'' AS info'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
UPDATE `t_customer_sender_address`
|
||||||
|
SET `AddressType` = 'sender'
|
||||||
|
WHERE `AddressType` IS NULL OR `AddressType` = '';
|
||||||
|
|
||||||
|
SET @idx_exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 't_customer_sender_address'
|
||||||
|
AND INDEX_NAME = 'idx_csa_customer_type'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql := IF(
|
||||||
|
@idx_exists = 0,
|
||||||
|
'ALTER TABLE `t_customer_sender_address` ADD KEY `idx_csa_customer_type` (`CustomerId`, `AddressType`, `IsDeleted`, `Id`)',
|
||||||
|
'SELECT ''idx_csa_customer_type already exists'' AS info'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
namespace Oms.Application.Dtos.Orders;
|
namespace Oms.Application.Dtos.Orders;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 客户发件地址分页查询。
|
/// 客户地址分页查询。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CustomerSenderAddressPaginatedInput
|
public class CustomerSenderAddressPaginatedInput
|
||||||
{
|
{
|
||||||
@ -13,6 +13,12 @@ public class CustomerSenderAddressPaginatedInput
|
|||||||
[Newtonsoft.Json.JsonProperty("pageSize")]
|
[Newtonsoft.Json.JsonProperty("pageSize")]
|
||||||
public int PageSize { get; set; } = 30;
|
public int PageSize { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 地址类型:all / sender / return / receiver。
|
||||||
|
/// </summary>
|
||||||
|
[Newtonsoft.Json.JsonProperty("addressType")]
|
||||||
|
public string? AddressType { get; set; }
|
||||||
|
|
||||||
[Newtonsoft.Json.JsonProperty("keyword")]
|
[Newtonsoft.Json.JsonProperty("keyword")]
|
||||||
public string? Keyword { get; set; }
|
public string? Keyword { get; set; }
|
||||||
|
|
||||||
@ -21,13 +27,23 @@ public class CustomerSenderAddressPaginatedInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 新增发件地址。
|
/// 新增客户地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CreateCustomerSenderAddressInput
|
public class CreateCustomerSenderAddressInput
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 地址类型:sender 发件 / return 退件 / receiver 收件。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[StringLength(16)]
|
||||||
|
public string AddressType { get; set; } = "sender";
|
||||||
|
|
||||||
[StringLength(64)]
|
[StringLength(64)]
|
||||||
public string? AddressName { get; set; }
|
public string? AddressName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 联系人姓名。
|
||||||
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(50)]
|
[StringLength(50)]
|
||||||
public string SenderName { get; set; } = string.Empty;
|
public string SenderName { get; set; } = string.Empty;
|
||||||
@ -73,7 +89,7 @@ public class CreateCustomerSenderAddressInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 更新发件地址。
|
/// 更新客户地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UpdateCustomerSenderAddressInput : CreateCustomerSenderAddressInput
|
public class UpdateCustomerSenderAddressInput : CreateCustomerSenderAddressInput
|
||||||
{
|
{
|
||||||
@ -82,7 +98,7 @@ public class UpdateCustomerSenderAddressInput : CreateCustomerSenderAddressInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 发件地址出参。
|
/// 客户地址出参。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CustomerSenderAddressOutput
|
public class CustomerSenderAddressOutput
|
||||||
{
|
{
|
||||||
@ -90,6 +106,16 @@ public class CustomerSenderAddressOutput
|
|||||||
|
|
||||||
public int CustomerId { get; set; }
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 地址类型 code。
|
||||||
|
/// </summary>
|
||||||
|
public string AddressType { get; set; } = "sender";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 地址类型名称:发件 / 退件 / 收件。
|
||||||
|
/// </summary>
|
||||||
|
public string? AddressTypeName { get; set; }
|
||||||
|
|
||||||
public string? AddressName { get; set; }
|
public string? AddressName { get; set; }
|
||||||
|
|
||||||
public string SenderName { get; set; } = string.Empty;
|
public string SenderName { get; set; } = string.Empty;
|
||||||
|
|||||||
@ -53,7 +53,7 @@ public class PlatformStoreSaveInput
|
|||||||
|
|
||||||
public int? CustomerId { get; set; }
|
public int? CustomerId { get; set; }
|
||||||
|
|
||||||
public string PlatformCode { get; set; } = "manual";
|
public string PlatformCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string StoreNickname { get; set; } = string.Empty;
|
public string StoreNickname { get; set; } = string.Empty;
|
||||||
@ -74,14 +74,61 @@ public class PlatformStoreAuthUrlInput
|
|||||||
|
|
||||||
public int? CustomerId { get; set; }
|
public int? CustomerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>自定义店铺昵称(OAuth 平台必填;续期不改名)</summary>
|
||||||
|
public string? StoreNickname { get; set; }
|
||||||
|
|
||||||
|
/// <summary>开户站,如 Lazada cb/sg;Temu US</summary>
|
||||||
|
public string? SiteCountry { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Temu 授权码</summary>
|
||||||
|
public string? AuthorizationCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>可选覆盖回调地址;默认用配置 FrontendBaseUrl + /oauth/platform-store/{plat}</summary>
|
||||||
public string? RedirectUri { get; set; }
|
public string? RedirectUri { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PlatformStoreAuthUrlOutput
|
public class PlatformStoreAuthUrlOutput
|
||||||
{
|
{
|
||||||
|
/// <summary>平台授权 URL;Temu 成功落库后返回 "/"</summary>
|
||||||
public string AuthUrl { get; set; } = string.Empty;
|
public string AuthUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>透传给回调页的 state(TikTok 等)</summary>
|
||||||
|
public string? State { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlatformStoreAuthCallbackInput
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string PlatformCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int? CustomerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>优先用 state 还原;也可由前端 localStorage 传入</summary>
|
||||||
|
public string? State { get; set; }
|
||||||
|
|
||||||
|
public string? StoreNickname { get; set; }
|
||||||
|
|
||||||
|
public string? SiteCountry { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Shopee 店铺授权</summary>
|
||||||
|
public long? ShopId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Shopee 主账号授权</summary>
|
||||||
|
public long? MainAccountId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlatformStoreAuthCallbackOutput
|
||||||
|
{
|
||||||
|
public int BoundCount { get; set; }
|
||||||
|
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public List<PlatformStoreOutput> Stores { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PlatformStoreIdsInput
|
public class PlatformStoreIdsInput
|
||||||
|
|||||||
@ -0,0 +1,481 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Oms.Application.Integrations.PlatformShop;
|
||||||
|
|
||||||
|
internal static class PlatformShopCrypto
|
||||||
|
{
|
||||||
|
public static string HmacSha256Hex(string data, string key)
|
||||||
|
{
|
||||||
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key));
|
||||||
|
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Md5Upper(string input)
|
||||||
|
{
|
||||||
|
var hash = MD5.HashData(Encoding.UTF8.GetBytes(input));
|
||||||
|
return Convert.ToHexString(hash).ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long UnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth 会话 state:携带客户与昵称,回调时还原。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlatformShopAuthStatePayload
|
||||||
|
{
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
public string PlatformCode { get; set; } = string.Empty;
|
||||||
|
public string StoreNickname { get; set; } = string.Empty;
|
||||||
|
public string? SiteCountry { get; set; }
|
||||||
|
public long Exp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PlatformShopAuthState
|
||||||
|
{
|
||||||
|
public static string Encode(PlatformShopAuthStatePayload payload, string secret)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(payload);
|
||||||
|
var body = Convert.ToBase64String(Encoding.UTF8.GetBytes(json))
|
||||||
|
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
var sig = PlatformShopCrypto.HmacSha256Hex(body, secret)[..16];
|
||||||
|
return $"{body}.{sig}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryDecode(string? state, string secret, out PlatformShopAuthStatePayload payload)
|
||||||
|
{
|
||||||
|
payload = new PlatformShopAuthStatePayload();
|
||||||
|
if (string.IsNullOrWhiteSpace(state)) return false;
|
||||||
|
var parts = state.Split('.', 2);
|
||||||
|
if (parts.Length != 2) return false;
|
||||||
|
var body = parts[0];
|
||||||
|
var expected = PlatformShopCrypto.HmacSha256Hex(body, secret)[..16];
|
||||||
|
if (!string.Equals(expected, parts[1], StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
|
||||||
|
var pad = body.Length % 4;
|
||||||
|
if (pad > 0) body += new string('=', 4 - pad);
|
||||||
|
body = body.Replace('-', '+').Replace('_', '/');
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = Encoding.UTF8.GetString(Convert.FromBase64String(body));
|
||||||
|
var parsed = JsonSerializer.Deserialize<PlatformShopAuthStatePayload>(json);
|
||||||
|
if (parsed == null) return false;
|
||||||
|
if (parsed.Exp > 0 && parsed.Exp < PlatformShopCrypto.UnixSeconds()) return false;
|
||||||
|
payload = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
namespace Oms.Application.Integrations.PlatformShop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平台店铺 OAuth / OpenAPI 配置(对齐 WMS application.yml)。
|
||||||
|
/// </summary>
|
||||||
|
public class PlatformShopOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Oms:PlatformShop";
|
||||||
|
|
||||||
|
/// <summary>前端站点根地址,用于拼 OAuth 回调页,如 http://localhost:5173</summary>
|
||||||
|
public string FrontendBaseUrl { get; set; } = "http://localhost:5173";
|
||||||
|
|
||||||
|
/// <summary>签名 state 密钥;空则用 Jwt SecretKey</summary>
|
||||||
|
public string StateSecret { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public LazadaPlatformOptions Lazada { get; set; } = new();
|
||||||
|
|
||||||
|
public ShopeePlatformOptions Shopee { get; set; } = new();
|
||||||
|
|
||||||
|
public TiktokPlatformOptions Tiktok { get; set; } = new();
|
||||||
|
|
||||||
|
public TemuPlatformOptions Temu { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LazadaPlatformOptions
|
||||||
|
{
|
||||||
|
public string AppKey { get; set; } = string.Empty;
|
||||||
|
public string AppSecret { get; set; } = string.Empty;
|
||||||
|
public string AuthorizeUrl { get; set; } = "https://auth.lazada.com/oauth/authorize";
|
||||||
|
public string AccessTokenUrl { get; set; } = "https://auth.lazada.com/rest";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ShopeePlatformOptions
|
||||||
|
{
|
||||||
|
public int PartnerId { get; set; }
|
||||||
|
public string PartnerKey { get; set; } = string.Empty;
|
||||||
|
public string HostUrl { get; set; } = "https://openplatform.shopee.cn";
|
||||||
|
public string HostAuthUrl { get; set; } = "https://partner.shopeemobile.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TiktokPlatformOptions
|
||||||
|
{
|
||||||
|
public string AppKey { get; set; } = string.Empty;
|
||||||
|
public string AppSecret { get; set; } = string.Empty;
|
||||||
|
public string HostAuth { get; set; } = "https://auth.tiktok-shops.com";
|
||||||
|
public string HostApi { get; set; } = "https://open-api.tiktokglobalshop.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TemuPlatformOptions
|
||||||
|
{
|
||||||
|
public string AppKey { get; set; } = string.Empty;
|
||||||
|
public string AppSecret { get; set; } = string.Empty;
|
||||||
|
public string HostAuthUrl { get; set; } = "https://openapi-b-us.temu.com/openapi/router";
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Oms.Application.Integrations.PlatformShop;
|
||||||
|
|
||||||
|
public static class PlatformShopServiceExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddOmsPlatformShopIntegration(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.Configure<PlatformShopOptions>(configuration.GetSection(PlatformShopOptions.SectionName));
|
||||||
|
services.AddHttpClient(PlatformShopApiClient.HttpClientName, client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(60);
|
||||||
|
});
|
||||||
|
services.AddScoped<IPlatformShopApiClient, PlatformShopApiClient>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ using Fake.Security.Claims;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Oms.Application.Integrations.SmallBagTrack;
|
using Oms.Application.Integrations.SmallBagTrack;
|
||||||
using Oms.Application.Integrations.Itfx;
|
using Oms.Application.Integrations.Itfx;
|
||||||
|
using Oms.Application.Integrations.PlatformShop;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Application.Workers;
|
using Oms.Application.Workers;
|
||||||
using Oms.Domain;
|
using Oms.Domain;
|
||||||
@ -28,6 +29,7 @@ public class OmsApplicationModule : FakeModule
|
|||||||
});
|
});
|
||||||
context.Services.AddOmsSmallBagTrack(context.Services.GetConfiguration());
|
context.Services.AddOmsSmallBagTrack(context.Services.GetConfiguration());
|
||||||
context.Services.AddOmsItfxIntegration(context.Services.GetConfiguration());
|
context.Services.AddOmsItfxIntegration(context.Services.GetConfiguration());
|
||||||
|
context.Services.AddOmsPlatformShopIntegration(context.Services.GetConfiguration());
|
||||||
context.Services.AddOmsOrderBatchMessaging(context.Services.GetConfiguration());
|
context.Services.AddOmsOrderBatchMessaging(context.Services.GetConfiguration());
|
||||||
context.Services.AddXhsLlmClient();
|
context.Services.AddXhsLlmClient();
|
||||||
context.Services.AddDistributedMemoryCache();
|
context.Services.AddDistributedMemoryCache();
|
||||||
|
|||||||
@ -52,6 +52,14 @@ public partial class OrderService
|
|||||||
throw new BusinessException("发件地址不存在");
|
throw new BusinessException("发件地址不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var addressType = string.IsNullOrWhiteSpace(senderAddress.AddressType)
|
||||||
|
? CustomerAddressTypeCodes.Sender
|
||||||
|
: senderAddress.AddressType;
|
||||||
|
if (!string.Equals(addressType, CustomerAddressTypeCodes.Sender, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new BusinessException("揽收订单仅可选择发件地址");
|
||||||
|
}
|
||||||
|
|
||||||
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId);
|
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId);
|
||||||
if (warehouse == null)
|
if (warehouse == null)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Dtos.Shared;
|
using Oms.Application.Dtos.Shared;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
using Oms.Domain.OrdersAggregate.Entities;
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
using Oms.Domain.OrdersAggregate.Repositories;
|
using Oms.Domain.OrdersAggregate.Repositories;
|
||||||
|
|
||||||
@ -15,7 +16,7 @@ public partial class OrderService
|
|||||||
LazyServiceProvider.GetRequiredService<ICustomerSenderAddressRepository>();
|
LazyServiceProvider.GetRequiredService<ICustomerSenderAddressRepository>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 发件地址分页列表。
|
/// 客户地址分页列表(发件/退件/收件)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/sender-address/list")]
|
[HttpPost("/order/sender-address/list")]
|
||||||
public async Task<OmsPageResult<CustomerSenderAddressOutput>> GetSenderAddressListAsync(
|
public async Task<OmsPageResult<CustomerSenderAddressOutput>> GetSenderAddressListAsync(
|
||||||
@ -46,7 +47,7 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 发件地址详情。
|
/// 客户地址详情。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("/order/sender-address/detail")]
|
[HttpGet("/order/sender-address/detail")]
|
||||||
public async Task<CustomerSenderAddressOutput> GetSenderAddressDetailAsync([FromQuery] int id)
|
public async Task<CustomerSenderAddressOutput> GetSenderAddressDetailAsync([FromQuery] int id)
|
||||||
@ -57,23 +58,25 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 新增发件地址。
|
/// 新增客户地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/sender-address/create")]
|
[HttpPost("/order/sender-address/create")]
|
||||||
public async Task<object> CreateSenderAddressAsync([FromBody] CreateCustomerSenderAddressInput input)
|
public async Task<object> CreateSenderAddressAsync([FromBody] CreateCustomerSenderAddressInput input)
|
||||||
{
|
{
|
||||||
var customer = await OmsSessionHelper.ResolveSessionCustomerAsync(CustomerRepository, CurrentUser);
|
var customer = await OmsSessionHelper.ResolveSessionCustomerAsync(CustomerRepository, CurrentUser);
|
||||||
|
var addressType = NormalizeAddressTypeOrThrow(input.AddressType);
|
||||||
ValidateSenderAddressInput(input);
|
ValidateSenderAddressInput(input);
|
||||||
var countryId = await ResolveSenderCountryIdAsync(input.CountryCode);
|
var countryId = await ResolveSenderCountryIdAsync(input.CountryCode);
|
||||||
|
|
||||||
if (input.IsDefault)
|
if (input.IsDefault)
|
||||||
{
|
{
|
||||||
await CustomerSenderAddressRepository.ClearDefaultAsync(customer.Id);
|
await CustomerSenderAddressRepository.ClearDefaultAsync(customer.Id, addressType);
|
||||||
}
|
}
|
||||||
|
|
||||||
var entity = new CustomerSenderAddress
|
var entity = new CustomerSenderAddress
|
||||||
{
|
{
|
||||||
CustomerId = customer.Id,
|
CustomerId = customer.Id,
|
||||||
|
AddressType = addressType,
|
||||||
AddressName = TrimOrNull(input.AddressName),
|
AddressName = TrimOrNull(input.AddressName),
|
||||||
SenderName = input.SenderName.Trim(),
|
SenderName = input.SenderName.Trim(),
|
||||||
Company = TrimOrNull(input.Company),
|
Company = TrimOrNull(input.Company),
|
||||||
@ -96,20 +99,22 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 更新发件地址。
|
/// 更新客户地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/sender-address/update")]
|
[HttpPost("/order/sender-address/update")]
|
||||||
public async Task UpdateSenderAddressAsync([FromBody] UpdateCustomerSenderAddressInput input)
|
public async Task UpdateSenderAddressAsync([FromBody] UpdateCustomerSenderAddressInput input)
|
||||||
{
|
{
|
||||||
var entity = await GetOwnedSenderAddressOrThrowAsync(input.Id);
|
var entity = await GetOwnedSenderAddressOrThrowAsync(input.Id);
|
||||||
|
var addressType = NormalizeAddressTypeOrThrow(input.AddressType);
|
||||||
ValidateSenderAddressInput(input);
|
ValidateSenderAddressInput(input);
|
||||||
var countryId = await ResolveSenderCountryIdAsync(input.CountryCode);
|
var countryId = await ResolveSenderCountryIdAsync(input.CountryCode);
|
||||||
|
|
||||||
if (input.IsDefault)
|
if (input.IsDefault)
|
||||||
{
|
{
|
||||||
await CustomerSenderAddressRepository.ClearDefaultAsync(entity.CustomerId, entity.Id);
|
await CustomerSenderAddressRepository.ClearDefaultAsync(entity.CustomerId, addressType, entity.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entity.AddressType = addressType;
|
||||||
entity.AddressName = TrimOrNull(input.AddressName);
|
entity.AddressName = TrimOrNull(input.AddressName);
|
||||||
entity.SenderName = input.SenderName.Trim();
|
entity.SenderName = input.SenderName.Trim();
|
||||||
entity.Company = TrimOrNull(input.Company);
|
entity.Company = TrimOrNull(input.Company);
|
||||||
@ -130,7 +135,7 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 删除发件地址(软删)。
|
/// 删除客户地址(软删)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/sender-address/delete")]
|
[HttpPost("/order/sender-address/delete")]
|
||||||
public async Task DeleteSenderAddressAsync([FromBody] IdInput input)
|
public async Task DeleteSenderAddressAsync([FromBody] IdInput input)
|
||||||
@ -140,13 +145,16 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 设为默认发件地址。
|
/// 设为同类型默认地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/sender-address/set-default")]
|
[HttpPost("/order/sender-address/set-default")]
|
||||||
public async Task SetDefaultSenderAddressAsync([FromBody] IdInput input)
|
public async Task SetDefaultSenderAddressAsync([FromBody] IdInput input)
|
||||||
{
|
{
|
||||||
var entity = await GetOwnedSenderAddressOrThrowAsync(input.Id);
|
var entity = await GetOwnedSenderAddressOrThrowAsync(input.Id);
|
||||||
await CustomerSenderAddressRepository.ClearDefaultAsync(entity.CustomerId, entity.Id);
|
var addressType = string.IsNullOrWhiteSpace(entity.AddressType)
|
||||||
|
? CustomerAddressTypeCodes.Sender
|
||||||
|
: entity.AddressType;
|
||||||
|
await CustomerSenderAddressRepository.ClearDefaultAsync(entity.CustomerId, addressType, entity.Id);
|
||||||
entity.IsDefault = 1;
|
entity.IsDefault = 1;
|
||||||
await CustomerSenderAddressRepository.UpdateAsync(entity);
|
await CustomerSenderAddressRepository.UpdateAsync(entity);
|
||||||
}
|
}
|
||||||
@ -157,29 +165,41 @@ public partial class OrderService
|
|||||||
var entity = await CustomerSenderAddressRepository.GetOwnedAsync(id, customer.Id);
|
var entity = await CustomerSenderAddressRepository.GetOwnedAsync(id, customer.Id);
|
||||||
if (entity == null)
|
if (entity == null)
|
||||||
{
|
{
|
||||||
throw new BusinessException("发件地址不存在");
|
throw new BusinessException("地址不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeAddressTypeOrThrow(string? addressType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return CustomerAddressTypeCodes.NormalizeOrThrow(addressType);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
throw new BusinessException("地址类型不合法,仅支持发件/退件/收件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ValidateSenderAddressInput(CreateCustomerSenderAddressInput input)
|
private static void ValidateSenderAddressInput(CreateCustomerSenderAddressInput input)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(input.SenderName))
|
if (string.IsNullOrWhiteSpace(input.SenderName))
|
||||||
{
|
{
|
||||||
throw new BusinessException("发件人姓名不能为空");
|
throw new BusinessException("联系人姓名不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(input.StreetAddress1))
|
if (string.IsNullOrWhiteSpace(input.StreetAddress1))
|
||||||
{
|
{
|
||||||
throw new BusinessException("发件地址不能为空");
|
throw new BusinessException("详细地址不能为空");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int?> ResolveSenderCountryIdAsync(string? countryCode)
|
private async Task<int?> ResolveSenderCountryIdAsync(string? countryCode)
|
||||||
{
|
{
|
||||||
var code = string.IsNullOrWhiteSpace(countryCode) ? "CN" : countryCode.Trim();
|
var code = string.IsNullOrWhiteSpace(countryCode) ? "CN" : countryCode.Trim();
|
||||||
var country = await GetCountryOrThrowAsync(code, "发件国家不存在");
|
var country = await GetCountryOrThrowAsync(code, "国家不存在");
|
||||||
return country.Id;
|
return country.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,8 +209,13 @@ public partial class OrderService
|
|||||||
{
|
{
|
||||||
var keyword = input.Keyword?.Trim();
|
var keyword = input.Keyword?.Trim();
|
||||||
var onlyDefault = input.OnlyDefault;
|
var onlyDefault = input.OnlyDefault;
|
||||||
|
var addressType = input.AddressType?.Trim();
|
||||||
|
var filterByType = !string.IsNullOrWhiteSpace(addressType)
|
||||||
|
&& !string.Equals(addressType, "all", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
return x =>
|
return x =>
|
||||||
x.CustomerId == customerId
|
x.CustomerId == customerId
|
||||||
|
&& (!filterByType || x.AddressType == addressType)
|
||||||
&& (!onlyDefault || x.IsDefault == 1)
|
&& (!onlyDefault || x.IsDefault == 1)
|
||||||
&& (string.IsNullOrWhiteSpace(keyword)
|
&& (string.IsNullOrWhiteSpace(keyword)
|
||||||
|| (x.AddressName ?? string.Empty).Contains(keyword)
|
|| (x.AddressName ?? string.Empty).Contains(keyword)
|
||||||
@ -221,10 +246,16 @@ public partial class OrderService
|
|||||||
countryCode = code;
|
countryCode = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var addressType = string.IsNullOrWhiteSpace(entity.AddressType)
|
||||||
|
? CustomerAddressTypeCodes.Sender
|
||||||
|
: entity.AddressType;
|
||||||
|
|
||||||
return new CustomerSenderAddressOutput
|
return new CustomerSenderAddressOutput
|
||||||
{
|
{
|
||||||
Id = entity.Id,
|
Id = entity.Id,
|
||||||
CustomerId = entity.CustomerId,
|
CustomerId = entity.CustomerId,
|
||||||
|
AddressType = addressType,
|
||||||
|
AddressTypeName = CustomerAddressTypeCodes.GetDisplayName(addressType),
|
||||||
AddressName = entity.AddressName,
|
AddressName = entity.AddressName,
|
||||||
SenderName = entity.SenderName,
|
SenderName = entity.SenderName,
|
||||||
Company = entity.Company,
|
Company = entity.Company,
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
using System.Linq.Expressions;
|
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.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.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Domain.CustomersAggregate.Repositories;
|
using Oms.Domain.CustomersAggregate.Repositories;
|
||||||
using Oms.Domain.OrdersAggregate.Entities;
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
@ -12,19 +15,18 @@ using Oms.Domain.WarehouseAggregate.Consts;
|
|||||||
namespace Oms.Application.Services.Orders;
|
namespace Oms.Application.Services.Orders;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 店铺授权 API 服务。
|
/// 店铺授权 API 服务(对齐 WMS /vip/shop 授权跳转与回调落库)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiExplorerSettings(GroupName = "OMS-Order")]
|
[ApiExplorerSettings(GroupName = "OMS-Order")]
|
||||||
public class PlatformStoreService : ApplicationService
|
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 PlatformShopOptions PlatformShopOptions =>
|
||||||
|
LazyServiceProvider.GetRequiredService<IOptions<PlatformShopOptions>>().Value;
|
||||||
|
private IConfiguration Configuration => LazyServiceProvider.GetRequiredService<IConfiguration>();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询店铺授权列表。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="input">入参:店铺分页查询条件。</param>
|
|
||||||
/// <returns>店铺分页结果。</returns>
|
|
||||||
[HttpPost("/order/platform-store/list")]
|
[HttpPost("/order/platform-store/list")]
|
||||||
public async Task<OmsPageResult<PlatformStoreOutput>> GetListAsync(PlatformStorePageInput input)
|
public async Task<OmsPageResult<PlatformStoreOutput>> GetListAsync(PlatformStorePageInput input)
|
||||||
{
|
{
|
||||||
@ -50,35 +52,120 @@ public class PlatformStoreService : ApplicationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取店铺 OAuth 授权地址。
|
/// 按平台生成授权跳转地址;Temu 当场建店并返回 "/"。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">入参:平台编码与回调地址。</param>
|
|
||||||
/// <returns>授权跳转 URL。</returns>
|
|
||||||
[HttpPost("/order/platform-store/auth-url")]
|
[HttpPost("/order/platform-store/auth-url")]
|
||||||
public async Task<PlatformStoreAuthUrlOutput> GetAuthUrlAsync(PlatformStoreAuthUrlInput input)
|
public async Task<PlatformStoreAuthUrlOutput> GetAuthUrlAsync(PlatformStoreAuthUrlInput input)
|
||||||
{
|
{
|
||||||
await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
|
var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
|
||||||
CustomerRepository,
|
CustomerRepository,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
input.CustomerId);
|
input.CustomerId);
|
||||||
|
var platform = NormalizePlatform(input.PlatformCode);
|
||||||
var platform = input.PlatformCode.Trim().ToLowerInvariant();
|
var nickname = (input.StoreNickname ?? string.Empty).Trim();
|
||||||
var redirect = string.IsNullOrWhiteSpace(input.RedirectUri)
|
if (string.IsNullOrWhiteSpace(nickname))
|
||||||
? "https://localhost/oauth/callback"
|
|
||||||
: input.RedirectUri.Trim();
|
|
||||||
|
|
||||||
return new PlatformStoreAuthUrlOutput
|
|
||||||
{
|
{
|
||||||
AuthUrl = $"https://oauth.example.com/{platform}?redirect={Uri.EscapeDataString(redirect)}",
|
throw new BusinessException("请输入店铺名称");
|
||||||
Message = "OAuth 对接待平台 SDK 接入,当前返回占位地址"
|
}
|
||||||
|
|
||||||
|
return platform switch
|
||||||
|
{
|
||||||
|
"temu" => await CreateTemuStoreAsync(customerId, nickname, input),
|
||||||
|
"lazada" or "shopee" or "tiktok" => BuildOAuthUrl(customerId, platform, nickname, input),
|
||||||
|
_ => throw new BusinessException($"暂不支持平台:{platform}")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 保存店铺授权(新增或编辑)。
|
/// OAuth 回调:用 code 换 token 并落库(同 PlatformStoreId 视为续期,不改昵称)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">入参:店铺保存数据。</param>
|
[HttpPost("/order/platform-store/auth-callback")]
|
||||||
/// <returns>保存后的店铺信息。</returns>
|
public async Task<PlatformStoreAuthCallbackOutput> AuthCallbackAsync(PlatformStoreAuthCallbackInput input)
|
||||||
|
{
|
||||||
|
var platform = NormalizePlatform(input.PlatformCode);
|
||||||
|
if (platform is not ("lazada" or "shopee" or "tiktok"))
|
||||||
|
{
|
||||||
|
throw new BusinessException("当前平台不走 OAuth 回调");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Code))
|
||||||
|
{
|
||||||
|
throw new BusinessException("缺少授权 code");
|
||||||
|
}
|
||||||
|
|
||||||
|
var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
|
||||||
|
CustomerRepository,
|
||||||
|
CurrentUser,
|
||||||
|
input.CustomerId);
|
||||||
|
|
||||||
|
var nickname = (input.StoreNickname ?? string.Empty).Trim();
|
||||||
|
var siteCountry = input.SiteCountry?.Trim();
|
||||||
|
var stateSecret = ResolveStateSecret();
|
||||||
|
if (PlatformShopAuthState.TryDecode(input.State, stateSecret, out var statePayload)
|
||||||
|
&& statePayload.CustomerId == customerId)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(statePayload.StoreNickname))
|
||||||
|
{
|
||||||
|
nickname = statePayload.StoreNickname.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(statePayload.SiteCountry))
|
||||||
|
{
|
||||||
|
siteCountry = statePayload.SiteCountry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(nickname))
|
||||||
|
{
|
||||||
|
throw new BusinessException("缺少店铺昵称,请重新发起授权");
|
||||||
|
}
|
||||||
|
|
||||||
|
PlatformShopTokenResult token;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
token = await PlatformShopApi.ExchangeTokenAsync(new PlatformShopTokenExchangeRequest
|
||||||
|
{
|
||||||
|
PlatformCode = platform,
|
||||||
|
Code = input.Code.Trim(),
|
||||||
|
ShopId = input.ShopId,
|
||||||
|
MainAccountId = input.MainAccountId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new BusinessException($"平台换票失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var bound = new List<PlatformStoreOutput>();
|
||||||
|
var shops = token.Shops.Count > 0
|
||||||
|
? token.Shops
|
||||||
|
: [new PlatformShopBoundShop
|
||||||
|
{
|
||||||
|
PlatformStoreId = token.PlatformStoreId ?? Guid.NewGuid().ToString("N"),
|
||||||
|
PlatformAccount = token.PlatformAccount,
|
||||||
|
SiteCountry = token.SiteCountry ?? siteCountry
|
||||||
|
}];
|
||||||
|
|
||||||
|
foreach (var shop in shops)
|
||||||
|
{
|
||||||
|
var entity = await UpsertAuthorizedStoreAsync(
|
||||||
|
customerId,
|
||||||
|
platform,
|
||||||
|
nickname,
|
||||||
|
shop.SiteCountry ?? siteCountry ?? token.SiteCountry,
|
||||||
|
shop,
|
||||||
|
token);
|
||||||
|
bound.Add(MapOutput(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PlatformStoreAuthCallbackOutput
|
||||||
|
{
|
||||||
|
BoundCount = bound.Count,
|
||||||
|
Message = $"成功绑定 {bound.Count} 个店铺",
|
||||||
|
Stores = bound
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("/order/platform-store/save")]
|
[HttpPost("/order/platform-store/save")]
|
||||||
public async Task<PlatformStoreOutput> SaveAsync(PlatformStoreSaveInput input)
|
public async Task<PlatformStoreOutput> SaveAsync(PlatformStoreSaveInput input)
|
||||||
{
|
{
|
||||||
@ -99,10 +186,16 @@ public class PlatformStoreService : ApplicationService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
var platform = NormalizePlatform(input.PlatformCode);
|
||||||
|
if (string.IsNullOrWhiteSpace(platform) || platform is "manual")
|
||||||
|
{
|
||||||
|
throw new BusinessException("平台不能为空或不支持手工店铺");
|
||||||
|
}
|
||||||
|
|
||||||
entity = new SalesStore
|
entity = new SalesStore
|
||||||
{
|
{
|
||||||
CustomerId = customerId,
|
CustomerId = customerId,
|
||||||
PlatformCode = string.IsNullOrWhiteSpace(input.PlatformCode) ? "manual" : input.PlatformCode.Trim(),
|
PlatformCode = platform,
|
||||||
AuthStatus = SalesStoreAuthStatusCodes.Active,
|
AuthStatus = SalesStoreAuthStatusCodes.Active,
|
||||||
AuthTime = DateTime.Now
|
AuthTime = DateTime.Now
|
||||||
};
|
};
|
||||||
@ -126,10 +219,6 @@ public class PlatformStoreService : ApplicationService
|
|||||||
return MapOutput(entity);
|
return MapOutput(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 删除店铺授权。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="input">入参:店铺 ID 列表。</param>
|
|
||||||
[HttpPost("/order/platform-store/delete")]
|
[HttpPost("/order/platform-store/delete")]
|
||||||
public async Task DeleteAsync(PlatformStoreIdsInput input)
|
public async Task DeleteAsync(PlatformStoreIdsInput input)
|
||||||
{
|
{
|
||||||
@ -151,6 +240,155 @@ public class PlatformStoreService : ApplicationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<PlatformStoreAuthUrlOutput> CreateTemuStoreAsync(
|
||||||
|
int customerId,
|
||||||
|
string nickname,
|
||||||
|
PlatformStoreAuthUrlInput input)
|
||||||
|
{
|
||||||
|
var code = (input.AuthorizationCode ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(code))
|
||||||
|
{
|
||||||
|
throw new BusinessException("请填写 Temu 授权码");
|
||||||
|
}
|
||||||
|
|
||||||
|
PlatformShopTokenResult token;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
token = await PlatformShopApi.CreateTemuStoreTokenAsync(code);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new BusinessException($"Temu 授权失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var shop = token.Shops.FirstOrDefault() ?? new PlatformShopBoundShop
|
||||||
|
{
|
||||||
|
PlatformStoreId = token.PlatformStoreId ?? Guid.NewGuid().ToString("N"),
|
||||||
|
PlatformAccount = token.PlatformAccount,
|
||||||
|
SiteCountry = input.SiteCountry ?? "US"
|
||||||
|
};
|
||||||
|
shop.SiteCountry ??= string.IsNullOrWhiteSpace(input.SiteCountry) ? "US" : input.SiteCountry.Trim();
|
||||||
|
|
||||||
|
await UpsertAuthorizedStoreAsync(customerId, "temu", nickname, shop.SiteCountry, shop, token);
|
||||||
|
return new PlatformStoreAuthUrlOutput
|
||||||
|
{
|
||||||
|
AuthUrl = "/",
|
||||||
|
Message = "Temu 店铺授权成功"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlatformStoreAuthUrlOutput BuildOAuthUrl(
|
||||||
|
int customerId,
|
||||||
|
string platform,
|
||||||
|
string nickname,
|
||||||
|
PlatformStoreAuthUrlInput input)
|
||||||
|
{
|
||||||
|
var redirect = string.IsNullOrWhiteSpace(input.RedirectUri)
|
||||||
|
? $"{PlatformShopOptions.FrontendBaseUrl.TrimEnd('/')}/oauth/platform-store/{platform}"
|
||||||
|
: input.RedirectUri.Trim();
|
||||||
|
|
||||||
|
var state = PlatformShopAuthState.Encode(new PlatformShopAuthStatePayload
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
PlatformCode = platform,
|
||||||
|
StoreNickname = nickname,
|
||||||
|
SiteCountry = input.SiteCountry?.Trim(),
|
||||||
|
Exp = DateTimeOffset.UtcNow.AddHours(2).ToUnixTimeSeconds()
|
||||||
|
}, ResolveStateSecret());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var authUrl = PlatformShopApi.BuildAuthUrl(platform, redirect, state, input.SiteCountry);
|
||||||
|
return new PlatformStoreAuthUrlOutput
|
||||||
|
{
|
||||||
|
AuthUrl = authUrl,
|
||||||
|
State = state,
|
||||||
|
Message = "请在新窗口完成平台授权"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SalesStore> UpsertAuthorizedStoreAsync(
|
||||||
|
int customerId,
|
||||||
|
string platform,
|
||||||
|
string nickname,
|
||||||
|
string? siteCountry,
|
||||||
|
PlatformShopBoundShop shop,
|
||||||
|
PlatformShopTokenResult token)
|
||||||
|
{
|
||||||
|
var platformStoreId = (shop.PlatformStoreId ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(platformStoreId))
|
||||||
|
{
|
||||||
|
platformStoreId = Guid.NewGuid().ToString("N");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await StoreRepository.FirstOrDefaultAsync(x =>
|
||||||
|
x.CustomerId == customerId
|
||||||
|
&& x.PlatformCode == platform
|
||||||
|
&& x.PlatformStoreId == platformStoreId);
|
||||||
|
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var isRenew = existing != null;
|
||||||
|
var entity = existing ?? new SalesStore
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
PlatformCode = platform,
|
||||||
|
StoreNickname = nickname
|
||||||
|
};
|
||||||
|
|
||||||
|
// 续期不改昵称(对齐 WMS)
|
||||||
|
if (!isRenew)
|
||||||
|
{
|
||||||
|
entity.StoreNickname = nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.PlatformStoreId = platformStoreId;
|
||||||
|
entity.PlatformAccount = shop.PlatformAccount ?? token.PlatformAccount;
|
||||||
|
entity.SiteCountry = siteCountry ?? shop.SiteCountry ?? token.SiteCountry;
|
||||||
|
entity.SellerId = token.SellerId;
|
||||||
|
entity.AuthStatus = SalesStoreAuthStatusCodes.Active;
|
||||||
|
entity.AuthTime = now;
|
||||||
|
entity.AccessToken = token.AccessToken;
|
||||||
|
entity.RefreshToken = token.RefreshToken;
|
||||||
|
entity.TokenExpireTime = token.ExpiresInSeconds > 0
|
||||||
|
? now.AddSeconds(token.ExpiresInSeconds)
|
||||||
|
: now.AddDays(1);
|
||||||
|
entity.RefreshExpireTime = token.RefreshExpiresInSeconds > 0
|
||||||
|
? now.AddSeconds(token.RefreshExpiresInSeconds)
|
||||||
|
: entity.TokenExpireTime;
|
||||||
|
entity.TokenExtraJson = token.ExtraJson;
|
||||||
|
|
||||||
|
if (isRenew)
|
||||||
|
{
|
||||||
|
await StoreRepository.UpdateAsync(entity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await StoreRepository.InsertAsync(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveStateSecret()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(PlatformShopOptions.StateSecret))
|
||||||
|
{
|
||||||
|
return PlatformShopOptions.StateSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Configuration["Jwt:SecretKey"]
|
||||||
|
?? Configuration["JwtSettings:SecretKey"]
|
||||||
|
?? "oms-platform-shop-state-secret";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePlatform(string? code) =>
|
||||||
|
(code ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
|
||||||
private async Task<SalesStore> GetStoreOrThrowAsync(int? customerId, int id)
|
private async Task<SalesStore> GetStoreOrThrowAsync(int? customerId, int id)
|
||||||
{
|
{
|
||||||
var entity = customerId.HasValue
|
var entity = customerId.HasValue
|
||||||
@ -175,7 +413,6 @@ public class PlatformStoreService : ApplicationService
|
|||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
var within7Days = now.AddDays(7);
|
var within7Days = now.AddDays(7);
|
||||||
|
|
||||||
// 显式按过期筛选分支构造表达式,避免嵌套三元被 ORM 翻译成非法 IIF。
|
|
||||||
return filter switch
|
return filter switch
|
||||||
{
|
{
|
||||||
"expired" => x =>
|
"expired" => x =>
|
||||||
@ -227,7 +464,6 @@ public class PlatformStoreService : ApplicationService
|
|||||||
"shopee" => "Shopee",
|
"shopee" => "Shopee",
|
||||||
"tiktok" => "TikTok",
|
"tiktok" => "TikTok",
|
||||||
"temu" => "Temu",
|
"temu" => "Temu",
|
||||||
"manual" => "手工店铺",
|
|
||||||
_ => platformCode
|
_ => platformCode
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
namespace Oms.Domain.OrdersAggregate.Consts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户地址类型:发件 / 退件 / 收件。
|
||||||
|
/// </summary>
|
||||||
|
public static class CustomerAddressTypeCodes
|
||||||
|
{
|
||||||
|
public const string Sender = "sender";
|
||||||
|
public const string Return = "return";
|
||||||
|
public const string Receiver = "receiver";
|
||||||
|
|
||||||
|
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
Sender,
|
||||||
|
Return,
|
||||||
|
Receiver
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string NormalizeOrThrow(string? addressType)
|
||||||
|
{
|
||||||
|
var code = string.IsNullOrWhiteSpace(addressType) ? Sender : addressType.Trim();
|
||||||
|
if (!All.Contains(code))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("地址类型不合法,仅支持 sender/return/receiver");
|
||||||
|
}
|
||||||
|
|
||||||
|
return All.First(x => string.Equals(x, code, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetDisplayName(string? addressType)
|
||||||
|
{
|
||||||
|
return addressType?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
Sender => "发件",
|
||||||
|
Return => "退件",
|
||||||
|
Receiver => "收件",
|
||||||
|
_ => addressType ?? string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,15 +4,23 @@ using SqlSugar;
|
|||||||
namespace Oms.Domain.OrdersAggregate.Entities;
|
namespace Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 客户发件地址簿。
|
/// 客户地址簿(发件/退件/收件)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[SugarTable("t_customer_sender_address")]
|
[SugarTable("t_customer_sender_address")]
|
||||||
public class CustomerSenderAddress : OmsAuditedAggregateRoot
|
public class CustomerSenderAddress : OmsAuditedAggregateRoot
|
||||||
{
|
{
|
||||||
public int CustomerId { get; set; }
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 地址类型:sender / return / receiver。
|
||||||
|
/// </summary>
|
||||||
|
public string AddressType { get; set; } = "sender";
|
||||||
|
|
||||||
public string? AddressName { get; set; }
|
public string? AddressName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 联系人姓名。
|
||||||
|
/// </summary>
|
||||||
public string SenderName { get; set; } = string.Empty;
|
public string SenderName { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string? Company { get; set; }
|
public string? Company { get; set; }
|
||||||
|
|||||||
@ -7,5 +7,9 @@ public interface ICustomerSenderAddressRepository : IRepository<CustomerSenderAd
|
|||||||
{
|
{
|
||||||
Task<CustomerSenderAddress?> GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default);
|
Task<CustomerSenderAddress?> GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task ClearDefaultAsync(int customerId, int? excludeId = null, CancellationToken cancellationToken = default);
|
Task ClearDefaultAsync(
|
||||||
|
int customerId,
|
||||||
|
string addressType,
|
||||||
|
int? excludeId = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,10 +12,17 @@ public class CustomerSenderAddressRepository : OmsSqlSugarRepository<CustomerSen
|
|||||||
return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken);
|
return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ClearDefaultAsync(int customerId, int? excludeId = null, CancellationToken cancellationToken = default)
|
public async Task ClearDefaultAsync(
|
||||||
|
int customerId,
|
||||||
|
string addressType,
|
||||||
|
int? excludeId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var type = addressType.Trim();
|
||||||
var list = await GetListAsync(
|
var list = await GetListAsync(
|
||||||
x => x.CustomerId == customerId && x.IsDefault == 1
|
x => x.CustomerId == customerId
|
||||||
|
&& x.AddressType == type
|
||||||
|
&& x.IsDefault == 1
|
||||||
&& (!excludeId.HasValue || x.Id != excludeId.Value),
|
&& (!excludeId.HasValue || x.Id != excludeId.Value),
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
if (list.Count == 0)
|
if (list.Count == 0)
|
||||||
|
|||||||
@ -62,6 +62,32 @@
|
|||||||
"Itfx": {
|
"Itfx": {
|
||||||
"BaseUrl": "http://api.nanafa.com/itfx",
|
"BaseUrl": "http://api.nanafa.com/itfx",
|
||||||
"TimeoutSeconds": 30
|
"TimeoutSeconds": 30
|
||||||
|
},
|
||||||
|
"PlatformShop": {
|
||||||
|
"FrontendBaseUrl": "http://localhost:5173",
|
||||||
|
"Lazada": {
|
||||||
|
"AppKey": "123631",
|
||||||
|
"AppSecret": "7vBz2EaD6rIupeGEfwrgkabsTNsXt2ix",
|
||||||
|
"AuthorizeUrl": "https://auth.lazada.com/oauth/authorize",
|
||||||
|
"AccessTokenUrl": "https://auth.lazada.com/rest"
|
||||||
|
},
|
||||||
|
"Shopee": {
|
||||||
|
"PartnerId": 2004112,
|
||||||
|
"PartnerKey": "2f385d7ea38b087d5d71f7292e06a09c9f19022ca6c3f60ec0110a8214ef7d65",
|
||||||
|
"HostUrl": "https://openplatform.shopee.cn",
|
||||||
|
"HostAuthUrl": "https://partner.shopeemobile.com"
|
||||||
|
},
|
||||||
|
"Tiktok": {
|
||||||
|
"AppKey": "6hc64lc6vl312",
|
||||||
|
"AppSecret": "5a691a96ee6d844534769bab5c21de1f848fb678",
|
||||||
|
"HostAuth": "https://auth.tiktok-shops.com",
|
||||||
|
"HostApi": "https://open-api.tiktokglobalshop.com"
|
||||||
|
},
|
||||||
|
"Temu": {
|
||||||
|
"AppKey": "4ebbc9190ae410443d65b4c2faca981f",
|
||||||
|
"AppSecret": "4782d2d827276688bf4758bed55dbdd4bbe79a79",
|
||||||
|
"HostAuthUrl": "https://openapi-b-us.temu.com/openapi/router"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"OrderBatch": {
|
"OrderBatch": {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user