From b7e6df462ab3ec2ea141ad225ee61ee397309b07 Mon Sep 17 00:00:00 2001 From: chenchuchuan <13554600537@163.com> Date: Thu, 29 Oct 2026 14:56:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(lingxing):=20=E9=A2=86=E6=98=9F=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E6=8B=89=E5=8F=96=E9=80=82=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../common/ecommerce/lingxing/LingxingAdapter.java | 262 +++++++++++++++++++++ .../ecommerce/lingxing/LingxingAdapterFactory.java | 58 +++++ .../ecommerce/lingxing/LingxingConstants.java | 60 +++++ .../ecommerce/lingxing/LingxingOrderMapper.java | 112 +++++++++ .../common/ecommerce/lingxing/LingxingSigner.java | 58 +++++ .../ecommerce/lingxing/LingxingTokenClient.java | 67 ++++++ .../ecommerce/lingxing/dto/LingxingOrderDto.java | 196 +++++++++++++++ .../lingxing/dto/LingxingOrderItemDto.java | 88 +++++++ .../ecommerce/lingxing/LingxingAdapterTest.java | 190 +++++++++++++++ .../lingxing/LingxingOrderMapperTest.java | 72 ++++++ .../ecommerce/lingxing/LingxingSignerTest.java | 54 +++++ .../fixtures/lingxing/orderList/success.json | 77 ++++++ .../resources/fixtures/lingxing/token/success.json | 9 + 13 files changed, 1303 insertions(+) create mode 100644 lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapter.java create mode 100644 lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapterFactory.java create mode 100644 lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingConstants.java create mode 100644 lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingOrderMapper.java create mode 100644 lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingSigner.java create mode 100644 lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingTokenClient.java create mode 100644 lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderDto.java create mode 100644 lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderItemDto.java create mode 100644 lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingAdapterTest.java create mode 100644 lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingOrderMapperTest.java create mode 100644 lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingSignerTest.java create mode 100644 lingxing/src/test/resources/fixtures/lingxing/orderList/success.json create mode 100644 lingxing/src/test/resources/fixtures/lingxing/token/success.json diff --git a/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapter.java b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapter.java new file mode 100644 index 0000000..7296762 --- /dev/null +++ b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapter.java @@ -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; + +/** + * 领星聚合适配器。 + * + *

一个适配器服务多个子平台,子平台由 {@code ChannelAccount.platformCode} 决定, + * 能力按子平台细化({@link #supports})。首期只实现订单列表拉取。

+ * + *

领星字段名与签名细节标注为「待核对」的地方集中在 {@code dto} 与 + * {@link LingxingSigner},联调时只改这两处。

+ */ +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 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 capabilities() { + return EnumSet.of(Capability.SALES_ORDER); + } + + /** + * 聚合器按子平台细化能力。 + * + *

eBay / SHEIN / Mirakl / 自定义平台在领星侧只有订单,未见账单与售后接口, + * 因此只声明 SALES_ORDER,缺口由能力报告显式暴露,不假装支持。

+ */ + @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 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 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 dtos = mapper.parseList(resp.body()); + int total = mapper.parseTotal(resp.body()); + + List orders = new ArrayList(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 body = new LinkedHashMap(); + 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 common = new LinkedHashMap(); + 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; + } + } +} diff --git a/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapterFactory.java b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapterFactory.java new file mode 100644 index 0000000..2729d1c --- /dev/null +++ b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapterFactory.java @@ -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 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); + } +} diff --git a/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingConstants.java b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingConstants.java new file mode 100644 index 0000000..0c74095 --- /dev/null +++ b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingConstants.java @@ -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。 + * + *

10034 同时对应 Mirakl / BestBuy;Target、AEPH 未独立枚举, + * 按文档需要到领星后台确认,此处先映射到 CUSTOM,待核对。

+ */ + 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; + } + } +} diff --git a/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingOrderMapper.java b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingOrderMapper.java new file mode 100644 index 0000000..ebb30b3 --- /dev/null +++ b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingOrderMapper.java @@ -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; + +/** + * 领星订单 → 统一模型。 + * + *

领星订单是唯一的映射入口,平台差异全部收敛在这里。

+ */ +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 lines = new ArrayList(); + 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 的一部分,必须稳定。 + * + *

优先平台单号;缺失时退回领星 order_id(此时跨 provider 无法去重, + * 会由 DuplicateOrderGuard 记录差异)。

