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 CreateSenderAddressAsync([FromBody] CreateCustomerSenderAddressInput input) { var customer = await OmsSessionHelper.ResolveSessionCustomerAsync(CustomerRepository, CurrentUser); + var addressType = NormalizeAddressTypeOrThrow(input.AddressType); ValidateSenderAddressInput(input); var countryId = await ResolveSenderCountryIdAsync(input.CountryCode); if (input.IsDefault) { - await CustomerSenderAddressRepository.ClearDefaultAsync(customer.Id); + await CustomerSenderAddressRepository.ClearDefaultAsync(customer.Id, addressType); } var entity = new CustomerSenderAddress { CustomerId = customer.Id, + AddressType = addressType, AddressName = TrimOrNull(input.AddressName), SenderName = input.SenderName.Trim(), Company = TrimOrNull(input.Company), @@ -96,20 +99,22 @@ public partial class OrderService } /// - /// 更新发件地址。 + /// 更新客户地址。 /// [HttpPost("/order/sender-address/update")] public async Task UpdateSenderAddressAsync([FromBody] UpdateCustomerSenderAddressInput input) { var entity = await GetOwnedSenderAddressOrThrowAsync(input.Id); + var addressType = NormalizeAddressTypeOrThrow(input.AddressType); ValidateSenderAddressInput(input); var countryId = await ResolveSenderCountryIdAsync(input.CountryCode); if (input.IsDefault) { - await CustomerSenderAddressRepository.ClearDefaultAsync(entity.CustomerId, entity.Id); + await CustomerSenderAddressRepository.ClearDefaultAsync(entity.CustomerId, addressType, entity.Id); } + entity.AddressType = addressType; entity.AddressName = TrimOrNull(input.AddressName); entity.SenderName = input.SenderName.Trim(); entity.Company = TrimOrNull(input.Company); @@ -130,7 +135,7 @@ public partial class OrderService } /// - /// 删除发件地址(软删)。 + /// 删除客户地址(软删)。 /// [HttpPost("/order/sender-address/delete")] public async Task DeleteSenderAddressAsync([FromBody] IdInput input) @@ -140,13 +145,16 @@ public partial class OrderService } /// - /// 设为默认发件地址。 + /// 设为同类型默认地址。 /// [HttpPost("/order/sender-address/set-default")] public async Task SetDefaultSenderAddressAsync([FromBody] IdInput input) { var entity = await GetOwnedSenderAddressOrThrowAsync(input.Id); - await CustomerSenderAddressRepository.ClearDefaultAsync(entity.CustomerId, entity.Id); + var addressType = string.IsNullOrWhiteSpace(entity.AddressType) + ? CustomerAddressTypeCodes.Sender + : entity.AddressType; + await CustomerSenderAddressRepository.ClearDefaultAsync(entity.CustomerId, addressType, entity.Id); entity.IsDefault = 1; await CustomerSenderAddressRepository.UpdateAsync(entity); } @@ -157,29 +165,41 @@ public partial class OrderService var entity = await CustomerSenderAddressRepository.GetOwnedAsync(id, customer.Id); if (entity == null) { - throw new BusinessException("发件地址不存在"); + throw new BusinessException("地址不存在"); } return entity; } + private static string NormalizeAddressTypeOrThrow(string? addressType) + { + try + { + return CustomerAddressTypeCodes.NormalizeOrThrow(addressType); + } + catch (ArgumentException) + { + throw new BusinessException("地址类型不合法,仅支持发件/退件/收件"); + } + } + private static void ValidateSenderAddressInput(CreateCustomerSenderAddressInput input) { if (string.IsNullOrWhiteSpace(input.SenderName)) { - throw new BusinessException("发件人姓名不能为空"); + throw new BusinessException("联系人姓名不能为空"); } if (string.IsNullOrWhiteSpace(input.StreetAddress1)) { - throw new BusinessException("发件地址不能为空"); + throw new BusinessException("详细地址不能为空"); } } private async Task ResolveSenderCountryIdAsync(string? countryCode) { var code = string.IsNullOrWhiteSpace(countryCode) ? "CN" : countryCode.Trim(); - var country = await GetCountryOrThrowAsync(code, "发件国家不存在"); + var country = await GetCountryOrThrowAsync(code, "国家不存在"); return country.Id; } @@ -189,8 +209,13 @@ public partial class OrderService { var keyword = input.Keyword?.Trim(); var onlyDefault = input.OnlyDefault; + var addressType = input.AddressType?.Trim(); + var filterByType = !string.IsNullOrWhiteSpace(addressType) + && !string.Equals(addressType, "all", StringComparison.OrdinalIgnoreCase); + return x => x.CustomerId == customerId + && (!filterByType || x.AddressType == addressType) && (!onlyDefault || x.IsDefault == 1) && (string.IsNullOrWhiteSpace(keyword) || (x.AddressName ?? string.Empty).Contains(keyword) @@ -221,10 +246,16 @@ public partial class OrderService countryCode = code; } + var addressType = string.IsNullOrWhiteSpace(entity.AddressType) + ? CustomerAddressTypeCodes.Sender + : entity.AddressType; + return new CustomerSenderAddressOutput { Id = entity.Id, CustomerId = entity.CustomerId, + AddressType = addressType, + AddressTypeName = CustomerAddressTypeCodes.GetDisplayName(addressType), AddressName = entity.AddressName, SenderName = entity.SenderName, Company = entity.Company, diff --git a/src/Oms.Application/Services/Orders/PlatformStoreService.cs b/src/Oms.Application/Services/Orders/PlatformStoreService.cs index 71678c3..f2a08c6 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreService.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreService.cs @@ -1,8 +1,11 @@ using System.Linq.Expressions; using Fake.Application; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using Oms.Application.Dtos.PlatformStore; using Oms.Application.Dtos.Shared; +using Oms.Application.Integrations.PlatformShop; using Oms.Application.Shared; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.OrdersAggregate.Entities; @@ -12,19 +15,18 @@ using Oms.Domain.WarehouseAggregate.Consts; namespace Oms.Application.Services.Orders; /// -/// 店铺授权 API 服务。 +/// 店铺授权 API 服务(对齐 WMS /vip/shop 授权跳转与回调落库)。 /// [ApiExplorerSettings(GroupName = "OMS-Order")] public class PlatformStoreService : ApplicationService { private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); private ISalesStoreRepository StoreRepository => LazyServiceProvider.GetRequiredService(); + private IPlatformShopApiClient PlatformShopApi => LazyServiceProvider.GetRequiredService(); + private PlatformShopOptions PlatformShopOptions => + LazyServiceProvider.GetRequiredService>().Value; + private IConfiguration Configuration => LazyServiceProvider.GetRequiredService(); - /// - /// 查询店铺授权列表。 - /// - /// 入参:店铺分页查询条件。 - /// 店铺分页结果。 [HttpPost("/order/platform-store/list")] public async Task> GetListAsync(PlatformStorePageInput input) { @@ -50,35 +52,120 @@ public class PlatformStoreService : ApplicationService } /// - /// 获取店铺 OAuth 授权地址。 + /// 按平台生成授权跳转地址;Temu 当场建店并返回 "/"。 /// - /// 入参:平台编码与回调地址。 - /// 授权跳转 URL。 [HttpPost("/order/platform-store/auth-url")] public async Task GetAuthUrlAsync(PlatformStoreAuthUrlInput input) { - await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( CustomerRepository, CurrentUser, input.CustomerId); - - var platform = input.PlatformCode.Trim().ToLowerInvariant(); - var redirect = string.IsNullOrWhiteSpace(input.RedirectUri) - ? "https://localhost/oauth/callback" - : input.RedirectUri.Trim(); - - return new PlatformStoreAuthUrlOutput + var platform = NormalizePlatform(input.PlatformCode); + var nickname = (input.StoreNickname ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(nickname)) { - AuthUrl = $"https://oauth.example.com/{platform}?redirect={Uri.EscapeDataString(redirect)}", - Message = "OAuth 对接待平台 SDK 接入,当前返回占位地址" + throw new BusinessException("请输入店铺名称"); + } + + return platform switch + { + "temu" => await CreateTemuStoreAsync(customerId, nickname, input), + "lazada" or "shopee" or "tiktok" => BuildOAuthUrl(customerId, platform, nickname, input), + _ => throw new BusinessException($"暂不支持平台:{platform}") }; } /// - /// 保存店铺授权(新增或编辑)。 + /// OAuth 回调:用 code 换 token 并落库(同 PlatformStoreId 视为续期,不改昵称)。 /// - /// 入参:店铺保存数据。 - /// 保存后的店铺信息。 + [HttpPost("/order/platform-store/auth-callback")] + public async Task 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(); + var shops = token.Shops.Count > 0 + ? token.Shops + : [new PlatformShopBoundShop + { + PlatformStoreId = token.PlatformStoreId ?? Guid.NewGuid().ToString("N"), + PlatformAccount = token.PlatformAccount, + SiteCountry = token.SiteCountry ?? siteCountry + }]; + + foreach (var shop in shops) + { + var entity = await UpsertAuthorizedStoreAsync( + customerId, + platform, + nickname, + shop.SiteCountry ?? siteCountry ?? token.SiteCountry, + shop, + token); + bound.Add(MapOutput(entity)); + } + + return new PlatformStoreAuthCallbackOutput + { + BoundCount = bound.Count, + Message = $"成功绑定 {bound.Count} 个店铺", + Stores = bound + }; + } + [HttpPost("/order/platform-store/save")] public async Task SaveAsync(PlatformStoreSaveInput input) { @@ -99,10 +186,16 @@ public class PlatformStoreService : ApplicationService } else { + var platform = NormalizePlatform(input.PlatformCode); + if (string.IsNullOrWhiteSpace(platform) || platform is "manual") + { + throw new BusinessException("平台不能为空或不支持手工店铺"); + } + entity = new SalesStore { CustomerId = customerId, - PlatformCode = string.IsNullOrWhiteSpace(input.PlatformCode) ? "manual" : input.PlatformCode.Trim(), + PlatformCode = platform, AuthStatus = SalesStoreAuthStatusCodes.Active, AuthTime = DateTime.Now }; @@ -126,10 +219,6 @@ public class PlatformStoreService : ApplicationService return MapOutput(entity); } - /// - /// 删除店铺授权。 - /// - /// 入参:店铺 ID 列表。 [HttpPost("/order/platform-store/delete")] public async Task DeleteAsync(PlatformStoreIdsInput input) { @@ -151,6 +240,155 @@ public class PlatformStoreService : ApplicationService } } + private async Task 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 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 GetStoreOrThrowAsync(int? customerId, int id) { var entity = customerId.HasValue @@ -175,7 +413,6 @@ public class PlatformStoreService : ApplicationService var now = DateTime.Now; var within7Days = now.AddDays(7); - // 显式按过期筛选分支构造表达式,避免嵌套三元被 ORM 翻译成非法 IIF。 return filter switch { "expired" => x => @@ -227,7 +464,6 @@ public class PlatformStoreService : ApplicationService "shopee" => "Shopee", "tiktok" => "TikTok", "temu" => "Temu", - "manual" => "手工店铺", _ => platformCode }; } diff --git a/src/Oms.Domain/OrdersAggregate/Consts/CustomerAddressTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/CustomerAddressTypeCodes.cs new file mode 100644 index 0000000..34b5f48 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/CustomerAddressTypeCodes.cs @@ -0,0 +1,40 @@ +namespace Oms.Domain.OrdersAggregate.Consts; + +/// +/// 客户地址类型:发件 / 退件 / 收件。 +/// +public static class CustomerAddressTypeCodes +{ + public const string Sender = "sender"; + public const string Return = "return"; + public const string Receiver = "receiver"; + + public static readonly HashSet 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 + }; + } +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/CustomerSenderAddress.cs b/src/Oms.Domain/OrdersAggregate/Entities/CustomerSenderAddress.cs index 2fe0141..410f347 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/CustomerSenderAddress.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/CustomerSenderAddress.cs @@ -4,15 +4,23 @@ using SqlSugar; namespace Oms.Domain.OrdersAggregate.Entities; /// -/// 客户发件地址簿。 +/// 客户地址簿(发件/退件/收件)。 /// [SugarTable("t_customer_sender_address")] public class CustomerSenderAddress : OmsAuditedAggregateRoot { public int CustomerId { get; set; } + /// + /// 地址类型:sender / return / receiver。 + /// + public string AddressType { get; set; } = "sender"; + public string? AddressName { get; set; } + /// + /// 联系人姓名。 + /// public string SenderName { get; set; } = string.Empty; public string? Company { get; set; } diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/ICustomerSenderAddressRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/ICustomerSenderAddressRepository.cs index c8dce6b..9b11c0b 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/ICustomerSenderAddressRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/ICustomerSenderAddressRepository.cs @@ -7,5 +7,9 @@ public interface ICustomerSenderAddressRepository : IRepository 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); } diff --git a/src/Oms.SqlSugarCore/Orders/CustomerSenderAddressRepository.cs b/src/Oms.SqlSugarCore/Orders/CustomerSenderAddressRepository.cs index 91bba1d..0f9e37c 100644 --- a/src/Oms.SqlSugarCore/Orders/CustomerSenderAddressRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/CustomerSenderAddressRepository.cs @@ -12,10 +12,17 @@ public class CustomerSenderAddressRepository : OmsSqlSugarRepository x.Id == id && x.CustomerId == customerId, cancellationToken); } - public async Task ClearDefaultAsync(int customerId, int? excludeId = null, CancellationToken cancellationToken = default) + public async Task ClearDefaultAsync( + int customerId, + string addressType, + int? excludeId = null, + CancellationToken cancellationToken = default) { + var type = addressType.Trim(); var list = await GetListAsync( - x => x.CustomerId == customerId && x.IsDefault == 1 + x => x.CustomerId == customerId + && x.AddressType == type + && x.IsDefault == 1 && (!excludeId.HasValue || x.Id != excludeId.Value), cancellationToken: cancellationToken); if (list.Count == 0) diff --git a/src/SimpleAdmin.Api/appsettings.Development.json b/src/SimpleAdmin.Api/appsettings.Development.json index 5feb6d2..a873cb3 100644 --- a/src/SimpleAdmin.Api/appsettings.Development.json +++ b/src/SimpleAdmin.Api/appsettings.Development.json @@ -62,6 +62,32 @@ "Itfx": { "BaseUrl": "http://api.nanafa.com/itfx", "TimeoutSeconds": 30 + }, + "PlatformShop": { + "FrontendBaseUrl": "http://localhost:5173", + "Lazada": { + "AppKey": "123631", + "AppSecret": "7vBz2EaD6rIupeGEfwrgkabsTNsXt2ix", + "AuthorizeUrl": "https://auth.lazada.com/oauth/authorize", + "AccessTokenUrl": "https://auth.lazada.com/rest" + }, + "Shopee": { + "PartnerId": 2004112, + "PartnerKey": "2f385d7ea38b087d5d71f7292e06a09c9f19022ca6c3f60ec0110a8214ef7d65", + "HostUrl": "https://openplatform.shopee.cn", + "HostAuthUrl": "https://partner.shopeemobile.com" + }, + "Tiktok": { + "AppKey": "6hc64lc6vl312", + "AppSecret": "5a691a96ee6d844534769bab5c21de1f848fb678", + "HostAuth": "https://auth.tiktok-shops.com", + "HostApi": "https://open-api.tiktokglobalshop.com" + }, + "Temu": { + "AppKey": "4ebbc9190ae410443d65b4c2faca981f", + "AppSecret": "4782d2d827276688bf4758bed55dbdd4bbe79a79", + "HostAuthUrl": "https://openapi-b-us.temu.com/openapi/router" + } } }, "OrderBatch": {