feat: 店铺真授权跳转与地址类型(发件/退件/收件)

对接 Lazada/Shopee/TikTok/Temu OAuth 与回调落库,移除手工店;地址管理支持按类型默认与揽收校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-16 10:25:13 +08:00
parent 341a318f0b
commit 674cbdd011
18 changed files with 1171 additions and 61 deletions

10
db.sql
View File

@ -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',

View File

@ -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='客户地址簿(发件/退件/收件)';

View 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;

View File

@ -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;

View File

@ -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

View File

@ -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)}&timestamp={ts}&sign={sign}";
}
private string BuildTiktokAuthUrl(string state)
{
EnsureTiktokConfigured();
var tiktok = _options.Tiktok;
return $"{tiktok.HostAuth}/oauth/authorize?app_key={Uri.EscapeDataString(tiktok.AppKey)}&state={Uri.EscapeDataString(state)}";
}
private async Task<PlatformShopTokenResult> ExchangeLazadaAsync(string code, CancellationToken ct)
{
EnsureLazadaConfigured();
var lazada = _options.Lazada;
var apiPath = "/auth/token/create";
var timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:sszzz");
var parameters = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["app_key"] = lazada.AppKey,
["code"] = code,
["sign_method"] = "sha256",
["timestamp"] = timestamp
};
var signBase = new StringBuilder(apiPath);
foreach (var kv in parameters)
{
signBase.Append(kv.Key).Append(kv.Value);
}
parameters["sign"] = PlatformShopCrypto.HmacSha256Hex(signBase.ToString(), lazada.AppSecret).ToUpperInvariant();
var client = httpClientFactory.CreateClient(HttpClientName);
var url = $"{lazada.AccessTokenUrl.TrimEnd('/')}{apiPath}";
using var content = new FormUrlEncodedContent(parameters);
using var response = await client.PostAsync(url, content, ct);
var raw = await response.Content.ReadAsStringAsync(ct);
logger.LogInformation("Lazada token/create response: {Body}", raw);
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
var apiCode = GetString(root, "code");
if (!string.IsNullOrEmpty(apiCode) && apiCode != "0")
{
throw new InvalidOperationException($"Lazada 获取 Token 失败: {GetString(root, "message") ?? raw}");
}
var accessToken = GetString(root, "access_token") ?? throw new InvalidOperationException("Lazada access_token 为空");
var country = GetString(root, "country");
var account = GetString(root, "account");
var expiresIn = GetInt(root, "expires_in");
var refreshExpiresIn = GetInt(root, "refresh_expires_in");
var platformStoreId = account ?? accessToken[..Math.Min(24, accessToken.Length)];
return new PlatformShopTokenResult
{
AccessToken = accessToken,
RefreshToken = GetString(root, "refresh_token"),
ExpiresInSeconds = expiresIn > 0 ? expiresIn : 86400,
RefreshExpiresInSeconds = refreshExpiresIn > 0 ? refreshExpiresIn : expiresIn,
PlatformStoreId = platformStoreId,
PlatformAccount = account,
SiteCountry = country,
ExtraJson = raw,
Shops =
[
new PlatformShopBoundShop
{
PlatformStoreId = platformStoreId,
PlatformAccount = account,
SiteCountry = country
}
]
};
}
private async Task<PlatformShopTokenResult> ExchangeShopeeAsync(
PlatformShopTokenExchangeRequest request,
CancellationToken ct)
{
EnsureShopeeConfigured();
var shopee = _options.Shopee;
var path = "/api/v2/auth/token/get";
var ts = PlatformShopCrypto.UnixSeconds();
var sign = PlatformShopCrypto.HmacSha256Hex($"{shopee.PartnerId}{path}{ts}", shopee.PartnerKey);
var url = $"{shopee.HostAuthUrl}{path}?sign={sign}&partner_id={shopee.PartnerId}&timestamp={ts}";
var body = new Dictionary<string, object?>
{
["code"] = request.Code,
["partner_id"] = shopee.PartnerId
};
var isMain = request.MainAccountId is > 0;
if (isMain)
{
body["main_account_id"] = request.MainAccountId;
}
else if (request.ShopId is > 0)
{
body["shop_id"] = request.ShopId;
}
else
{
throw new InvalidOperationException("Shopee 回调缺少 shop_id 或 main_account_id");
}
var client = httpClientFactory.CreateClient(HttpClientName);
using var response = await client.PostAsJsonAsync(url, body, ct);
var raw = await response.Content.ReadAsStringAsync(ct);
logger.LogInformation("Shopee token/get response: {Body}", raw);
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
if (root.TryGetProperty("error", out var err) && err.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(err.GetString()))
{
throw new InvalidOperationException($"Shopee 获取 Token 失败: {err.GetString()} {GetString(root, "message")}");
}
var accessToken = GetString(root, "access_token") ?? throw new InvalidOperationException("Shopee access_token 为空");
var expireIn = GetInt(root, "expire_in");
var shops = new List<PlatformShopBoundShop>();
if (isMain && root.TryGetProperty("shop_id_list", out var list) && list.ValueKind == JsonValueKind.Array)
{
foreach (var item in list.EnumerateArray())
{
var sid = item.GetInt64();
var info = await GetShopeeShopInfoAsync(accessToken, sid, ct);
shops.Add(info);
}
}
else
{
var sid = request.ShopId!.Value;
shops.Add(await GetShopeeShopInfoAsync(accessToken, sid, ct));
}
var first = shops.FirstOrDefault();
return new PlatformShopTokenResult
{
AccessToken = accessToken,
RefreshToken = GetString(root, "refresh_token"),
ExpiresInSeconds = expireIn > 0 ? expireIn : 14400,
RefreshExpiresInSeconds = expireIn > 0 ? expireIn * 30 : 86400 * 30,
PlatformStoreId = first?.PlatformStoreId,
PlatformAccount = first?.PlatformAccount,
SiteCountry = first?.SiteCountry,
ExtraJson = raw,
Shops = shops
};
}
private async Task<PlatformShopBoundShop> GetShopeeShopInfoAsync(string accessToken, long shopId, CancellationToken ct)
{
var shopee = _options.Shopee;
var path = "/api/v2/shop/get_shop_info";
var ts = PlatformShopCrypto.UnixSeconds();
var sign = PlatformShopCrypto.HmacSha256Hex(
$"{shopee.PartnerId}{path}{ts}{accessToken}{shopId}",
shopee.PartnerKey);
var url =
$"{shopee.HostAuthUrl}{path}?partner_id={shopee.PartnerId}&shop_id={shopId}&access_token={Uri.EscapeDataString(accessToken)}&sign={sign}&timestamp={ts}";
var client = httpClientFactory.CreateClient(HttpClientName);
using var response = await client.GetAsync(url, ct);
var raw = await response.Content.ReadAsStringAsync(ct);
logger.LogInformation("Shopee get_shop_info shop={ShopId} response: {Body}", shopId, raw);
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
return new PlatformShopBoundShop
{
PlatformStoreId = shopId.ToString(),
PlatformAccount = GetString(root, "shop_name") ?? shopId.ToString(),
SiteCountry = GetString(root, "region"),
Status = GetString(root, "status")
};
}
private async Task<PlatformShopTokenResult> ExchangeTiktokAsync(string code, CancellationToken ct)
{
EnsureTiktokConfigured();
var tiktok = _options.Tiktok;
var url = $"{tiktok.HostAuth}/api/v2/token/get" +
$"?app_key={Uri.EscapeDataString(tiktok.AppKey)}" +
$"&app_secret={Uri.EscapeDataString(tiktok.AppSecret)}" +
$"&auth_code={Uri.EscapeDataString(code)}" +
"&grant_type=authorized_code";
var client = httpClientFactory.CreateClient(HttpClientName);
using var response = await client.GetAsync(url, ct);
var raw = await response.Content.ReadAsStringAsync(ct);
logger.LogInformation("TikTok token/get response: {Body}", raw);
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
var data = root.TryGetProperty("data", out var d) ? d : root;
var accessToken = GetString(data, "access_token") ?? throw new InvalidOperationException("TikTok access_token 为空");
var openId = GetString(data, "open_id");
var sellerName = GetString(data, "seller_name");
var expiresIn = GetInt(data, "access_token_expire_in");
if (expiresIn <= 0) expiresIn = GetInt(data, "expires_in");
var refreshExpire = GetInt(data, "refresh_token_expire_in");
var shops = await GetTiktokAuthorizedShopsAsync(accessToken, ct);
if (shops.Count == 0)
{
shops.Add(new PlatformShopBoundShop
{
PlatformStoreId = openId ?? accessToken[..Math.Min(24, accessToken.Length)],
PlatformAccount = sellerName ?? openId
});
}
var first = shops[0];
return new PlatformShopTokenResult
{
AccessToken = accessToken,
RefreshToken = GetString(data, "refresh_token"),
ExpiresInSeconds = expiresIn > 0 ? expiresIn : 86400,
RefreshExpiresInSeconds = refreshExpire > 0 ? refreshExpire : expiresIn,
PlatformStoreId = first.PlatformStoreId,
PlatformAccount = first.PlatformAccount ?? sellerName,
SellerId = openId,
ExtraJson = raw,
Shops = shops
};
}
private async Task<List<PlatformShopBoundShop>> GetTiktokAuthorizedShopsAsync(string accessToken, CancellationToken ct)
{
var tiktok = _options.Tiktok;
var apiPath = $"/authorization/202309/shops?app_key={tiktok.AppKey}&timestamp={PlatformShopCrypto.UnixSeconds()}";
var signInput = apiPath.Replace("?", "").Replace("&", "").Replace("=", "");
var sign = PlatformShopCrypto.HmacSha256Hex(tiktok.AppSecret + signInput + tiktok.AppSecret, tiktok.AppSecret);
var url = $"{tiktok.HostApi}{apiPath}&sign={sign}";
var client = httpClientFactory.CreateClient(HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.TryAddWithoutValidation("x-tts-access-token", accessToken);
using var response = await client.SendAsync(request, ct);
var raw = await response.Content.ReadAsStringAsync(ct);
logger.LogInformation("TikTok authorized shops response: {Body}", raw);
var shops = new List<PlatformShopBoundShop>();
try
{
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
var data = root.TryGetProperty("data", out var d) ? d : root;
if (data.TryGetProperty("shops", out var arr) && arr.ValueKind == JsonValueKind.Array)
{
foreach (var item in arr.EnumerateArray())
{
var id = GetString(item, "id") ?? GetString(item, "shop_id");
if (string.IsNullOrWhiteSpace(id)) continue;
shops.Add(new PlatformShopBoundShop
{
PlatformStoreId = id,
PlatformAccount = GetString(item, "name") ?? GetString(item, "shop_name"),
SiteCountry = GetString(item, "region") ?? GetString(item, "code"),
Status = GetString(item, "status")
});
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Parse TikTok shops failed");
}
return shops;
}
private static string BuildTemuSign(Dictionary<string, string> body, string appSecret)
{
var concat = new StringBuilder();
foreach (var key in body.Keys.OrderBy(x => x, StringComparer.Ordinal))
{
concat.Append(key).Append(body[key]);
}
return PlatformShopCrypto.Md5Upper(appSecret + concat + appSecret);
}
private void EnsureLazadaConfigured()
{
if (string.IsNullOrWhiteSpace(_options.Lazada.AppKey) || string.IsNullOrWhiteSpace(_options.Lazada.AppSecret))
{
throw new InvalidOperationException("未配置 Lazada AppKey/AppSecret(Oms:PlatformShop:Lazada)");
}
}
private void EnsureShopeeConfigured()
{
if (_options.Shopee.PartnerId <= 0 || string.IsNullOrWhiteSpace(_options.Shopee.PartnerKey))
{
throw new InvalidOperationException("未配置 Shopee PartnerId/PartnerKey(Oms:PlatformShop:Shopee)");
}
}
private void EnsureTiktokConfigured()
{
if (string.IsNullOrWhiteSpace(_options.Tiktok.AppKey) || string.IsNullOrWhiteSpace(_options.Tiktok.AppSecret))
{
throw new InvalidOperationException("未配置 TikTok AppKey/AppSecret(Oms:PlatformShop:Tiktok)");
}
}
private void EnsureTemuConfigured()
{
if (string.IsNullOrWhiteSpace(_options.Temu.AppKey) || string.IsNullOrWhiteSpace(_options.Temu.AppSecret))
{
throw new InvalidOperationException("未配置 Temu AppKey/AppSecret(Oms:PlatformShop:Temu)");
}
}
private static string? GetString(JsonElement el, string name) =>
el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() :
el.TryGetProperty(name, out p) && p.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False
? p.ToString()
: null;
private static int GetInt(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return 0;
if (p.ValueKind == JsonValueKind.Number && p.TryGetInt32(out var i)) return i;
return int.TryParse(p.ToString(), out var parsed) ? parsed : 0;
}
private static long GetLong(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return 0;
if (p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var i)) return i;
return long.TryParse(p.ToString(), out var parsed) ? parsed : 0;
}
}

View File

@ -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;
}
}
}

View File

@ -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";
}

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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)
{ {

View File

@ -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,

View File

@ -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
}; };
} }

View File

@ -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
};
}
}

View File

@ -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; }

View File

@ -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);
} }

View File

@ -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)

View File

@ -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": {