+ */ + static String resolveChannelOrderId(LingxingOrderDto dto) { + if (dto.getPlatformOrderNo() != null && !dto.getPlatformOrderNo().trim().isEmpty()) { + return dto.getPlatformOrderNo().trim(); + } + List names = dto.getPlatformOrderNames(); + if (names != null && !names.isEmpty()) { + List sorted = new ArrayList(names); + Collections.sort(sorted, new Comparator() { + @Override + public int compare(String a, String b) { + return a.compareTo(b); + } + }); + return sorted.get(0); + } + return dto.getOrderId(); + } + + public List 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 List safe(List in) { + return in == null ? Collections.emptyList() : in; + } + + private static Money moneyOrNull(String value, String currency) { + if (value == null || value.trim().isEmpty()) { + return null; + } + return Money.of(value.trim(), currency); + } +} diff --git a/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingSigner.java b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingSigner.java new file mode 100644 index 0000000..70f1246 --- /dev/null +++ b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingSigner.java @@ -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 小写。 + * + *

待核对:官方文档对 sign 的大小写与大写规则未在需求文档中固化, + * 实现集中在 {@link #sign(Map, String)} 一处,便于联调时调整。

+ */ +public final class LingxingSigner { + + private LingxingSigner() { + } + + /** + * @param params 参与签名的参数(不含 sign),值不得为 null + * @param appSecret 应用密钥 + */ + public static String sign(Map params, String appSecret) { + List keys = new ArrayList(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 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); + } + } +} diff --git a/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingTokenClient.java b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingTokenClient.java new file mode 100644 index 0000000..95f3e14 --- /dev/null +++ b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingTokenClient.java @@ -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}。 + * + *

请求体字段名以官方文档为准(appId/appSecret),这里集中在一处便于联调调整。

+ */ +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 body = new LinkedHashMap(); + 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(); + } +} diff --git a/lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderDto.java b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderDto.java new file mode 100644 index 0000000..5fb4633 --- /dev/null +++ b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderDto.java @@ -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; + +/** + * 领星多平台订单列表条目。 + * + *

待核对:字段名依据领星 OpenAPI 文档摘录,联调时以官方最新文档 + * 与真实响应为准;调整集中在本 DTO 与 {@code LingxingOrderMapper}。

+ */ +@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 platformOrderNames = new ArrayList(); + + /** 领星平台码,如 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 itemList = new ArrayList(); + + 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 getPlatformOrderNames() { + return platformOrderNames; + } + + public void setPlatformOrderNames(List 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 getItemList() { + return itemList; + } + + public void setItemList(List itemList) { + this.itemList = itemList; + } +} diff --git a/lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderItemDto.java b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderItemDto.java new file mode 100644 index 0000000..b50e990 --- /dev/null +++ b/lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderItemDto.java @@ -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; + } +} diff --git a/lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingAdapterTest.java b/lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingAdapterTest.java new file mode 100644 index 0000000..25ceae0 --- /dev/null +++ b/lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingAdapterTest.java @@ -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 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()); + } +} diff --git a/lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingOrderMapperTest.java b/lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingOrderMapperTest.java new file mode 100644 index 0000000..491d275 --- /dev/null +++ b/lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingOrderMapperTest.java @@ -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 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)); + } +} diff --git a/lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingSignerTest.java b/lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingSignerTest.java new file mode 100644 index 0000000..3eb8076 --- /dev/null +++ b/lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingSignerTest.java @@ -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 a = new LinkedHashMap(); + a.put("app_key", "k1"); + a.put("timestamp", "1793001600"); + + Map b = new LinkedHashMap(); + b.put("timestamp", "1793001600"); + b.put("app_key", "k1"); + + assertEquals(LingxingSigner.sign(a, "secret"), LingxingSigner.sign(b, "secret")); + } + + @Test + void signatureIs32LowercaseHex() { + Map p = new LinkedHashMap(); + 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 p = new LinkedHashMap(); + 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 p = new LinkedHashMap(); + p.put("app_key", "k1"); + p.put("timestamp", "1793001600"); + assertEquals(LingxingSigner.md5Hex("secretapp_keyk1timestamp1793001600secret"), + LingxingSigner.sign(p, "secret")); + } +} diff --git a/lingxing/src/test/resources/fixtures/lingxing/orderList/success.json b/lingxing/src/test/resources/fixtures/lingxing/orderList/success.json new file mode 100644 index 0000000..33fb5bb --- /dev/null +++ b/lingxing/src/test/resources/fixtures/lingxing/orderList/success.json @@ -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": [] + } + ] + } +} diff --git a/lingxing/src/test/resources/fixtures/lingxing/token/success.json b/lingxing/src/test/resources/fixtures/lingxing/token/success.json new file mode 100644 index 0000000..a07a4a5 --- /dev/null +++ b/lingxing/src/test/resources/fixtures/lingxing/token/success.json @@ -0,0 +1,9 @@ +{ + "code": 0, + "msg": "success", + "data": { + "access_token": "lx-access-token-abc", + "refresh_token": "lx-refresh-token-xyz", + "expires_in": 7199 + } +}