Browse Source

feat(lingxing): 领星订单拉取适配器

- 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
chenchuchuan 18 hours ago
parent
commit
b7e6df462a
  1. 262
      lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapter.java
  2. 58
      lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapterFactory.java
  3. 60
      lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingConstants.java
  4. 112
      lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingOrderMapper.java
  5. 58
      lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingSigner.java
  6. 67
      lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingTokenClient.java
  7. 196
      lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderDto.java
  8. 88
      lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderItemDto.java
  9. 190
      lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingAdapterTest.java
  10. 72
      lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingOrderMapperTest.java
  11. 54
      lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingSignerTest.java
  12. 77
      lingxing/src/test/resources/fixtures/lingxing/orderList/success.json
  13. 9
      lingxing/src/test/resources/fixtures/lingxing/token/success.json

262
lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapter.java

@ -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"))
+ "&timestamp=" + 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;
}
}
}

58
lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingAdapterFactory.java

@ -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);
}
}

60
lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingConstants.java

@ -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;
}
}
}

112
lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingOrderMapper.java

@ -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);
}
}

58
lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingSigner.java

@ -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);
}
}
}

67
lingxing/src/main/java/apelet/common/ecommerce/lingxing/LingxingTokenClient.java

@ -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();
}
}

196
lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderDto.java

@ -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;
}
}

88
lingxing/src/main/java/apelet/common/ecommerce/lingxing/dto/LingxingOrderItemDto.java

@ -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;
}
}

190
lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingAdapterTest.java

@ -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());
}
}

72
lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingOrderMapperTest.java

@ -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));
}
}

54
lingxing/src/test/java/apelet/common/ecommerce/lingxing/LingxingSignerTest.java

@ -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"));
}
}

77
lingxing/src/test/resources/fixtures/lingxing/orderList/success.json

@ -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": []
}
]
}
}

9
lingxing/src/test/resources/fixtures/lingxing/token/success.json

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
{
"code": 0,
"msg": "success",
"data": {
"access_token": "lx-access-token-abc",
"refresh_token": "lx-refresh-token-xyz",
"expires_in": 7199
}
}
Loading…
Cancel
Save