Merge pull request #19 from xiaoliutral/zzh
feat: 店铺真授权跳转与地址类型(发件/退件/收件)
This commit is contained in:
commit
ba0be083c2
10
db.sql
10
db.sql
@ -178,8 +178,9 @@ CREATE TABLE `t_order` (
|
||||
CREATE TABLE `t_customer_sender_address` (
|
||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id',
|
||||
`AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender' COMMENT '地址类型:sender发件/return退件/receiver收件',
|
||||
`AddressName` VARCHAR(64) NULL COMMENT '地址别名',
|
||||
`SenderName` VARCHAR(50) NOT NULL COMMENT '发件人姓名',
|
||||
`SenderName` VARCHAR(50) NOT NULL COMMENT '联系人姓名',
|
||||
`Company` VARCHAR(255) NULL COMMENT '公司',
|
||||
`Country` INT NULL COMMENT '国家 t_country.Id',
|
||||
`Province` VARCHAR(50) NULL COMMENT '州省',
|
||||
@ -192,7 +193,7 @@ CREATE TABLE `t_customer_sender_address` (
|
||||
`Phone` VARCHAR(50) NULL COMMENT '手机',
|
||||
`TelePhone` VARCHAR(50) 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-是',
|
||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
|
||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||
@ -201,8 +202,9 @@ CREATE TABLE `t_customer_sender_address` (
|
||||
`UpdateTime` DATETIME NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`Id`),
|
||||
KEY `idx_csa_customer` (`CustomerId`, `IsDeleted`, `Id`),
|
||||
KEY `idx_csa_customer_default` (`CustomerId`, `IsDefault`)
|
||||
) COMMENT='客户发件地址簿';
|
||||
KEY `idx_csa_customer_type` (`CustomerId`, `AddressType`, `IsDeleted`, `Id`),
|
||||
KEY `idx_csa_customer_default` (`CustomerId`, `AddressType`, `IsDefault`)
|
||||
) COMMENT='客户地址簿(发件/退件/收件)';
|
||||
|
||||
CREATE TABLE `t_order_template` (
|
||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
-- 已有库增量:客户发件地址簿 t_customer_sender_address
|
||||
-- 对应功能:客户 → 发件地址;揽收订单选址;见 oms系统设计-050801.md §4.2.10
|
||||
-- 已有库增量:客户地址簿 t_customer_sender_address(发件/退件/收件)
|
||||
-- 对应功能:客户 → 地址管理;揽收订单选发件地址
|
||||
-- 可重复执行(IF NOT EXISTS)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `t_customer_sender_address` (
|
||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id',
|
||||
`AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender' COMMENT '地址类型:sender发件/return退件/receiver收件',
|
||||
`AddressName` VARCHAR(64) NULL COMMENT '地址别名',
|
||||
`SenderName` VARCHAR(50) NOT NULL COMMENT '发件人姓名',
|
||||
`SenderName` VARCHAR(50) NOT NULL COMMENT '联系人姓名',
|
||||
`Company` VARCHAR(255) NULL COMMENT '公司',
|
||||
`Country` INT NULL COMMENT '国家 t_country.Id',
|
||||
`Province` VARCHAR(50) NULL COMMENT '州省',
|
||||
@ -19,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `t_customer_sender_address` (
|
||||
`Phone` VARCHAR(50) NULL COMMENT '手机',
|
||||
`TelePhone` VARCHAR(50) 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-是',
|
||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) 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 '更新时间',
|
||||
PRIMARY KEY (`Id`),
|
||||
KEY `idx_csa_customer` (`CustomerId`, `IsDeleted`, `Id`),
|
||||
KEY `idx_csa_customer_default` (`CustomerId`, `IsDefault`)
|
||||
) COMMENT='客户发件地址簿';
|
||||
KEY `idx_csa_customer_type` (`CustomerId`, `AddressType`, `IsDeleted`, `Id`),
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 客户发件地址分页查询。
|
||||
/// 客户地址分页查询。
|
||||
/// </summary>
|
||||
public class CustomerSenderAddressPaginatedInput
|
||||
{
|
||||
@ -13,6 +13,12 @@ public class CustomerSenderAddressPaginatedInput
|
||||
[Newtonsoft.Json.JsonProperty("pageSize")]
|
||||
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")]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
@ -21,13 +27,23 @@ public class CustomerSenderAddressPaginatedInput
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增发件地址。
|
||||
/// 新增客户地址。
|
||||
/// </summary>
|
||||
public class CreateCustomerSenderAddressInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 地址类型:sender 发件 / return 退件 / receiver 收件。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(16)]
|
||||
public string AddressType { get; set; } = "sender";
|
||||
|
||||
[StringLength(64)]
|
||||
public string? AddressName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系人姓名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string SenderName { get; set; } = string.Empty;
|
||||
@ -73,7 +89,7 @@ public class CreateCustomerSenderAddressInput
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新发件地址。
|
||||
/// 更新客户地址。
|
||||
/// </summary>
|
||||
public class UpdateCustomerSenderAddressInput : CreateCustomerSenderAddressInput
|
||||
{
|
||||
@ -82,7 +98,7 @@ public class UpdateCustomerSenderAddressInput : CreateCustomerSenderAddressInput
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发件地址出参。
|
||||
/// 客户地址出参。
|
||||
/// </summary>
|
||||
public class CustomerSenderAddressOutput
|
||||
{
|
||||
@ -90,6 +106,16 @@ public class CustomerSenderAddressOutput
|
||||
|
||||
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 SenderName { get; set; } = string.Empty;
|
||||
|
||||
@ -53,7 +53,7 @@ public class PlatformStoreSaveInput
|
||||
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
public string PlatformCode { get; set; } = "manual";
|
||||
public string PlatformCode { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string StoreNickname { get; set; } = string.Empty;
|
||||
@ -74,14 +74,61 @@ public class PlatformStoreAuthUrlInput
|
||||
|
||||
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 class PlatformStoreAuthUrlOutput
|
||||
{
|
||||
/// <summary>平台授权 URL;Temu 成功落库后返回 "/"</summary>
|
||||
public string AuthUrl { 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
|
||||
|
||||
@ -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 Oms.Application.Integrations.SmallBagTrack;
|
||||
using Oms.Application.Integrations.Itfx;
|
||||
using Oms.Application.Integrations.PlatformShop;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Application.Workers;
|
||||
using Oms.Domain;
|
||||
@ -28,6 +29,7 @@ public class OmsApplicationModule : FakeModule
|
||||
});
|
||||
context.Services.AddOmsSmallBagTrack(context.Services.GetConfiguration());
|
||||
context.Services.AddOmsItfxIntegration(context.Services.GetConfiguration());
|
||||
context.Services.AddOmsPlatformShopIntegration(context.Services.GetConfiguration());
|
||||
context.Services.AddOmsOrderBatchMessaging(context.Services.GetConfiguration());
|
||||
context.Services.AddXhsLlmClient();
|
||||
context.Services.AddDistributedMemoryCache();
|
||||
|
||||
@ -52,6 +52,14 @@ public partial class OrderService
|
||||
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);
|
||||
if (warehouse == null)
|
||||
{
|
||||
|
||||
@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Dtos.Shared;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
|
||||
@ -15,7 +16,7 @@ public partial class OrderService
|
||||
LazyServiceProvider.GetRequiredService<ICustomerSenderAddressRepository>();
|
||||
|
||||
/// <summary>
|
||||
/// 发件地址分页列表。
|
||||
/// 客户地址分页列表(发件/退件/收件)。
|
||||
/// </summary>
|
||||
[HttpPost("/order/sender-address/list")]
|
||||
public async Task<OmsPageResult<CustomerSenderAddressOutput>> GetSenderAddressListAsync(
|
||||
@ -46,7 +47,7 @@ public partial class OrderService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发件地址详情。
|
||||
/// 客户地址详情。
|
||||
/// </summary>
|
||||
[HttpGet("/order/sender-address/detail")]
|
||||
public async Task<CustomerSenderAddressOutput> GetSenderAddressDetailAsync([FromQuery] int id)
|
||||
@ -57,23 +58,25 @@ public partial class OrderService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增发件地址。
|
||||
/// 新增客户地址。
|
||||
/// </summary>
|
||||
[HttpPost("/order/sender-address/create")]
|
||||
public async Task<object> CreateSenderAddressAsync([FromBody] CreateCustomerSenderAddressInput input)
|
||||
{
|
||||
var customer = await OmsSessionHelper.ResolveSessionCustomerAsync(CustomerRepository, CurrentUser);
|
||||
var addressType = NormalizeAddressTypeOrThrow(input.AddressType);
|
||||
ValidateSenderAddressInput(input);
|
||||
var countryId = await ResolveSenderCountryIdAsync(input.CountryCode);
|
||||
|
||||
if (input.IsDefault)
|
||||
{
|
||||
await CustomerSenderAddressRepository.ClearDefaultAsync(customer.Id);
|
||||
await CustomerSenderAddressRepository.ClearDefaultAsync(customer.Id, addressType);
|
||||
}
|
||||
|
||||
var entity = new CustomerSenderAddress
|
||||
{
|
||||
CustomerId = customer.Id,
|
||||
AddressType = addressType,
|
||||
AddressName = TrimOrNull(input.AddressName),
|
||||
SenderName = input.SenderName.Trim(),
|
||||
Company = TrimOrNull(input.Company),
|
||||
@ -96,20 +99,22 @@ public partial class OrderService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新发件地址。
|
||||
/// 更新客户地址。
|
||||
/// </summary>
|
||||
[HttpPost("/order/sender-address/update")]
|
||||
public async Task UpdateSenderAddressAsync([FromBody] UpdateCustomerSenderAddressInput input)
|
||||
{
|
||||
var entity = await GetOwnedSenderAddressOrThrowAsync(input.Id);
|
||||
var addressType = NormalizeAddressTypeOrThrow(input.AddressType);
|
||||
ValidateSenderAddressInput(input);
|
||||
var countryId = await ResolveSenderCountryIdAsync(input.CountryCode);
|
||||
|
||||
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.SenderName = input.SenderName.Trim();
|
||||
entity.Company = TrimOrNull(input.Company);
|
||||
@ -130,7 +135,7 @@ public partial class OrderService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除发件地址(软删)。
|
||||
/// 删除客户地址(软删)。
|
||||
/// </summary>
|
||||
[HttpPost("/order/sender-address/delete")]
|
||||
public async Task DeleteSenderAddressAsync([FromBody] IdInput input)
|
||||
@ -140,13 +145,16 @@ public partial class OrderService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设为默认发件地址。
|
||||
/// 设为同类型默认地址。
|
||||
/// </summary>
|
||||
[HttpPost("/order/sender-address/set-default")]
|
||||
public async Task SetDefaultSenderAddressAsync([FromBody] IdInput input)
|
||||
{
|
||||
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;
|
||||
await CustomerSenderAddressRepository.UpdateAsync(entity);
|
||||
}
|
||||
@ -157,29 +165,41 @@ public partial class OrderService
|
||||
var entity = await CustomerSenderAddressRepository.GetOwnedAsync(id, customer.Id);
|
||||
if (entity == null)
|
||||
{
|
||||
throw new BusinessException("发件地址不存在");
|
||||
throw new BusinessException("地址不存在");
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static string NormalizeAddressTypeOrThrow(string? addressType)
|
||||
{
|
||||
try
|
||||
{
|
||||
return CustomerAddressTypeCodes.NormalizeOrThrow(addressType);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
throw new BusinessException("地址类型不合法,仅支持发件/退件/收件");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSenderAddressInput(CreateCustomerSenderAddressInput input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.SenderName))
|
||||
{
|
||||
throw new BusinessException("发件人姓名不能为空");
|
||||
throw new BusinessException("联系人姓名不能为空");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.StreetAddress1))
|
||||
{
|
||||
throw new BusinessException("发件地址不能为空");
|
||||
throw new BusinessException("详细地址不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int?> ResolveSenderCountryIdAsync(string? countryCode)
|
||||
{
|
||||
var code = string.IsNullOrWhiteSpace(countryCode) ? "CN" : countryCode.Trim();
|
||||
var country = await GetCountryOrThrowAsync(code, "发件国家不存在");
|
||||
var country = await GetCountryOrThrowAsync(code, "国家不存在");
|
||||
return country.Id;
|
||||
}
|
||||
|
||||
@ -189,8 +209,13 @@ public partial class OrderService
|
||||
{
|
||||
var keyword = input.Keyword?.Trim();
|
||||
var onlyDefault = input.OnlyDefault;
|
||||
var addressType = input.AddressType?.Trim();
|
||||
var filterByType = !string.IsNullOrWhiteSpace(addressType)
|
||||
&& !string.Equals(addressType, "all", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return x =>
|
||||
x.CustomerId == customerId
|
||||
&& (!filterByType || x.AddressType == addressType)
|
||||
&& (!onlyDefault || x.IsDefault == 1)
|
||||
&& (string.IsNullOrWhiteSpace(keyword)
|
||||
|| (x.AddressName ?? string.Empty).Contains(keyword)
|
||||
@ -221,10 +246,16 @@ public partial class OrderService
|
||||
countryCode = code;
|
||||
}
|
||||
|
||||
var addressType = string.IsNullOrWhiteSpace(entity.AddressType)
|
||||
? CustomerAddressTypeCodes.Sender
|
||||
: entity.AddressType;
|
||||
|
||||
return new CustomerSenderAddressOutput
|
||||
{
|
||||
Id = entity.Id,
|
||||
CustomerId = entity.CustomerId,
|
||||
AddressType = addressType,
|
||||
AddressTypeName = CustomerAddressTypeCodes.GetDisplayName(addressType),
|
||||
AddressName = entity.AddressName,
|
||||
SenderName = entity.SenderName,
|
||||
Company = entity.Company,
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
using System.Linq.Expressions;
|
||||
using Fake.Application;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Oms.Application.Dtos.PlatformStore;
|
||||
using Oms.Application.Dtos.Shared;
|
||||
using Oms.Application.Integrations.PlatformShop;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
@ -12,19 +15,18 @@ using Oms.Domain.WarehouseAggregate.Consts;
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 店铺授权 API 服务。
|
||||
/// 店铺授权 API 服务(对齐 WMS /vip/shop 授权跳转与回调落库)。
|
||||
/// </summary>
|
||||
[ApiExplorerSettings(GroupName = "OMS-Order")]
|
||||
public class PlatformStoreService : ApplicationService
|
||||
{
|
||||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||
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")]
|
||||
public async Task<OmsPageResult<PlatformStoreOutput>> GetListAsync(PlatformStorePageInput input)
|
||||
{
|
||||
@ -50,35 +52,120 @@ public class PlatformStoreService : ApplicationService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取店铺 OAuth 授权地址。
|
||||
/// 按平台生成授权跳转地址;Temu 当场建店并返回 "/"。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:平台编码与回调地址。</param>
|
||||
/// <returns>授权跳转 URL。</returns>
|
||||
[HttpPost("/order/platform-store/auth-url")]
|
||||
public async Task<PlatformStoreAuthUrlOutput> GetAuthUrlAsync(PlatformStoreAuthUrlInput input)
|
||||
{
|
||||
await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
|
||||
var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
|
||||
CustomerRepository,
|
||||
CurrentUser,
|
||||
input.CustomerId);
|
||||
|
||||
var platform = input.PlatformCode.Trim().ToLowerInvariant();
|
||||
var redirect = string.IsNullOrWhiteSpace(input.RedirectUri)
|
||||
? "https://localhost/oauth/callback"
|
||||
: input.RedirectUri.Trim();
|
||||
|
||||
return new PlatformStoreAuthUrlOutput
|
||||
var platform = NormalizePlatform(input.PlatformCode);
|
||||
var nickname = (input.StoreNickname ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(nickname))
|
||||
{
|
||||
AuthUrl = $"https://oauth.example.com/{platform}?redirect={Uri.EscapeDataString(redirect)}",
|
||||
Message = "OAuth 对接待平台 SDK 接入,当前返回占位地址"
|
||||
throw new BusinessException("请输入店铺名称");
|
||||
}
|
||||
|
||||
return platform switch
|
||||
{
|
||||
"temu" => await CreateTemuStoreAsync(customerId, nickname, input),
|
||||
"lazada" or "shopee" or "tiktok" => BuildOAuthUrl(customerId, platform, nickname, input),
|
||||
_ => throw new BusinessException($"暂不支持平台:{platform}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存店铺授权(新增或编辑)。
|
||||
/// OAuth 回调:用 code 换 token 并落库(同 PlatformStoreId 视为续期,不改昵称)。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:店铺保存数据。</param>
|
||||
/// <returns>保存后的店铺信息。</returns>
|
||||
[HttpPost("/order/platform-store/auth-callback")]
|
||||
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")]
|
||||
public async Task<PlatformStoreOutput> SaveAsync(PlatformStoreSaveInput input)
|
||||
{
|
||||
@ -99,10 +186,16 @@ public class PlatformStoreService : ApplicationService
|
||||
}
|
||||
else
|
||||
{
|
||||
var platform = NormalizePlatform(input.PlatformCode);
|
||||
if (string.IsNullOrWhiteSpace(platform) || platform is "manual")
|
||||
{
|
||||
throw new BusinessException("平台不能为空或不支持手工店铺");
|
||||
}
|
||||
|
||||
entity = new SalesStore
|
||||
{
|
||||
CustomerId = customerId,
|
||||
PlatformCode = string.IsNullOrWhiteSpace(input.PlatformCode) ? "manual" : input.PlatformCode.Trim(),
|
||||
PlatformCode = platform,
|
||||
AuthStatus = SalesStoreAuthStatusCodes.Active,
|
||||
AuthTime = DateTime.Now
|
||||
};
|
||||
@ -126,10 +219,6 @@ public class PlatformStoreService : ApplicationService
|
||||
return MapOutput(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除店铺授权。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:店铺 ID 列表。</param>
|
||||
[HttpPost("/order/platform-store/delete")]
|
||||
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)
|
||||
{
|
||||
var entity = customerId.HasValue
|
||||
@ -175,7 +413,6 @@ public class PlatformStoreService : ApplicationService
|
||||
var now = DateTime.Now;
|
||||
var within7Days = now.AddDays(7);
|
||||
|
||||
// 显式按过期筛选分支构造表达式,避免嵌套三元被 ORM 翻译成非法 IIF。
|
||||
return filter switch
|
||||
{
|
||||
"expired" => x =>
|
||||
@ -227,7 +464,6 @@ public class PlatformStoreService : ApplicationService
|
||||
"shopee" => "Shopee",
|
||||
"tiktok" => "TikTok",
|
||||
"temu" => "Temu",
|
||||
"manual" => "手工店铺",
|
||||
_ => 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;
|
||||
|
||||
/// <summary>
|
||||
/// 客户发件地址簿。
|
||||
/// 客户地址簿(发件/退件/收件)。
|
||||
/// </summary>
|
||||
[SugarTable("t_customer_sender_address")]
|
||||
public class CustomerSenderAddress : OmsAuditedAggregateRoot
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地址类型:sender / return / receiver。
|
||||
/// </summary>
|
||||
public string AddressType { get; set; } = "sender";
|
||||
|
||||
public string? AddressName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系人姓名。
|
||||
/// </summary>
|
||||
public string SenderName { get; set; } = string.Empty;
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
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(
|
||||
x => x.CustomerId == customerId && x.IsDefault == 1
|
||||
x => x.CustomerId == customerId
|
||||
&& x.AddressType == type
|
||||
&& x.IsDefault == 1
|
||||
&& (!excludeId.HasValue || x.Id != excludeId.Value),
|
||||
cancellationToken: cancellationToken);
|
||||
if (list.Count == 0)
|
||||
|
||||
@ -62,6 +62,32 @@
|
||||
"Itfx": {
|
||||
"BaseUrl": "http://api.nanafa.com/itfx",
|
||||
"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": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user