diff --git a/db.sql b/db.sql
index ca9a7d5..595997a 100644
--- a/db.sql
+++ b/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',
diff --git a/sql/migrate_t_customer_sender_address.sql b/sql/migrate_t_customer_sender_address.sql
index d3815a7..1ba3960 100644
--- a/sql/migrate_t_customer_sender_address.sql
+++ b/sql/migrate_t_customer_sender_address.sql
@@ -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='客户地址簿(发件/退件/收件)';
diff --git a/sql/migrate_t_customer_sender_address_AddressType.sql b/sql/migrate_t_customer_sender_address_AddressType.sql
new file mode 100644
index 0000000..4eaacd4
--- /dev/null
+++ b/sql/migrate_t_customer_sender_address_AddressType.sql
@@ -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;
diff --git a/src/Oms.Application/Dtos/Orders/CustomerSenderAddressDtos.cs b/src/Oms.Application/Dtos/Orders/CustomerSenderAddressDtos.cs
index f01632f..e8797e8 100644
--- a/src/Oms.Application/Dtos/Orders/CustomerSenderAddressDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/CustomerSenderAddressDtos.cs
@@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.Orders;
///
-/// 客户发件地址分页查询。
+/// 客户地址分页查询。
///
public class CustomerSenderAddressPaginatedInput
{
@@ -13,6 +13,12 @@ public class CustomerSenderAddressPaginatedInput
[Newtonsoft.Json.JsonProperty("pageSize")]
public int PageSize { get; set; } = 30;
+ ///
+ /// 地址类型:all / sender / return / receiver。
+ ///
+ [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
}
///
-/// 新增发件地址。
+/// 新增客户地址。
///
public class CreateCustomerSenderAddressInput
{
+ ///
+ /// 地址类型:sender 发件 / return 退件 / receiver 收件。
+ ///
+ [Required]
+ [StringLength(16)]
+ public string AddressType { get; set; } = "sender";
+
[StringLength(64)]
public string? AddressName { get; set; }
+ ///
+ /// 联系人姓名。
+ ///
[Required]
[StringLength(50)]
public string SenderName { get; set; } = string.Empty;
@@ -73,7 +89,7 @@ public class CreateCustomerSenderAddressInput
}
///
-/// 更新发件地址。
+/// 更新客户地址。
///
public class UpdateCustomerSenderAddressInput : CreateCustomerSenderAddressInput
{
@@ -82,7 +98,7 @@ public class UpdateCustomerSenderAddressInput : CreateCustomerSenderAddressInput
}
///
-/// 发件地址出参。
+/// 客户地址出参。
///
public class CustomerSenderAddressOutput
{
@@ -90,6 +106,16 @@ public class CustomerSenderAddressOutput
public int CustomerId { get; set; }
+ ///
+ /// 地址类型 code。
+ ///
+ public string AddressType { get; set; } = "sender";
+
+ ///
+ /// 地址类型名称:发件 / 退件 / 收件。
+ ///
+ public string? AddressTypeName { get; set; }
+
public string? AddressName { get; set; }
public string SenderName { get; set; } = string.Empty;
diff --git a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs
index aca478e..1b5f4de 100644
--- a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs
+++ b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs
@@ -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; }
+ /// 自定义店铺昵称(OAuth 平台必填;续期不改名)
+ public string? StoreNickname { get; set; }
+
+ /// 开户站,如 Lazada cb/sg;Temu US
+ public string? SiteCountry { get; set; }
+
+ /// Temu 授权码
+ public string? AuthorizationCode { get; set; }
+
+ /// 可选覆盖回调地址;默认用配置 FrontendBaseUrl + /oauth/platform-store/{plat}
public string? RedirectUri { get; set; }
}
public class PlatformStoreAuthUrlOutput
{
+ /// 平台授权 URL;Temu 成功落库后返回 "/"
public string AuthUrl { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
+
+ /// 透传给回调页的 state(TikTok 等)
+ 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; }
+
+ /// 优先用 state 还原;也可由前端 localStorage 传入
+ public string? State { get; set; }
+
+ public string? StoreNickname { get; set; }
+
+ public string? SiteCountry { get; set; }
+
+ /// Shopee 店铺授权
+ public long? ShopId { get; set; }
+
+ /// Shopee 主账号授权
+ public long? MainAccountId { get; set; }
+}
+
+public class PlatformStoreAuthCallbackOutput
+{
+ public int BoundCount { get; set; }
+
+ public string Message { get; set; } = string.Empty;
+
+ public List Stores { get; set; } = [];
}
public class PlatformStoreIdsInput
diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopApiClient.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopApiClient.cs
new file mode 100644
index 0000000..f0dc06d
--- /dev/null
+++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopApiClient.cs
@@ -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 ExchangeTokenAsync(PlatformShopTokenExchangeRequest request, CancellationToken ct = default);
+ Task 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 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 options,
+ ILogger 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 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 CreateTemuStoreTokenAsync(string authorizationCode, CancellationToken ct = default)
+ {
+ EnsureTemuConfigured();
+ var temu = _options.Temu;
+ var body = new Dictionary
+ {
+ ["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 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(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 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
+ {
+ ["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();
+
+ 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 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 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> 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();
+ 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 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;
+ }
+}
diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs
new file mode 100644
index 0000000..d12b923
--- /dev/null
+++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs
@@ -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();
+}
+
+///
+/// OAuth 会话 state:携带客户与昵称,回调时还原。
+///
+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(json);
+ if (parsed == null) return false;
+ if (parsed.Exp > 0 && parsed.Exp < PlatformShopCrypto.UnixSeconds()) return false;
+ payload = parsed;
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopOptions.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopOptions.cs
new file mode 100644
index 0000000..1e66d39
--- /dev/null
+++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopOptions.cs
@@ -0,0 +1,54 @@
+namespace Oms.Application.Integrations.PlatformShop;
+
+///
+/// 平台店铺 OAuth / OpenAPI 配置(对齐 WMS application.yml)。
+///
+public class PlatformShopOptions
+{
+ public const string SectionName = "Oms:PlatformShop";
+
+ /// 前端站点根地址,用于拼 OAuth 回调页,如 http://localhost:5173
+ public string FrontendBaseUrl { get; set; } = "http://localhost:5173";
+
+ /// 签名 state 密钥;空则用 Jwt SecretKey
+ 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";
+}
diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopServiceExtensions.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopServiceExtensions.cs
new file mode 100644
index 0000000..bfde03d
--- /dev/null
+++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopServiceExtensions.cs
@@ -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(configuration.GetSection(PlatformShopOptions.SectionName));
+ services.AddHttpClient(PlatformShopApiClient.HttpClientName, client =>
+ {
+ client.Timeout = TimeSpan.FromSeconds(60);
+ });
+ services.AddScoped();
+ return services;
+ }
+}
diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs
index 6fdaa17..305e199 100644
--- a/src/Oms.Application/OmsApplicationModule.cs
+++ b/src/Oms.Application/OmsApplicationModule.cs
@@ -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();
diff --git a/src/Oms.Application/Services/Orders/OrderService.Pickup.cs b/src/Oms.Application/Services/Orders/OrderService.Pickup.cs
index e37a354..42c3e5b 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Pickup.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Pickup.cs
@@ -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)
{
diff --git a/src/Oms.Application/Services/Orders/OrderService.SenderAddress.cs b/src/Oms.Application/Services/Orders/OrderService.SenderAddress.cs
index 8f71ce8..a0ae85c 100644
--- a/src/Oms.Application/Services/Orders/OrderService.SenderAddress.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.SenderAddress.cs
@@ -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();
///
- /// 发件地址分页列表。
+ /// 客户地址分页列表(发件/退件/收件)。
///
[HttpPost("/order/sender-address/list")]
public async Task> GetSenderAddressListAsync(
@@ -46,7 +47,7 @@ public partial class OrderService
}
///
- /// 发件地址详情。
+ /// 客户地址详情。
///
[HttpGet("/order/sender-address/detail")]
public async Task GetSenderAddressDetailAsync([FromQuery] int id)
@@ -57,23 +58,25 @@ public partial class OrderService
}
///
- /// 新增发件地址。
+ /// 新增客户地址。
///
[HttpPost("/order/sender-address/create")]
public async Task