Browse Source
- LingxingAdapter:SalesOrderCapable + AggregatorCapable, 按 platform_code 细化能力(eBay/SHEIN/Mirakl/自定义平台只有订单) - LingxingSigner:公共参数 ASCII 升序拼接 appSecret 前后包裹后 MD5 - LingxingTokenClient + TokenManager:约 2h 令牌,提前 10 分钟续期 - 时间窗 ≤31 天截断、length ≤500、offset 游标、10 req/s 令牌桶 - channelOrderId 优先平台单号,platform_order_names 排序取首,缺失回落领星 order_id - LingxingAdapterFactory:与直连适配器同一套注册机制 契约测试 16 个,全部基于录制夹具,无网络/数据库/Spring。 Co-Authored-By: Claude <noreply@anthropic.com>master
13 changed files with 1303 additions and 0 deletions
@ -0,0 +1,262 @@
@@ -0,0 +1,262 @@
|
||||
package apelet.common.ecommerce.lingxing; |
||||
|
||||
import apelet.common.ecommerce.capability.AggregatorCapable; |
||||
import apelet.common.ecommerce.capability.SalesOrderCapable; |
||||
import apelet.common.ecommerce.error.ChannelException; |
||||
import apelet.common.ecommerce.http.HttpRequest; |
||||
import apelet.common.ecommerce.http.HttpResponse; |
||||
import apelet.common.ecommerce.http.HttpTransport; |
||||
import apelet.common.ecommerce.http.JacksonJsonCodec; |
||||
import apelet.common.ecommerce.http.Responses; |
||||
import apelet.common.ecommerce.http.TokenManager; |
||||
import apelet.common.ecommerce.lingxing.dto.LingxingOrderDto; |
||||
import apelet.common.ecommerce.model.Capability; |
||||
import apelet.common.ecommerce.model.ChannelAccount; |
||||
import apelet.common.ecommerce.model.ChannelProvider; |
||||
import apelet.common.ecommerce.model.Page; |
||||
import apelet.common.ecommerce.model.Platform; |
||||
import apelet.common.ecommerce.model.SalesOrder; |
||||
import apelet.common.ecommerce.model.SalesOrderQuery; |
||||
import apelet.common.ecommerce.sdk.ChannelContext; |
||||
import apelet.common.ecommerce.spi.AccessToken; |
||||
import apelet.common.ecommerce.spi.ChannelSecret; |
||||
import apelet.common.ecommerce.spi.LockProvider; |
||||
import apelet.common.ecommerce.spi.SecretProvider; |
||||
import apelet.common.ecommerce.spi.TokenStore; |
||||
|
||||
import java.io.UnsupportedEncodingException; |
||||
import java.net.URLEncoder; |
||||
import java.time.Clock; |
||||
import java.time.Duration; |
||||
import java.time.Instant; |
||||
import java.time.ZoneId; |
||||
import java.util.ArrayList; |
||||
import java.util.Collections; |
||||
import java.util.EnumSet; |
||||
import java.util.LinkedHashMap; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
import java.util.Set; |
||||
|
||||
/** |
||||
* 领星聚合适配器。 |
||||
* |
||||
* <p>一个适配器服务多个子平台,子平台由 {@code ChannelAccount.platformCode} 决定, |
||||
* 能力按子平台细化({@link #supports})。首期只实现订单列表拉取。</p> |
||||
* |
||||
* <p>领星字段名与签名细节标注为「待核对」的地方集中在 {@code dto} 与 |
||||
* {@link LingxingSigner},联调时只改这两处。</p> |
||||
*/ |
||||
public final class LingxingAdapter implements SalesOrderCapable, AggregatorCapable { |
||||
|
||||
/** 领星订单列表令牌桶:10 req/s(文档 9.3 按接口配置)。 */ |
||||
private static final int ORDERS_PER_SECOND = 10; |
||||
|
||||
private final String baseUrl; |
||||
private final HttpTransport transport; |
||||
private final JacksonJsonCodec json; |
||||
private final SecretProvider secretProvider; |
||||
private final Clock clock; |
||||
private final LingxingOrderMapper mapper; |
||||
private final TokenManager tokenManager; |
||||
private final SimpleRateLimiter rateLimiter; |
||||
|
||||
public LingxingAdapter(String baseUrl, HttpTransport transport, JacksonJsonCodec json, |
||||
SecretProvider secretProvider, TokenStore tokenStore, LockProvider lockProvider, |
||||
Clock clock) { |
||||
this.baseUrl = baseUrl; |
||||
this.transport = transport; |
||||
this.json = json; |
||||
this.secretProvider = secretProvider; |
||||
this.clock = clock; |
||||
this.mapper = new LingxingOrderMapper(json); |
||||
final LingxingTokenClient tokenClient = new LingxingTokenClient(transport, json, baseUrl); |
||||
// 领星 access_token 约 2h,提前 10 分钟续期
|
||||
this.tokenManager = new TokenManager(tokenStore, lockProvider, clock, 600) { |
||||
@Override |
||||
protected AccessToken refresh(ChannelSecret secret, AccessToken previous) { |
||||
return tokenClient.fetch(secret.appKey(), secret.appSecret()); |
||||
} |
||||
}; |
||||
this.rateLimiter = new SimpleRateLimiter(ORDERS_PER_SECOND); |
||||
} |
||||
|
||||
@Override |
||||
public String adapterId() { |
||||
return LingxingConstants.ADAPTER_ID; |
||||
} |
||||
|
||||
@Override |
||||
public ChannelProvider provider() { |
||||
return ChannelProvider.LINGXING; |
||||
} |
||||
|
||||
@Override |
||||
public Set<Platform> platforms() { |
||||
return EnumSet.of(Platform.AMAZON_SC, Platform.AMAZON_VC, Platform.WALMART_SC, Platform.TIKTOK, |
||||
Platform.TEMU, Platform.EBAY, Platform.SHEIN, Platform.MIRAKL, Platform.CUSTOM); |
||||
} |
||||
|
||||
@Override |
||||
public EnumSet<Capability> capabilities() { |
||||
return EnumSet.of(Capability.SALES_ORDER); |
||||
} |
||||
|
||||
/** |
||||
* 聚合器按子平台细化能力。 |
||||
* |
||||
* <p>eBay / SHEIN / Mirakl / 自定义平台在领星侧只有订单,未见账单与售后接口, |
||||
* 因此只声明 SALES_ORDER,缺口由能力报告显式暴露,不假装支持。</p> |
||||
*/ |
||||
@Override |
||||
public boolean supports(ChannelAccount account, Capability capability) { |
||||
if (!capabilities().contains(capability)) { |
||||
return false; |
||||
} |
||||
Platform p = LingxingConstants.toPlatform(account.platformCode()); |
||||
if (p == Platform.EBAY || p == Platform.SHEIN || p == Platform.MIRAKL || p == Platform.CUSTOM) { |
||||
return capability == Capability.SALES_ORDER; |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
@Override |
||||
public List<SubChannel> listSubChannels(ChannelAccount account) { |
||||
// 首期一个账户对应一个子渠道;店铺列表接口待接入
|
||||
SubChannel ch = new SubChannel( |
||||
account.platformCode() == null ? 0 : account.platformCode(), |
||||
account.externalStoreId(), |
||||
LingxingConstants.toPlatform(account.platformCode()), |
||||
account.externalStoreId()); |
||||
return Collections.singletonList(ch); |
||||
} |
||||
|
||||
@Override |
||||
public Page<SalesOrder> pull(ChannelContext ctx, SalesOrderQuery query) { |
||||
requireCapability(); |
||||
ChannelAccount account = ctx.account(); |
||||
ZoneId zone = ZoneId.of(account.marketplaceZone()); |
||||
|
||||
Instant from = query.updatedAfter(); |
||||
Instant to = query.updatedBefore() != null ? query.updatedBefore() : Instant.now(clock); |
||||
// 领星时间窗 ≤31 天:超出时截断到最近 31 天,由 SyncEngine 分段推进
|
||||
if (Duration.between(from, to).toDays() > LingxingConstants.MAX_QUERY_DAYS) { |
||||
from = to.minus(Duration.ofDays(LingxingConstants.MAX_QUERY_DAYS)); |
||||
} |
||||
|
||||
int length = Math.min(query.pageSize() <= 0 ? 100 : query.pageSize(), LingxingConstants.MAX_PAGE_SIZE); |
||||
int offset = query.cursor() == null ? 0 : Integer.parseInt(query.cursor()); |
||||
|
||||
rateLimiter.acquire(); |
||||
String body = buildOrderListBody(account, from, to, offset, length); |
||||
HttpRequest req = signedPost(ctx, LingxingConstants.PATH_ORDER_LIST, body, "salesOrders.pull"); |
||||
|
||||
HttpResponse resp = transport.execute(req); |
||||
Responses.throwIfNotSuccess(resp, "salesOrders.pull"); |
||||
|
||||
List<LingxingOrderDto> dtos = mapper.parseList(resp.body()); |
||||
int total = mapper.parseTotal(resp.body()); |
||||
|
||||
List<SalesOrder> orders = new ArrayList<SalesOrder>(dtos.size()); |
||||
for (LingxingOrderDto dto : dtos) { |
||||
orders.add(mapper.toSalesOrder(dto, account.omsStoreId(), zone)); |
||||
} |
||||
|
||||
int nextOffset = offset + dtos.size(); |
||||
String nextCursor = (dtos.isEmpty() || nextOffset >= total) ? null : String.valueOf(nextOffset); |
||||
return Page.of(orders, nextCursor); |
||||
} |
||||
|
||||
/** 单订单详情接口待核对(领星以列表接口为主),先显式拒绝而不是返回 null。 */ |
||||
@Override |
||||
public SalesOrder get(ChannelContext ctx, String channelOrderId) { |
||||
throw ChannelException.builder() |
||||
.message("lingxing order detail is not implemented yet") |
||||
.operation("salesOrders.get") |
||||
.provider(provider().name()) |
||||
.omsStoreId(ctx.omsStoreId()) |
||||
.requestId(ctx.requestId()) |
||||
.platformCode(channelOrderId) |
||||
.build(); |
||||
} |
||||
|
||||
// ---------------------------------------------------------------- internals
|
||||
|
||||
private String buildOrderListBody(ChannelAccount account, Instant from, Instant to, int offset, int length) { |
||||
Map<String, Object> body = new LinkedHashMap<String, Object>(); |
||||
body.put("offset", offset); |
||||
body.put("length", length); |
||||
if (account.platformCode() != null) { |
||||
body.put("platform_code", account.platformCode()); |
||||
} |
||||
if (account.externalStoreId() != null) { |
||||
body.put("store_id", account.externalStoreId()); |
||||
} |
||||
// 领星查询时间为 epoch 秒
|
||||
body.put("update_time_start", from.getEpochSecond()); |
||||
body.put("update_time_end", to.getEpochSecond()); |
||||
return json.toJson(body); |
||||
} |
||||
|
||||
private HttpRequest signedPost(ChannelContext ctx, String path, String body, String operation) { |
||||
ChannelAccount account = ctx.account(); |
||||
ChannelSecret secret = secretProvider.get(account.credentialRef()); |
||||
AccessToken token = tokenManager.getOrRefresh(tokenKey(account), secret); |
||||
|
||||
Map<String, String> common = new LinkedHashMap<String, String>(); |
||||
common.put("app_key", secret.appKey()); |
||||
common.put("timestamp", String.valueOf(Instant.now(clock).getEpochSecond())); |
||||
String sign = LingxingSigner.sign(common, secret); |
||||
|
||||
String url = baseUrl + path |
||||
+ "?app_key=" + urlEncode(common.get("app_key")) |
||||
+ "×tamp=" + common.get("timestamp") |
||||
+ "&sign=" + sign; |
||||
|
||||
return HttpRequest.builder() |
||||
.method("POST") |
||||
.url(url) |
||||
.header("Content-Type", "application/json") |
||||
.header("Authorization", token.tokenType() + " " + token.value()) |
||||
.header("X-Request-Id", ctx.requestId()) |
||||
.body(body) |
||||
.operation(operation) |
||||
.build(); |
||||
} |
||||
|
||||
private static String tokenKey(ChannelAccount account) { |
||||
return "lingxing:" + account.externalStoreId(); |
||||
} |
||||
|
||||
private static String urlEncode(String v) { |
||||
try { |
||||
return URLEncoder.encode(v == null ? "" : v, "UTF-8"); |
||||
} catch (UnsupportedEncodingException e) { |
||||
throw new IllegalStateException(e); |
||||
} |
||||
} |
||||
|
||||
/** 简单令牌桶;首期按接口串行即可,后续替换为通用 RateLimiter。 */ |
||||
static final class SimpleRateLimiter { |
||||
|
||||
private final long intervalNanos; |
||||
private long nextAllowed; |
||||
|
||||
SimpleRateLimiter(int permitsPerSecond) { |
||||
this.intervalNanos = 1_000_000_000L / permitsPerSecond; |
||||
} |
||||
|
||||
synchronized void acquire() { |
||||
long now = System.nanoTime(); |
||||
if (now < nextAllowed) { |
||||
long waitNanos = nextAllowed - now; |
||||
try { |
||||
Thread.sleep(waitNanos / 1_000_000L, (int) (waitNanos % 1_000_000L)); |
||||
} catch (InterruptedException e) { |
||||
Thread.currentThread().interrupt(); |
||||
} |
||||
} |
||||
nextAllowed = System.nanoTime() + intervalNanos; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
package apelet.common.ecommerce.lingxing; |
||||
|
||||
import apelet.common.ecommerce.capability.PlatformAdapter; |
||||
import apelet.common.ecommerce.model.ChannelProvider; |
||||
import apelet.common.ecommerce.model.Platform; |
||||
import apelet.common.ecommerce.sdk.AdapterFactory; |
||||
import apelet.common.ecommerce.spi.LockProvider; |
||||
import apelet.common.ecommerce.spi.SecretProvider; |
||||
import apelet.common.ecommerce.spi.TokenStore; |
||||
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
||||
import java.time.Clock; |
||||
import java.util.EnumSet; |
||||
|
||||
/** |
||||
* 领星适配器工厂。直连适配器使用同一套注册机制。 |
||||
*/ |
||||
public final class LingxingAdapterFactory implements AdapterFactory { |
||||
|
||||
private final SecretProvider secretProvider; |
||||
private final TokenStore tokenStore; |
||||
private final LockProvider lockProvider; |
||||
private final Clock clock; |
||||
|
||||
public LingxingAdapterFactory(SecretProvider secretProvider, TokenStore tokenStore, |
||||
LockProvider lockProvider, Clock clock) { |
||||
this.secretProvider = secretProvider; |
||||
this.tokenStore = tokenStore; |
||||
this.lockProvider = lockProvider; |
||||
this.clock = clock; |
||||
} |
||||
|
||||
@Override |
||||
public String adapterId() { |
||||
return LingxingConstants.ADAPTER_ID; |
||||
} |
||||
|
||||
@Override |
||||
public ChannelProvider provider() { |
||||
return ChannelProvider.LINGXING; |
||||
} |
||||
|
||||
@Override |
||||
public EnumSet<Platform> platforms() { |
||||
return EnumSet.of(Platform.AMAZON_SC, Platform.AMAZON_VC, Platform.WALMART_SC, Platform.TIKTOK, |
||||
Platform.TEMU, Platform.EBAY, Platform.SHEIN, Platform.MIRAKL, Platform.CUSTOM); |
||||
} |
||||
|
||||
@Override |
||||
public PlatformAdapter create(AdapterConfig config) { |
||||
String baseUrl = config.endpointBaseUrl() == null |
||||
? LingxingConstants.DEFAULT_BASE_URL : config.endpointBaseUrl(); |
||||
return new LingxingAdapter(baseUrl, |
||||
new apelet.common.ecommerce.http.OkHttpTransport("common-ecommerce/1.0.0 (lingxing)"), |
||||
new apelet.common.ecommerce.http.JacksonJsonCodec(), |
||||
secretProvider, tokenStore, lockProvider, clock); |
||||
} |
||||
} |
||||
@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
|
||||
package apelet.common.ecommerce.lingxing; |
||||
|
||||
/** 领星常量与 platform_code 映射。 */ |
||||
public final class LingxingConstants { |
||||
|
||||
/** 生产环境基址。 */ |
||||
public static final String DEFAULT_BASE_URL = "https://openapi.lingxing.com"; |
||||
|
||||
public static final String ADAPTER_ID = "lingxing"; |
||||
|
||||
/** 获取令牌。 */ |
||||
public static final String PATH_ACCESS_TOKEN = "/api/auth-server/oauth/access-token"; |
||||
/** 多平台订单列表(各平台自发货)。 */ |
||||
public static final String PATH_ORDER_LIST = "/pb/mp/order/v2/list"; |
||||
/** 店铺列表。 */ |
||||
public static final String PATH_SELLER_LIST = "/pb/mp/shop/v2/getSellerList"; |
||||
|
||||
/** 订单查询时间跨度上限:31 天。 */ |
||||
public static final int MAX_QUERY_DAYS = 31; |
||||
/** 分页 length 上限。 */ |
||||
public static final int MAX_PAGE_SIZE = 500; |
||||
|
||||
private LingxingConstants() { |
||||
} |
||||
|
||||
/** |
||||
* 领星 platform_code → 统一 Platform。 |
||||
* |
||||
* <p>10034 同时对应 Mirakl / BestBuy;Target、AEPH 未独立枚举, |
||||
* 按文档需要到领星后台确认,此处先映射到 CUSTOM,待核对。</p> |
||||
*/ |
||||
public static apelet.common.ecommerce.model.Platform toPlatform(Integer platformCode) { |
||||
if (platformCode == null) { |
||||
return null; |
||||
} |
||||
switch (platformCode) { |
||||
case 10001: |
||||
return apelet.common.ecommerce.model.Platform.AMAZON_SC; |
||||
case 10035: |
||||
return apelet.common.ecommerce.model.Platform.AMAZON_VC; |
||||
case 10008: |
||||
return apelet.common.ecommerce.model.Platform.WALMART_SC; |
||||
case 10011: |
||||
return apelet.common.ecommerce.model.Platform.TIKTOK; |
||||
case 10022: |
||||
case 10024: |
||||
return apelet.common.ecommerce.model.Platform.TEMU; |
||||
case 10003: |
||||
return apelet.common.ecommerce.model.Platform.EBAY; |
||||
case 10021: |
||||
case 10027: |
||||
case 10028: |
||||
return apelet.common.ecommerce.model.Platform.SHEIN; |
||||
case 10034: |
||||
return apelet.common.ecommerce.model.Platform.MIRAKL; |
||||
default: |
||||
return apelet.common.ecommerce.model.Platform.CUSTOM; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
|
||||
package apelet.common.ecommerce.lingxing; |
||||
|
||||
import apelet.common.ecommerce.http.JacksonJsonCodec; |
||||
import apelet.common.ecommerce.lingxing.dto.LingxingOrderDto; |
||||
import apelet.common.ecommerce.lingxing.dto.LingxingOrderItemDto; |
||||
import apelet.common.ecommerce.model.ChannelProvider; |
||||
import apelet.common.ecommerce.model.Money; |
||||
import apelet.common.ecommerce.model.OrderLine; |
||||
import apelet.common.ecommerce.model.Platform; |
||||
import apelet.common.ecommerce.model.PlatformTime; |
||||
import apelet.common.ecommerce.model.SalesOrder; |
||||
|
||||
import java.time.Instant; |
||||
import java.time.ZoneId; |
||||
import java.util.ArrayList; |
||||
import java.util.Collections; |
||||
import java.util.Comparator; |
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 领星订单 → 统一模型。 |
||||
* |
||||
* <p>领星订单是唯一的映射入口,平台差异全部收敛在这里。</p> |
||||
*/ |
||||
public final class LingxingOrderMapper { |
||||
|
||||
private final JacksonJsonCodec json; |
||||
|
||||
public LingxingOrderMapper(JacksonJsonCodec json) { |
||||
this.json = json; |
||||
} |
||||
|
||||
public SalesOrder toSalesOrder(LingxingOrderDto dto, String omsStoreId, ZoneId marketplaceZone) { |
||||
Platform platform = LingxingConstants.toPlatform(dto.getPlatformCode()); |
||||
String channelOrderId = resolveChannelOrderId(dto); |
||||
String currency = dto.getCurrencyCode() == null ? "USD" : dto.getCurrencyCode(); |
||||
|
||||
List<OrderLine> lines = new ArrayList<OrderLine>(); |
||||
for (LingxingOrderItemDto item : safe(dto.getItemList())) { |
||||
lines.add(OrderLine.builder() |
||||
.lineId(item.getOrderItemId()) |
||||
// 领星 MSKU 与平台 SKU 不总一致;优先 platform_sku,缺失时回落 sku
|
||||
.platformSku(item.getPlatformSku() != null ? item.getPlatformSku() : item.getSku()) |
||||
.quantity(item.getQuantity() == null ? 0 : item.getQuantity()) |
||||
.unitPrice(moneyOrNull(item.getItemPrice(), currency)) |
||||
.tax(moneyOrNull(item.getTax(), currency)) |
||||
.discount(moneyOrNull(item.getDiscount(), currency)) |
||||
.build()); |
||||
} |
||||
|
||||
Instant occur = dto.getPurchaseTime() != null |
||||
? Instant.ofEpochSecond(dto.getPurchaseTime()) |
||||
: Instant.EPOCH; |
||||
|
||||
return SalesOrder.builder() |
||||
.omsStoreId(omsStoreId) |
||||
.channelOrderId(channelOrderId) |
||||
.status(dto.getOrderStatus()) |
||||
.buyerName(dto.getBuyerName()) |
||||
.shipToCountryCode(dto.getCountryCode()) |
||||
.lines(lines) |
||||
.totalAmount(moneyOrNull(dto.getOrderTotal(), currency)) |
||||
.occurredAt(PlatformTime.of(occur, marketplaceZone)) |
||||
.sourceProvider(ChannelProvider.LINGXING) |
||||
.sourceAccountId(dto.getStoreId()) |
||||
.build(); |
||||
} |
||||
|
||||
/** |
||||
* channelOrderId 是 canonicalKey 的一部分,必须稳定。 |
||||
* |
||||
* <p>优先平台单号;缺失时退回领星 order_id(此时跨 provider 无法去重, |
||||
* 会由 DuplicateOrderGuard 记录差异)。</p> |
||||
*/ |
||||
static String resolveChannelOrderId(LingxingOrderDto dto) { |
||||
if (dto.getPlatformOrderNo() != null && !dto.getPlatformOrderNo().trim().isEmpty()) { |
||||
return dto.getPlatformOrderNo().trim(); |
||||
} |
||||
List<String> names = dto.getPlatformOrderNames(); |
||||
if (names != null && !names.isEmpty()) { |
||||
List<String> sorted = new ArrayList<String>(names); |
||||
Collections.sort(sorted, new Comparator<String>() { |
||||
@Override |
||||
public int compare(String a, String b) { |
||||
return a.compareTo(b); |
||||
} |
||||
}); |
||||
return sorted.get(0); |
||||
} |
||||
return dto.getOrderId(); |
||||
} |
||||
|
||||
public List<LingxingOrderDto> parseList(String body) { |
||||
return json.listAt(body, "data.list", LingxingOrderDto.class); |
||||
} |
||||
|
||||
public int parseTotal(String body) { |
||||
String total = json.stringAt(body, "data.total"); |
||||
return total == null ? 0 : Integer.parseInt(total); |
||||
} |
||||
|
||||
private static <T> List<T> safe(List<T> in) { |
||||
return in == null ? Collections.<T>emptyList() : in; |
||||
} |
||||
|
||||
private static Money moneyOrNull(String value, String currency) { |
||||
if (value == null || value.trim().isEmpty()) { |
||||
return null; |
||||
} |
||||
return Money.of(value.trim(), currency); |
||||
} |
||||
} |
||||
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
package apelet.common.ecommerce.lingxing; |
||||
|
||||
import apelet.common.ecommerce.spi.ChannelSecret; |
||||
|
||||
import java.nio.charset.StandardCharsets; |
||||
import java.security.MessageDigest; |
||||
import java.security.NoSuchAlgorithmException; |
||||
import java.util.ArrayList; |
||||
import java.util.Collections; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
|
||||
/** |
||||
* 领星签名:公共参数按 ASCII 升序拼接 {@code key+value},前后拼 appSecret,MD5 小写。 |
||||
* |
||||
* <p>待核对:官方文档对 sign 的大小写与大写规则未在需求文档中固化, |
||||
* 实现集中在 {@link #sign(Map, String)} 一处,便于联调时调整。</p> |
||||
*/ |
||||
public final class LingxingSigner { |
||||
|
||||
private LingxingSigner() { |
||||
} |
||||
|
||||
/** |
||||
* @param params 参与签名的参数(不含 sign),值不得为 null |
||||
* @param appSecret 应用密钥 |
||||
*/ |
||||
public static String sign(Map<String, String> params, String appSecret) { |
||||
List<String> keys = new ArrayList<String>(params.keySet()); |
||||
Collections.sort(keys); |
||||
StringBuilder sb = new StringBuilder(appSecret); |
||||
for (String k : keys) { |
||||
sb.append(k).append(params.get(k)); |
||||
} |
||||
sb.append(appSecret); |
||||
return md5Hex(sb.toString()); |
||||
} |
||||
|
||||
/** 便捷:从凭证取 appSecret。 */ |
||||
public static String sign(Map<String, String> params, ChannelSecret secret) { |
||||
return sign(params, secret.appSecret()); |
||||
} |
||||
|
||||
static String md5Hex(String raw) { |
||||
try { |
||||
MessageDigest md = MessageDigest.getInstance("MD5"); |
||||
byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8)); |
||||
StringBuilder sb = new StringBuilder(digest.length * 2); |
||||
for (byte b : digest) { |
||||
sb.append(Character.forDigit((b >> 4) & 0xF, 16)); |
||||
sb.append(Character.forDigit(b & 0xF, 16)); |
||||
} |
||||
return sb.toString(); |
||||
} catch (NoSuchAlgorithmException e) { |
||||
throw new IllegalStateException("MD5 not available", e); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
package apelet.common.ecommerce.lingxing; |
||||
|
||||
import apelet.common.ecommerce.http.HttpRequest; |
||||
import apelet.common.ecommerce.http.HttpResponse; |
||||
import apelet.common.ecommerce.http.HttpTransport; |
||||
import apelet.common.ecommerce.http.JsonCodec; |
||||
import apelet.common.ecommerce.http.Responses; |
||||
import apelet.common.ecommerce.spi.AccessToken; |
||||
|
||||
import java.time.Instant; |
||||
import java.util.LinkedHashMap; |
||||
import java.util.Map; |
||||
|
||||
/** |
||||
* 领星令牌获取:{@code POST /api/auth-server/oauth/access-token}。 |
||||
* |
||||
* <p>请求体字段名以官方文档为准(appId/appSecret),这里集中在一处便于联调调整。</p> |
||||
*/ |
||||
public final class LingxingTokenClient { |
||||
|
||||
private final HttpTransport transport; |
||||
private final JsonCodec json; |
||||
private final String baseUrl; |
||||
|
||||
public LingxingTokenClient(HttpTransport transport, JsonCodec json, String baseUrl) { |
||||
this.transport = transport; |
||||
this.json = json; |
||||
this.baseUrl = baseUrl; |
||||
} |
||||
|
||||
public AccessToken fetch(String appId, String appSecret) { |
||||
Map<String, String> body = new LinkedHashMap<String, String>(); |
||||
body.put("appId", appId); |
||||
body.put("appSecret", appSecret); |
||||
|
||||
HttpRequest req = HttpRequest.builder() |
||||
.method("POST") |
||||
.url(baseUrl + LingxingConstants.PATH_ACCESS_TOKEN) |
||||
.header("Content-Type", "application/json") |
||||
.body(json.toJson(body)) |
||||
.operation("token.refresh") |
||||
.build(); |
||||
|
||||
HttpResponse resp = transport.execute(req); |
||||
Responses.throwIfNotSuccess(resp, "token.refresh"); |
||||
|
||||
String token = json.stringAt(resp.body(), "data.access_token"); |
||||
if (token == null) { |
||||
// 兼容部分版本的 data 为数组
|
||||
token = json.stringAt(resp.body(), "data.0.access_token"); |
||||
} |
||||
String accessToken = json.stringAt(resp.body(), "access_token"); |
||||
if (accessToken == null) { |
||||
accessToken = token; |
||||
} |
||||
String refreshToken = json.stringAt(resp.body(), "data.refresh_token"); |
||||
String expiresIn = json.stringAt(resp.body(), "data.expires_in"); |
||||
|
||||
long seconds = expiresIn == null ? 7199L : Long.parseLong(expiresIn); |
||||
return AccessToken.builder() |
||||
.value(accessToken) |
||||
.refreshToken(refreshToken) |
||||
.expiresAt(Instant.now().plusSeconds(seconds)) |
||||
.tokenType("Bearer") |
||||
.build(); |
||||
} |
||||
} |
||||
@ -0,0 +1,196 @@
@@ -0,0 +1,196 @@
|
||||
package apelet.common.ecommerce.lingxing.dto; |
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; |
||||
import com.fasterxml.jackson.annotation.JsonProperty; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 领星多平台订单列表条目。 |
||||
* |
||||
* <p><b>待核对</b>:字段名依据领星 OpenAPI 文档摘录,联调时以官方最新文档 |
||||
* 与真实响应为准;调整集中在本 DTO 与 {@code LingxingOrderMapper}。</p> |
||||
*/ |
||||
@JsonIgnoreProperties(ignoreUnknown = true) |
||||
public class LingxingOrderDto { |
||||
|
||||
/** 领星内部订单 ID。 */ |
||||
@JsonProperty("order_id") |
||||
private String orderId; |
||||
|
||||
/** 平台订单号(Amazon / Walmart PO / eBay 等)。 */ |
||||
@JsonProperty("platform_order_no") |
||||
private String platformOrderNo; |
||||
|
||||
/** 部分接口返回平台单号数组,eBay 需用 platform_order_names 查询。 */ |
||||
@JsonProperty("platform_order_names") |
||||
private List<String> platformOrderNames = new ArrayList<String>(); |
||||
|
||||
/** 领星平台码,如 10008=Walmart。 */ |
||||
@JsonProperty("platform_code") |
||||
private Integer platformCode; |
||||
|
||||
/** 领星店铺 ID。 */ |
||||
@JsonProperty("store_id") |
||||
private String storeId; |
||||
|
||||
/** 平台侧订单状态。 */ |
||||
@JsonProperty("order_status") |
||||
private String orderStatus; |
||||
|
||||
/** 买家名称。 */ |
||||
@JsonProperty("buyer_name") |
||||
private String buyerName; |
||||
|
||||
/** 收货国家二字码。 */ |
||||
@JsonProperty("country_code") |
||||
private String countryCode; |
||||
|
||||
/** 订单币种。 */ |
||||
@JsonProperty("currency_code") |
||||
private String currencyCode; |
||||
|
||||
/** 订单总额。 */ |
||||
@JsonProperty("order_total") |
||||
private String orderTotal; |
||||
|
||||
/** 站点本地下单时间字符串。 */ |
||||
@JsonProperty("purchase_time_locale") |
||||
private String purchaseTimeLocale; |
||||
|
||||
/** epoch 秒下单时间。 */ |
||||
@JsonProperty("purchase_time") |
||||
private Long purchaseTime; |
||||
|
||||
/** epoch 秒更新时间。 */ |
||||
@JsonProperty("update_time") |
||||
private Long updateTime; |
||||
|
||||
/** 站点本地更新时间字符串。 */ |
||||
@JsonProperty("update_time_locale") |
||||
private String updateTimeLocale; |
||||
|
||||
@JsonProperty("item_list") |
||||
private List<LingxingOrderItemDto> itemList = new ArrayList<LingxingOrderItemDto>(); |
||||
|
||||
public String getOrderId() { |
||||
return orderId; |
||||
} |
||||
|
||||
public void setOrderId(String orderId) { |
||||
this.orderId = orderId; |
||||
} |
||||
|
||||
public String getPlatformOrderNo() { |
||||
return platformOrderNo; |
||||
} |
||||
|
||||
public void setPlatformOrderNo(String platformOrderNo) { |
||||
this.platformOrderNo = platformOrderNo; |
||||
} |
||||
|
||||
public List<String> getPlatformOrderNames() { |
||||
return platformOrderNames; |
||||
} |
||||
|
||||
public void setPlatformOrderNames(List<String> platformOrderNames) { |
||||
this.platformOrderNames = platformOrderNames; |
||||
} |
||||
|
||||
public Integer getPlatformCode() { |
||||
return platformCode; |
||||
} |
||||
|
||||
public void setPlatformCode(Integer platformCode) { |
||||
this.platformCode = platformCode; |
||||
} |
||||
|
||||
public String getStoreId() { |
||||
return storeId; |
||||
} |
||||
|
||||
public void setStoreId(String storeId) { |
||||
this.storeId = storeId; |
||||
} |
||||
|
||||
public String getOrderStatus() { |
||||
return orderStatus; |
||||
} |
||||
|
||||
public void setOrderStatus(String orderStatus) { |
||||
this.orderStatus = orderStatus; |
||||
} |
||||
|
||||
public String getBuyerName() { |
||||
return buyerName; |
||||
} |
||||
|
||||
public void setBuyerName(String buyerName) { |
||||
this.buyerName = buyerName; |
||||
} |
||||
|
||||
public String getCountryCode() { |
||||
return countryCode; |
||||
} |
||||
|
||||
public void setCountryCode(String countryCode) { |
||||
this.countryCode = countryCode; |
||||
} |
||||
|
||||
public String getCurrencyCode() { |
||||
return currencyCode; |
||||
} |
||||
|
||||
public void setCurrencyCode(String currencyCode) { |
||||
this.currencyCode = currencyCode; |
||||
} |
||||
|
||||
public String getOrderTotal() { |
||||
return orderTotal; |
||||
} |
||||
|
||||
public void setOrderTotal(String orderTotal) { |
||||
this.orderTotal = orderTotal; |
||||
} |
||||
|
||||
public String getPurchaseTimeLocale() { |
||||
return purchaseTimeLocale; |
||||
} |
||||
|
||||
public void setPurchaseTimeLocale(String purchaseTimeLocale) { |
||||
this.purchaseTimeLocale = purchaseTimeLocale; |
||||
} |
||||
|
||||
public Long getPurchaseTime() { |
||||
return purchaseTime; |
||||
} |
||||
|
||||
public void setPurchaseTime(Long purchaseTime) { |
||||
this.purchaseTime = purchaseTime; |
||||
} |
||||
|
||||
public Long getUpdateTime() { |
||||
return updateTime; |
||||
} |
||||
|
||||
public void setUpdateTime(Long updateTime) { |
||||
this.updateTime = updateTime; |
||||
} |
||||
|
||||
public String getUpdateTimeLocale() { |
||||
return updateTimeLocale; |
||||
} |
||||
|
||||
public void setUpdateTimeLocale(String updateTimeLocale) { |
||||
this.updateTimeLocale = updateTimeLocale; |
||||
} |
||||
|
||||
public List<LingxingOrderItemDto> getItemList() { |
||||
return itemList; |
||||
} |
||||
|
||||
public void setItemList(List<LingxingOrderItemDto> itemList) { |
||||
this.itemList = itemList; |
||||
} |
||||
} |
||||
@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
|
||||
package apelet.common.ecommerce.lingxing.dto; |
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; |
||||
import com.fasterxml.jackson.annotation.JsonProperty; |
||||
|
||||
/** 领星订单明细行。字段名待官方文档核对。 */ |
||||
@JsonIgnoreProperties(ignoreUnknown = true) |
||||
public class LingxingOrderItemDto { |
||||
|
||||
@JsonProperty("order_item_id") |
||||
private String orderItemId; |
||||
|
||||
/** 领星 MSKU,需映射回平台 SKU 才能与直连对齐。 */ |
||||
@JsonProperty("sku") |
||||
private String sku; |
||||
|
||||
/** 平台 SKU(部分接口返回)。 */ |
||||
@JsonProperty("platform_sku") |
||||
private String platformSku; |
||||
|
||||
@JsonProperty("quantity") |
||||
private Integer quantity; |
||||
|
||||
@JsonProperty("item_price") |
||||
private String itemPrice; |
||||
|
||||
@JsonProperty("tax") |
||||
private String tax; |
||||
|
||||
@JsonProperty("discount") |
||||
private String discount; |
||||
|
||||
public String getOrderItemId() { |
||||
return orderItemId; |
||||
} |
||||
|
||||
public void setOrderItemId(String orderItemId) { |
||||
this.orderItemId = orderItemId; |
||||
} |
||||
|
||||
public String getSku() { |
||||
return sku; |
||||
} |
||||
|
||||
public void setSku(String sku) { |
||||
this.sku = sku; |
||||
} |
||||
|
||||
public String getPlatformSku() { |
||||
return platformSku; |
||||
} |
||||
|
||||
public void setPlatformSku(String platformSku) { |
||||
this.platformSku = platformSku; |
||||
} |
||||
|
||||
public Integer getQuantity() { |
||||
return quantity; |
||||
} |
||||
|
||||
public void setQuantity(Integer quantity) { |
||||
this.quantity = quantity; |
||||
} |
||||
|
||||
public String getItemPrice() { |
||||
return itemPrice; |
||||
} |
||||
|
||||
public void setItemPrice(String itemPrice) { |
||||
this.itemPrice = itemPrice; |
||||
} |
||||
|
||||
public String getTax() { |
||||
return tax; |
||||
} |
||||
|
||||
public void setTax(String tax) { |
||||
this.tax = tax; |
||||
} |
||||
|
||||
public String getDiscount() { |
||||
return discount; |
||||
} |
||||
|
||||
public void setDiscount(String discount) { |
||||
this.discount = discount; |
||||
} |
||||
} |
||||
@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
|
||||
package apelet.common.ecommerce.lingxing; |
||||
|
||||
import apelet.common.ecommerce.http.JacksonJsonCodec; |
||||
import apelet.common.ecommerce.model.AccountKey; |
||||
import apelet.common.ecommerce.model.ChannelAccount; |
||||
import apelet.common.ecommerce.model.ChannelProvider; |
||||
import apelet.common.ecommerce.model.Page; |
||||
import apelet.common.ecommerce.model.Platform; |
||||
import apelet.common.ecommerce.model.SalesOrder; |
||||
import apelet.common.ecommerce.model.SalesOrderQuery; |
||||
import apelet.common.ecommerce.sdk.ChannelContext; |
||||
import apelet.common.ecommerce.spi.ChannelSecret; |
||||
import apelet.common.ecommerce.sync.SyncEngine; |
||||
import apelet.common.ecommerce.sync.SyncResult; |
||||
import apelet.common.ecommerce.sync.SyncSpec; |
||||
import apelet.common.ecommerce.testkit.FakeTransport; |
||||
import apelet.common.ecommerce.testkit.Fixtures; |
||||
import apelet.common.ecommerce.testkit.InMemoryAccounts; |
||||
import apelet.common.ecommerce.testkit.InMemoryCursorStore; |
||||
import apelet.common.ecommerce.testkit.InMemoryLockProvider; |
||||
import apelet.common.ecommerce.testkit.InMemorySink; |
||||
import apelet.common.ecommerce.testkit.InMemoryTokenStore; |
||||
import apelet.common.ecommerce.testkit.Responses; |
||||
import org.junit.jupiter.api.BeforeEach; |
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import java.time.Clock; |
||||
import java.time.Duration; |
||||
import java.time.Instant; |
||||
import java.time.ZoneOffset; |
||||
import java.util.Collections; |
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals; |
||||
import static org.junit.jupiter.api.Assertions.assertNotNull; |
||||
import static org.junit.jupiter.api.Assertions.assertNull; |
||||
import static org.junit.jupiter.api.Assertions.assertTrue; |
||||
|
||||
/** |
||||
* 领星订单拉取的契约测试:不依赖网络、数据库、Spring。 |
||||
*/ |
||||
class LingxingAdapterTest { |
||||
|
||||
private static final String CREDENTIAL_REF = "kms://test/lingxing/001"; |
||||
|
||||
private FakeTransport transport; |
||||
private InMemoryTokenStore tokenStore; |
||||
private InMemoryAccounts accounts; |
||||
private InMemoryLockProvider locks; |
||||
private LingxingAdapter adapter; |
||||
private ChannelAccount account; |
||||
private ChannelContext ctx; |
||||
|
||||
@BeforeEach |
||||
void setUp() { |
||||
transport = FakeTransport.create(); |
||||
tokenStore = new InMemoryTokenStore(); |
||||
accounts = new InMemoryAccounts(); |
||||
locks = new InMemoryLockProvider(); |
||||
|
||||
account = ChannelAccount.builder() |
||||
.key(AccountKey.of(ChannelProvider.LINGXING, Platform.WALMART_SC, "oms-wm-001")) |
||||
.externalStoreId("1108413237731") |
||||
.platformCode(10008) |
||||
.credentialRef(CREDENTIAL_REF) |
||||
.marketplaceZone("America/Los_Angeles") |
||||
.currency("USD") |
||||
.build(); |
||||
|
||||
accounts.put(account).putSecret(CREDENTIAL_REF, ChannelSecret.builder() |
||||
.appKey("test-app-key") |
||||
.appSecret("test-app-secret") |
||||
.build()); |
||||
|
||||
Clock clock = Clock.fixed(Instant.parse("2026-10-29T00:00:00Z"), ZoneOffset.UTC); |
||||
adapter = new LingxingAdapter(LingxingConstants.DEFAULT_BASE_URL, transport, new JacksonJsonCodec(), |
||||
accounts, tokenStore, locks, clock); |
||||
|
||||
ctx = ChannelContext.of(account, "req-1", clock); |
||||
|
||||
String tokenJson = Fixtures.load(LingxingAdapterTest.class, "lingxing", "token", "success"); |
||||
String orderJson = Fixtures.load(LingxingAdapterTest.class, "lingxing", "orderList", "success"); |
||||
transport.route(LingxingConstants.PATH_ACCESS_TOKEN, "POST", r -> Responses.ok(tokenJson)); |
||||
transport.route(LingxingConstants.PATH_ORDER_LIST, "POST", r -> Responses.ok(orderJson)); |
||||
} |
||||
|
||||
@Test |
||||
void pullsOrdersAndMapsToUnifiedModel() { |
||||
Page<SalesOrder> page = adapter.pull(ctx, SalesOrderQuery.builder() |
||||
.updatedAfter(Instant.parse("2026-10-25T00:00:00Z")) |
||||
.updatedBefore(Instant.parse("2026-10-26T00:00:00Z")) |
||||
.pageSize(100) |
||||
.build()); |
||||
|
||||
assertEquals(3, page.size()); |
||||
assertEquals("PO-123456789", page.items().get(0).channelOrderId()); |
||||
assertEquals(ChannelProvider.LINGXING, page.items().get(0).sourceProvider()); |
||||
// total=3, offset 0 + 3 = 3 → 结束
|
||||
assertNull(page.nextCursor()); |
||||
} |
||||
|
||||
@Test |
||||
void tokenFetchedOnceAndReused() { |
||||
SalesOrderQuery q = SalesOrderQuery.builder() |
||||
.updatedAfter(Instant.parse("2026-10-25T00:00:00Z")) |
||||
.updatedBefore(Instant.parse("2026-10-26T00:00:00Z")) |
||||
.build(); |
||||
adapter.pull(ctx, q); |
||||
adapter.pull(ctx, q); |
||||
|
||||
assertEquals(1, tokenStore.saveCount(), "token should be refreshed only once"); |
||||
assertEquals(1, locks.acquireCount("token-refresh:lingxing:1108413237731")); |
||||
assertNotNull(tokenStore.get("lingxing:1108413237731").orElse(null)); |
||||
} |
||||
|
||||
@Test |
||||
void requestCarriesSignAndBearerToken() { |
||||
adapter.pull(ctx, SalesOrderQuery.builder() |
||||
.updatedAfter(Instant.parse("2026-10-25T00:00:00Z")) |
||||
.updatedBefore(Instant.parse("2026-10-26T00:00:00Z")) |
||||
.build()); |
||||
|
||||
apelet.common.ecommerce.http.HttpRequest last = transport.lastRequest(); |
||||
assertTrue(last.url().contains("app_key=test-app-key"), last.url()); |
||||
assertTrue(last.url().contains("sign="), last.url()); |
||||
assertEquals("Bearer lx-access-token-abc", last.headers().get("Authorization")); |
||||
assertEquals("req-1", last.headers().get("X-Request-Id")); |
||||
assertTrue(last.body().contains("\"platform_code\":10008"), last.body()); |
||||
assertTrue(last.body().contains("\"store_id\":\"1108413237731\""), last.body()); |
||||
} |
||||
|
||||
@Test |
||||
void queryWindowClampedTo31Days() { |
||||
adapter.pull(ctx, SalesOrderQuery.builder() |
||||
.updatedAfter(Instant.parse("2026-01-01T00:00:00Z")) |
||||
.updatedBefore(Instant.parse("2026-10-26T00:00:00Z")) |
||||
.build()); |
||||
|
||||
String body = transport.lastRequest().body(); |
||||
long end = Instant.parse("2026-10-26T00:00:00Z").getEpochSecond(); |
||||
long expectedStart = Instant.parse("2026-10-26T00:00:00Z").minus(Duration.ofDays(31)).getEpochSecond(); |
||||
assertTrue(body.contains("\"update_time_end\":" + end), body); |
||||
assertTrue(body.contains("\"update_time_start\":" + expectedStart), body); |
||||
} |
||||
|
||||
@Test |
||||
void syncEngineAdvancesWatermarkOnlyAfterWholeBatch() { |
||||
InMemoryCursorStore cursors = new InMemoryCursorStore(); |
||||
InMemorySink sink = new InMemorySink(); |
||||
SyncSpec spec = SyncSpec.of(ChannelProvider.LINGXING, "oms-wm-001", |
||||
SyncSpec.STREAM_SALES_ORDERS, Duration.ofMinutes(10)); |
||||
cursors.seed(ChannelProvider.LINGXING, "oms-wm-001", SyncSpec.STREAM_SALES_ORDERS, |
||||
Instant.parse("2026-10-25T00:00:00Z")); |
||||
|
||||
Instant to = Instant.parse("2026-10-26T00:00:00Z"); |
||||
SyncResult result = new SyncEngine(cursors, sink).syncSalesOrders(spec, to, |
||||
(from, cursor) -> adapter.pull(ctx, SalesOrderQuery.builder() |
||||
.updatedAfter(from).updatedBefore(to).build())); |
||||
|
||||
assertEquals(1, result.pages()); |
||||
assertEquals(3, result.items()); |
||||
assertTrue(result.watermarkAdvanced()); |
||||
assertEquals(to, cursors.watermark(ChannelProvider.LINGXING, "oms-wm-001", |
||||
SyncSpec.STREAM_SALES_ORDERS)); |
||||
assertEquals(3, sink.salesOrders().size()); |
||||
} |
||||
|
||||
@Test |
||||
void capabilityIsAccountScoped() { |
||||
ChannelAccount ebay = ChannelAccount.builder() |
||||
.key(AccountKey.of(ChannelProvider.LINGXING, Platform.EBAY, "oms-ebay-001")) |
||||
.externalStoreId("1108413237731") |
||||
.platformCode(10003) |
||||
.credentialRef(CREDENTIAL_REF) |
||||
.marketplaceZone("America/New_York") |
||||
.currency("USD") |
||||
.build(); |
||||
|
||||
assertTrue(adapter.supports(account, apelet.common.ecommerce.model.Capability.SALES_ORDER)); |
||||
assertTrue(adapter.supports(ebay, apelet.common.ecommerce.model.Capability.SALES_ORDER)); |
||||
// eBay 在领星侧未见账单与退货接口
|
||||
assertEquals(false, adapter.supports(ebay, apelet.common.ecommerce.model.Capability.BILLING)); |
||||
assertEquals(false, adapter.supports(ebay, apelet.common.ecommerce.model.Capability.REFUND)); |
||||
} |
||||
|
||||
@Test |
||||
void subChannelsReflectAccount() { |
||||
assertEquals(1, adapter.listSubChannels(account).size()); |
||||
assertEquals(10008, adapter.listSubChannels(account).get(0).platformCode()); |
||||
} |
||||
} |
||||
@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
|
||||
package apelet.common.ecommerce.lingxing; |
||||
|
||||
import apelet.common.ecommerce.http.JacksonJsonCodec; |
||||
import apelet.common.ecommerce.lingxing.dto.LingxingOrderDto; |
||||
import apelet.common.ecommerce.model.ChannelProvider; |
||||
import apelet.common.ecommerce.model.SalesOrder; |
||||
import apelet.common.ecommerce.testkit.Fixtures; |
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import java.time.ZoneId; |
||||
import java.util.List; |
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals; |
||||
import static org.junit.jupiter.api.Assertions.assertNull; |
||||
|
||||
class LingxingOrderMapperTest { |
||||
|
||||
private final JacksonJsonCodec json = new JacksonJsonCodec(); |
||||
private final LingxingOrderMapper mapper = new LingxingOrderMapper(json); |
||||
|
||||
private List<LingxingOrderDto> fixture() { |
||||
String body = Fixtures.load(LingxingOrderMapperTest.class, "lingxing", "orderList", "success"); |
||||
return json.listAt(body, "data.list", LingxingOrderDto.class); |
||||
} |
||||
|
||||
@Test |
||||
void mapsWalmartOrder() { |
||||
SalesOrder o = mapper.toSalesOrder(fixture().get(0), "oms-wm-001", ZoneId.of("America/Los_Angeles")); |
||||
|
||||
assertEquals("oms-wm-001", o.omsStoreId()); |
||||
assertEquals("PO-123456789", o.channelOrderId()); |
||||
assertEquals("Shipped", o.status()); |
||||
assertEquals("US", o.shipToCountryCode()); |
||||
assertEquals(2, o.lines().size()); |
||||
assertEquals("SKU-RED-M", o.lines().get(0).platformSku()); |
||||
assertEquals(2, o.lines().get(0).quantity()); |
||||
assertEquals("59.97 USD", o.totalAmount().toString()); |
||||
assertEquals(ChannelProvider.LINGXING, o.sourceProvider()); |
||||
assertEquals("1108413237731", o.sourceAccountId()); |
||||
// epoch 秒 1793001600 → UTC 2026-10-26T08:00:00Z
|
||||
assertEquals(1793001600L, o.occurredAt().utc().getEpochSecond()); |
||||
} |
||||
|
||||
@Test |
||||
void fallsBackToSkuWhenPlatformSkuMissing() { |
||||
SalesOrder o = mapper.toSalesOrder(fixture().get(1), "oms-ebay-001", ZoneId.of("America/New_York")); |
||||
assertEquals("MSKU-GREEN-S", o.lines().get(0).platformSku()); |
||||
assertNull(o.lines().get(0).tax()); |
||||
} |
||||
|
||||
@Test |
||||
void platformOrderNamesSortedForStableCanonicalKey() { |
||||
// 返回顺序是 EBAY-2222, EBAY-1111,取排序后第一个,保证多次同步 key 稳定
|
||||
SalesOrder o = mapper.toSalesOrder(fixture().get(1), "oms-ebay-001", ZoneId.of("America/New_York")); |
||||
assertEquals("EBAY-1111", o.channelOrderId()); |
||||
} |
||||
|
||||
@Test |
||||
void fallsBackToLingxingOrderIdWhenNoPlatformNo() { |
||||
SalesOrder o = mapper.toSalesOrder(fixture().get(2), "oms-tts-001", ZoneId.of("Europe/London")); |
||||
assertEquals("LX-1003", o.channelOrderId()); |
||||
} |
||||
|
||||
@Test |
||||
void platformCodeMapsToUnifiedPlatform() { |
||||
assertEquals(apelet.common.ecommerce.model.Platform.WALMART_SC, LingxingConstants.toPlatform(10008)); |
||||
assertEquals(apelet.common.ecommerce.model.Platform.EBAY, LingxingConstants.toPlatform(10003)); |
||||
assertEquals(apelet.common.ecommerce.model.Platform.SHEIN, LingxingConstants.toPlatform(10027)); |
||||
assertEquals(apelet.common.ecommerce.model.Platform.CUSTOM, LingxingConstants.toPlatform(99999)); |
||||
assertNull(LingxingConstants.toPlatform(null)); |
||||
} |
||||
} |
||||
@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
|
||||
package apelet.common.ecommerce.lingxing; |
||||
|
||||
import apelet.common.ecommerce.http.JsonCodec; |
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import java.util.LinkedHashMap; |
||||
import java.util.Map; |
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals; |
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals; |
||||
|
||||
class LingxingSignerTest { |
||||
|
||||
@Test |
||||
void signatureIsOrderIndependent() { |
||||
Map<String, String> a = new LinkedHashMap<String, String>(); |
||||
a.put("app_key", "k1"); |
||||
a.put("timestamp", "1793001600"); |
||||
|
||||
Map<String, String> b = new LinkedHashMap<String, String>(); |
||||
b.put("timestamp", "1793001600"); |
||||
b.put("app_key", "k1"); |
||||
|
||||
assertEquals(LingxingSigner.sign(a, "secret"), LingxingSigner.sign(b, "secret")); |
||||
} |
||||
|
||||
@Test |
||||
void signatureIs32LowercaseHex() { |
||||
Map<String, String> p = new LinkedHashMap<String, String>(); |
||||
p.put("app_key", "k1"); |
||||
p.put("timestamp", "1793001600"); |
||||
String sign = LingxingSigner.sign(p, "secret"); |
||||
assertEquals(32, sign.length()); |
||||
assertEquals(sign.toLowerCase(), sign); |
||||
assertEquals(true, sign.matches("[0-9a-f]{32}")); |
||||
} |
||||
|
||||
@Test |
||||
void differentSecretDifferentSignature() { |
||||
Map<String, String> p = new LinkedHashMap<String, String>(); |
||||
p.put("app_key", "k1"); |
||||
assertNotEquals(LingxingSigner.sign(p, "a"), LingxingSigner.sign(p, "b")); |
||||
} |
||||
|
||||
@Test |
||||
void knownVector() { |
||||
// 固定向量:appSecret + "app_key" + "k1" + "timestamp" + "1793001600" + appSecret
|
||||
Map<String, String> p = new LinkedHashMap<String, String>(); |
||||
p.put("app_key", "k1"); |
||||
p.put("timestamp", "1793001600"); |
||||
assertEquals(LingxingSigner.md5Hex("secretapp_keyk1timestamp1793001600secret"), |
||||
LingxingSigner.sign(p, "secret")); |
||||
} |
||||
} |
||||
@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
|
||||
{ |
||||
"code": 0, |
||||
"msg": "success", |
||||
"data": { |
||||
"total": 3, |
||||
"list": [ |
||||
{ |
||||
"order_id": "LX-1001", |
||||
"platform_order_no": "PO-123456789", |
||||
"platform_code": 10008, |
||||
"store_id": "1108413237731", |
||||
"order_status": "Shipped", |
||||
"buyer_name": "John Doe", |
||||
"country_code": "US", |
||||
"currency_code": "USD", |
||||
"order_total": "59.97", |
||||
"purchase_time": 1793001600, |
||||
"update_time": 1793088000, |
||||
"purchase_time_locale": "2026-10-26 08:00:00", |
||||
"update_time_locale": "2026-10-27 08:00:00", |
||||
"item_list": [ |
||||
{ |
||||
"order_item_id": "ITEM-1", |
||||
"sku": "MSKU-RED-M", |
||||
"platform_sku": "SKU-RED-M", |
||||
"quantity": 2, |
||||
"item_price": "19.99", |
||||
"tax": "1.60", |
||||
"discount": "0.00" |
||||
}, |
||||
{ |
||||
"order_item_id": "ITEM-2", |
||||
"sku": "MSKU-BLUE-L", |
||||
"quantity": 1, |
||||
"item_price": "19.99", |
||||
"tax": "0.80", |
||||
"discount": "0.00" |
||||
} |
||||
] |
||||
}, |
||||
{ |
||||
"order_id": "LX-1002", |
||||
"platform_order_names": ["EBAY-2222", "EBAY-1111"], |
||||
"platform_code": 10003, |
||||
"store_id": "1108413237731", |
||||
"order_status": "Paid", |
||||
"buyer_name": "Jane Roe", |
||||
"country_code": "US", |
||||
"currency_code": "USD", |
||||
"order_total": "15.00", |
||||
"purchase_time": 1793002000, |
||||
"update_time": 1793088000, |
||||
"item_list": [ |
||||
{ |
||||
"order_item_id": "ITEM-3", |
||||
"sku": "MSKU-GREEN-S", |
||||
"quantity": 1, |
||||
"item_price": "15.00" |
||||
} |
||||
] |
||||
}, |
||||
{ |
||||
"order_id": "LX-1003", |
||||
"platform_code": 10011, |
||||
"store_id": "1108413237731", |
||||
"order_status": "Unpaid", |
||||
"buyer_name": "No Platform No", |
||||
"country_code": "GB", |
||||
"currency_code": "GBP", |
||||
"order_total": "10.00", |
||||
"purchase_time": 1793003000, |
||||
"update_time": 1793088000, |
||||
"item_list": [] |
||||
} |
||||
] |
||||
} |
||||
} |
||||
Loading…
Reference in new issue