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
+ }
+}