pull(ChannelContext ctx, SalesOrderQuery query);
+
+ /** 单订单详情。 */
+ SalesOrder get(ChannelContext ctx, String channelOrderId);
+
+ /**
+ * 默认实现:未声明该能力即拒绝,避免适配器漏实现导致静默返回空。
+ */
+ default void requireCapability() {
+ if (!capabilities().contains(Capability.SALES_ORDER)) {
+ throw new ChannelException.UnsupportedCapabilityException(Capability.SALES_ORDER);
+ }
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/error/AuthException.java b/core/src/main/java/apelet/common/ecommerce/error/AuthException.java
new file mode 100644
index 0000000..98d317f
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/error/AuthException.java
@@ -0,0 +1,17 @@
+package apelet.common.ecommerce.error;
+
+/**
+ * 认证失败:401/403、token 失效。刷新后重试一次;仍失败则告警并停用账户。
+ */
+public class AuthException extends ChannelException {
+
+ private static final long serialVersionUID = 1L;
+
+ public AuthException(Builder b) {
+ super(b, null);
+ }
+
+ public AuthException(Builder b, Throwable cause) {
+ super(b, cause);
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/error/ChannelException.java b/core/src/main/java/apelet/common/ecommerce/error/ChannelException.java
new file mode 100644
index 0000000..f40130a
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/error/ChannelException.java
@@ -0,0 +1,154 @@
+package apelet.common.ecommerce.error;
+
+import apelet.common.ecommerce.model.Capability;
+import apelet.common.ecommerce.model.Platform;
+
+/**
+ * 统一异常基类。适配器把平台错误映射到这里;{@code rawResponse} 必须已脱敏。
+ */
+public class ChannelException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final Platform platform;
+ private final String provider;
+ private final String omsStoreId;
+ private final String operation;
+ private final String requestId;
+ private final String platformCode;
+ private final boolean retryable;
+ private final String rawResponse;
+
+ public ChannelException(Builder b, Throwable cause) {
+ super(b.message != null ? b.message : b.operation, cause);
+ this.platform = b.platform;
+ this.provider = b.provider;
+ this.omsStoreId = b.omsStoreId;
+ this.operation = b.operation;
+ this.requestId = b.requestId;
+ this.platformCode = b.platformCode;
+ this.retryable = b.retryable;
+ this.rawResponse = b.rawResponse;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public Platform platform() {
+ return platform;
+ }
+
+ public String provider() {
+ return provider;
+ }
+
+ public String omsStoreId() {
+ return omsStoreId;
+ }
+
+ public String operation() {
+ return operation;
+ }
+
+ public String requestId() {
+ return requestId;
+ }
+
+ public String platformCode() {
+ return platformCode;
+ }
+
+ public boolean retryable() {
+ return retryable;
+ }
+
+ public String rawResponse() {
+ return rawResponse;
+ }
+
+ public static class Builder {
+
+ protected String message;
+ protected Platform platform;
+ protected String provider;
+ protected String omsStoreId;
+ protected String operation;
+ protected String requestId;
+ protected String platformCode;
+ protected boolean retryable;
+ protected String rawResponse;
+
+ public Builder message(String message) {
+ this.message = message;
+ return this;
+ }
+
+ public Builder platform(Platform platform) {
+ this.platform = platform;
+ return this;
+ }
+
+ public Builder provider(String provider) {
+ this.provider = provider;
+ return this;
+ }
+
+ public Builder omsStoreId(String omsStoreId) {
+ this.omsStoreId = omsStoreId;
+ return this;
+ }
+
+ public Builder operation(String operation) {
+ this.operation = operation;
+ return this;
+ }
+
+ public Builder requestId(String requestId) {
+ this.requestId = requestId;
+ return this;
+ }
+
+ public Builder platformCode(String platformCode) {
+ this.platformCode = platformCode;
+ return this;
+ }
+
+ public Builder retryable(boolean retryable) {
+ this.retryable = retryable;
+ return this;
+ }
+
+ public Builder rawResponse(String rawResponse) {
+ this.rawResponse = rawResponse;
+ return this;
+ }
+
+ public ChannelException build() {
+ return new ChannelException(this, null);
+ }
+
+ public ChannelException build(Throwable cause) {
+ return new ChannelException(this, cause);
+ }
+ }
+
+ /** 平台不支持该能力;调用方走 3PL 或人工兜底。 */
+ public static class UnsupportedCapabilityException extends ChannelException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final Capability capability;
+
+ public UnsupportedCapabilityException(Capability capability) {
+ super(ChannelException.builder()
+ .message("capability [" + capability + "] is not supported by this adapter")
+ .operation("capabilities"), null);
+ this.capability = capability;
+ }
+
+ public Capability capability() {
+ return capability;
+ }
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/error/CrossProviderConflictException.java b/core/src/main/java/apelet/common/ecommerce/error/CrossProviderConflictException.java
new file mode 100644
index 0000000..861ecd3
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/error/CrossProviderConflictException.java
@@ -0,0 +1,22 @@
+package apelet.common.ecommerce.error;
+
+/**
+ * 跨 provider 冲突:同一业务动作已由另一个 provider 完成。
+ *
+ * 不自动跨 provider 重试,必须人工确认。
+ */
+public class CrossProviderConflictException extends ChannelException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String completedByProvider;
+
+ public CrossProviderConflictException(Builder b, String completedByProvider) {
+ super(b, null);
+ this.completedByProvider = completedByProvider;
+ }
+
+ public String completedByProvider() {
+ return completedByProvider;
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/error/IdempotencyConflictException.java b/core/src/main/java/apelet/common/ecommerce/error/IdempotencyConflictException.java
new file mode 100644
index 0000000..ccadc75
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/error/IdempotencyConflictException.java
@@ -0,0 +1,18 @@
+package apelet.common.ecommerce.error;
+
+/** 写操作幂等冲突:重复写。返回已完成结果,不视为失败。 */
+public class IdempotencyConflictException extends ChannelException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String idempotencyKey;
+
+ public IdempotencyConflictException(Builder b, String idempotencyKey) {
+ super(b, null);
+ this.idempotencyKey = idempotencyKey;
+ }
+
+ public String idempotencyKey() {
+ return idempotencyKey;
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/error/PlatformBusinessException.java b/core/src/main/java/apelet/common/ecommerce/error/PlatformBusinessException.java
new file mode 100644
index 0000000..5643984
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/error/PlatformBusinessException.java
@@ -0,0 +1,13 @@
+package apelet.common.ecommerce.error;
+
+/**
+ * 平台业务错误码。是否重试视错误码而定,保留 code/message/raw。
+ */
+public class PlatformBusinessException extends ChannelException {
+
+ private static final long serialVersionUID = 1L;
+
+ public PlatformBusinessException(Builder b) {
+ super(b, null);
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/error/RateLimitException.java b/core/src/main/java/apelet/common/ecommerce/error/RateLimitException.java
new file mode 100644
index 0000000..08d9e1c
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/error/RateLimitException.java
@@ -0,0 +1,20 @@
+package apelet.common.ecommerce.error;
+
+/**
+ * 限流:429。遵守 Retry-After 并退避。
+ */
+public class RateLimitException extends ChannelException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final long retryAfterMillis;
+
+ public RateLimitException(Builder b, long retryAfterMillis) {
+ super(b, null);
+ this.retryAfterMillis = retryAfterMillis;
+ }
+
+ public long retryAfterMillis() {
+ return retryAfterMillis;
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/error/TransientException.java b/core/src/main/java/apelet/common/ecommerce/error/TransientException.java
new file mode 100644
index 0000000..d3cbdbd
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/error/TransientException.java
@@ -0,0 +1,13 @@
+package apelet.common.ecommerce.error;
+
+/**
+ * 瞬时错误:5xx / 网络 / 超时。退避加抖动后重试,超过上限进入补偿队列。
+ */
+public class TransientException extends ChannelException {
+
+ private static final long serialVersionUID = 1L;
+
+ public TransientException(Builder b, Throwable cause) {
+ super(b, cause);
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/error/ValidationException.java b/core/src/main/java/apelet/common/ecommerce/error/ValidationException.java
new file mode 100644
index 0000000..1838a43
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/error/ValidationException.java
@@ -0,0 +1,17 @@
+package apelet.common.ecommerce.error;
+
+/**
+ * 参数或映射错误:4xx。不重试,保留原始报文由人工修正映射。
+ */
+public class ValidationException extends ChannelException {
+
+ private static final long serialVersionUID = 1L;
+
+ public ValidationException(Builder b) {
+ super(b, null);
+ }
+
+ public ValidationException(Builder b, Throwable cause) {
+ super(b, cause);
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/AccountKey.java b/core/src/main/java/apelet/common/ecommerce/model/AccountKey.java
new file mode 100644
index 0000000..bbde0bb
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/AccountKey.java
@@ -0,0 +1,65 @@
+package apelet.common.ecommerce.model;
+
+import java.util.Objects;
+
+/**
+ * 渠道账户标识:{@code provider + platform + omsStoreId}。
+ *
+ * 这是所有门面调用的隔离维度。同一 omsStoreId 可以同时绑定直连与聚合器账户,
+ * 业务实体不会因此重复——provider 不进入业务主键,见 {@link CanonicalKeys}。
+ */
+public final class AccountKey {
+
+ private final ChannelProvider provider;
+ private final Platform platform;
+ private final String omsStoreId;
+
+ private AccountKey(ChannelProvider provider, Platform platform, String omsStoreId) {
+ this.provider = Objects.requireNonNull(provider, "provider");
+ this.platform = Objects.requireNonNull(platform, "platform");
+ if (omsStoreId == null || omsStoreId.trim().isEmpty()) {
+ throw new IllegalArgumentException("omsStoreId must not be blank");
+ }
+ this.omsStoreId = omsStoreId.trim();
+ }
+
+ public static AccountKey of(ChannelProvider provider, Platform platform, String omsStoreId) {
+ return new AccountKey(provider, platform, omsStoreId);
+ }
+
+ public ChannelProvider provider() {
+ return provider;
+ }
+
+ public Platform platform() {
+ return platform;
+ }
+
+ public String omsStoreId() {
+ return omsStoreId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof AccountKey)) {
+ return false;
+ }
+ AccountKey that = (AccountKey) o;
+ return provider == that.provider
+ && platform == that.platform
+ && omsStoreId.equals(that.omsStoreId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(provider, platform, omsStoreId);
+ }
+
+ @Override
+ public String toString() {
+ return provider + ":" + platform + ":" + omsStoreId;
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/CanonicalKeys.java b/core/src/main/java/apelet/common/ecommerce/model/CanonicalKeys.java
new file mode 100644
index 0000000..4cf6262
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/CanonicalKeys.java
@@ -0,0 +1,112 @@
+package apelet.common.ecommerce.model;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * 三级标识计算。
+ *
+ *
+ * - omsStoreId:业务归属,直连与聚合器都映射到同一个店铺
+ * - canonicalKey:platform + omsStoreId + 平台实体 ID,用于 upsert、去重、状态流转
+ * - sourceKey:provider + platform + externalStoreId + 平台实体 ID,用于原始报文、水位线、审计
+ *
+ *
+ * provider 不得进入 canonicalKey,否则直连与聚合器切换会把同一实体变成两条。
+ */
+public final class CanonicalKeys {
+
+ private CanonicalKeys() {
+ }
+
+ public static String canonical(Platform platform, String omsStoreId, String platformEntityId) {
+ return platform.name() + ':' + require(omsStoreId, "omsStoreId") + ':' + require(platformEntityId, "platformEntityId");
+ }
+
+ public static String source(ChannelProvider provider, Platform platform, String externalStoreId, String platformEntityId) {
+ return provider.name() + ':' + platform.name() + ':' + require(externalStoreId, "externalStoreId")
+ + ':' + require(platformEntityId, "platformEntityId");
+ }
+
+ /** 销售订单 canonicalKey:platform + omsStoreId + channelOrderId */
+ public static String salesOrder(Platform platform, String omsStoreId, String channelOrderId) {
+ return canonical(platform, omsStoreId, channelOrderId);
+ }
+
+ /** 商品 canonicalKey:platform + omsStoreId + platformSku */
+ public static String product(Platform platform, String omsStoreId, String platformSku) {
+ return canonical(platform, omsStoreId, platformSku);
+ }
+
+ /**
+ * 结算单指纹:hash(periodStart, periodEnd, currency, paymentId/statementNo)。
+ *
+ * 刻意不含金额,避免不同 provider 的舍入差异造成重复账单;金额差异由 SourceConflict 记录。
+ */
+ public static String statementFingerprint(String periodStart, String periodEnd, String currency, String statementNo) {
+ return sha256Hex("STMT|" + periodStart + '|' + periodEnd + '|' + currency + '|' + statementNo);
+ }
+
+ /** 账单行指纹:hash(platformTxnId, orderRef, type, amount, currency, postedAt) */
+ public static String txnFingerprint(String platformTxnId, String orderRef, String type,
+ String amount, String currency, String postedAt) {
+ return sha256Hex("TXN|" + platformTxnId + '|' + orderRef + '|' + type + '|' + amount + '|' + currency + '|' + postedAt);
+ }
+
+ /** 事件指纹:hash(eventType, platformOrderId, returnId/refundId, occurredAt, status) */
+ public static String eventFingerprint(String eventType, String platformOrderId, String returnOrRefundId,
+ String occurredAt, String status) {
+ return sha256Hex("EVT|" + eventType + '|' + platformOrderId + '|' + returnOrRefundId + '|' + occurredAt + '|' + status);
+ }
+
+ /** 1P 扣款指纹:hash(poNumber, invoiceNumber, type, amount, currency, period, reason) */
+ public static String deductionFingerprint(String poNumber, String invoiceNumber, String type,
+ String amount, String currency, String period, String reason) {
+ return sha256Hex("DED|" + poNumber + '|' + invoiceNumber + '|' + type + '|'
+ + amount + '|' + currency + '|' + period + '|' + reason);
+ }
+
+ /**
+ * 写操作业务幂等键:platform + omsStoreId + businessRef + action。
+ *
+ * 不带 provider,直连与聚合器共用,保证同一业务动作只成功一次。
+ */
+ public static String businessIdempotencyKey(Platform platform, String omsStoreId, String businessRef, String action) {
+ return platform.name() + ':' + require(omsStoreId, "omsStoreId") + ':' + require(businessRef, "businessRef")
+ + ':' + require(action, "action");
+ }
+
+ /**
+ * provider 请求幂等键:provider + platform + externalStoreId + operation + providerRef。
+ *
+ * 带 provider,用于单 provider 内的重复请求/重试去重。
+ */
+ public static String providerRequestKey(ChannelProvider provider, Platform platform, String externalStoreId,
+ String operation, String providerRef) {
+ return provider.name() + ':' + platform.name() + ':' + require(externalStoreId, "externalStoreId")
+ + ':' + require(operation, "operation") + ':' + require(providerRef, "providerRef");
+ }
+
+ static String sha256Hex(String raw) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ 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("SHA-256 not available", e);
+ }
+ }
+
+ private static String require(String value, String name) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException(name + " must not be blank");
+ }
+ return value.trim();
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/Capability.java b/core/src/main/java/apelet/common/ecommerce/model/Capability.java
new file mode 100644
index 0000000..14eda8c
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/Capability.java
@@ -0,0 +1,45 @@
+package apelet.common.ecommerce.model;
+
+/**
+ * 能力枚举。
+ *
+ * 适配器实现哪些能力接口,就声明哪些能力;门面据此在调用前做能力检查,
+ * 缺失时抛 UnsupportedCapabilityException,而不是静默失败。
+ */
+public enum Capability {
+
+ /** 商品 / Listing */
+ PRODUCT,
+ /** 3P 销售订单 */
+ SALES_ORDER,
+ /** 1P 采购订单 */
+ PURCHASE_ORDER,
+ /** 3P 标发 */
+ MARK_SHIPPED,
+ /** 1P DF/DSV 发货确认 */
+ VENDOR_SHIPMENT,
+ /** 1P Retail ASN(856) */
+ ASN,
+ /** 1P 发票 */
+ INVOICE,
+ /** 平台面单 */
+ LABEL,
+ /** 作废面单 */
+ LABEL_VOID,
+ /** 账单 / 结算 */
+ BILLING,
+ /** 退货单 */
+ RETURNS,
+ /** 退款 */
+ REFUND,
+ /** 退货运单 */
+ RETURN_LABEL,
+ /** 1P 扣款 / 红字 */
+ DEDUCTION,
+ /** 事件接入 */
+ WEBHOOK,
+ /** 异步报表 */
+ ASYNC_REPORT,
+ /** EDI 报文 */
+ EDI
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/ChannelAccount.java b/core/src/main/java/apelet/common/ecommerce/model/ChannelAccount.java
new file mode 100644
index 0000000..cf4de9f
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/ChannelAccount.java
@@ -0,0 +1,167 @@
+package apelet.common.ecommerce.model;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 渠道账户。一个 omsStoreId 可绑定多个 provider 账户。
+ *
+ * 凭证不落配置:本对象只保存 {@code credentialRef},真实密钥由宿主
+ * {@code SecretProvider} 按引用取出。
+ */
+public final class ChannelAccount {
+
+ private final AccountKey key;
+ private final String externalStoreId;
+ private final Integer platformCode;
+ private final String region;
+ private final List marketplaceIds;
+ private final String credentialRef;
+ private final String marketplaceZone;
+ private final String currency;
+ private final String shipNode;
+ private final boolean sandbox;
+
+ private ChannelAccount(Builder b) {
+ this.key = Objects.requireNonNull(b.key, "key");
+ this.externalStoreId = Objects.requireNonNull(b.externalStoreId, "externalStoreId");
+ this.platformCode = b.platformCode;
+ this.region = b.region;
+ this.marketplaceIds = Collections.unmodifiableList(new ArrayList(b.marketplaceIds));
+ this.credentialRef = Objects.requireNonNull(b.credentialRef, "credentialRef");
+ this.marketplaceZone = Objects.requireNonNull(b.marketplaceZone, "marketplaceZone");
+ this.currency = Objects.requireNonNull(b.currency, "currency");
+ this.shipNode = b.shipNode;
+ this.sandbox = b.sandbox;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public AccountKey key() {
+ return key;
+ }
+
+ public String omsStoreId() {
+ return key.omsStoreId();
+ }
+
+ public Platform platform() {
+ return key.platform();
+ }
+
+ public ChannelProvider provider() {
+ return key.provider();
+ }
+
+ /** 平台店铺 ID / 领星 store_id */
+ public String externalStoreId() {
+ return externalStoreId;
+ }
+
+ /** 领星 platform_code;直连账户为 null,落库时用 {@link ChannelProvider#NO_PLATFORM_CODE} */
+ public Integer platformCode() {
+ return platformCode;
+ }
+
+ public String region() {
+ return region;
+ }
+
+ public List marketplaceIds() {
+ return marketplaceIds;
+ }
+
+ /** SecretProvider 的引用,不是密钥本身 */
+ public String credentialRef() {
+ return credentialRef;
+ }
+
+ /** IANA 时区 ID,如 America/Los_Angeles */
+ public String marketplaceZone() {
+ return marketplaceZone;
+ }
+
+ public String currency() {
+ return currency;
+ }
+
+ public String shipNode() {
+ return shipNode;
+ }
+
+ public boolean sandbox() {
+ return sandbox;
+ }
+
+ public static final class Builder {
+
+ private AccountKey key;
+ private String externalStoreId;
+ private Integer platformCode;
+ private String region;
+ private List marketplaceIds = Collections.emptyList();
+ private String credentialRef;
+ private String marketplaceZone;
+ private String currency;
+ private String shipNode;
+ private boolean sandbox;
+
+ public Builder key(AccountKey key) {
+ this.key = key;
+ return this;
+ }
+
+ public Builder externalStoreId(String externalStoreId) {
+ this.externalStoreId = externalStoreId;
+ return this;
+ }
+
+ public Builder platformCode(Integer platformCode) {
+ this.platformCode = platformCode;
+ return this;
+ }
+
+ public Builder region(String region) {
+ this.region = region;
+ return this;
+ }
+
+ public Builder marketplaceIds(List marketplaceIds) {
+ this.marketplaceIds = marketplaceIds == null ? Collections.emptyList() : marketplaceIds;
+ return this;
+ }
+
+ public Builder credentialRef(String credentialRef) {
+ this.credentialRef = credentialRef;
+ return this;
+ }
+
+ public Builder marketplaceZone(String marketplaceZone) {
+ this.marketplaceZone = marketplaceZone;
+ return this;
+ }
+
+ public Builder currency(String currency) {
+ this.currency = currency;
+ return this;
+ }
+
+ public Builder shipNode(String shipNode) {
+ this.shipNode = shipNode;
+ return this;
+ }
+
+ public Builder sandbox(boolean sandbox) {
+ this.sandbox = sandbox;
+ return this;
+ }
+
+ public ChannelAccount build() {
+ return new ChannelAccount(this);
+ }
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/ChannelProvider.java b/core/src/main/java/apelet/common/ecommerce/model/ChannelProvider.java
new file mode 100644
index 0000000..5eab69e
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/ChannelProvider.java
@@ -0,0 +1,23 @@
+package apelet.common.ecommerce.model;
+
+/**
+ * 接入 provider 枚举。
+ *
+ * provider 是接入通道,不是业务渠道。同一个平台店铺可以同时存在
+ * {@link #DIRECT} 与 {@link #LINGXING} 两条接入通道,由调用方显式选择;
+ * SDK 不做推荐、不自动选路、不自动兜底。
+ */
+public enum ChannelProvider {
+
+ /** 平台直连 */
+ DIRECT,
+ /** 领星聚合 */
+ LINGXING,
+ /** Teapplix 订单源 */
+ TEAPPLIX,
+ /** 其他聚合器或自建通道 */
+ CUSTOM;
+
+ /** 直连账户的 platformCode 哨兵值,避免 {@code (provider, externalStoreId, platformCode)} 唯一索引因 NULL 失效。 */
+ public static final int NO_PLATFORM_CODE = 0;
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/FulfillmentMode.java b/core/src/main/java/apelet/common/ecommerce/model/FulfillmentMode.java
new file mode 100644
index 0000000..89b8503
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/FulfillmentMode.java
@@ -0,0 +1,12 @@
+package apelet.common.ecommerce.model;
+
+/** 履约模式,对应四种业务模式中的 A / C / D;B(FBA/WFS 平台仓发货)只读,不在枚举内。 */
+public enum FulfillmentMode {
+
+ /** A:3P 卖家自发货 */
+ THIRD_PARTY_SHIP,
+ /** C:1P 代发(VC DF / 沃尔玛 DSV) */
+ VENDOR_DF_SHIP,
+ /** D:1P Retail 供应商供货 */
+ VENDOR_RETAIL_ASN
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/Money.java b/core/src/main/java/apelet/common/ecommerce/model/Money.java
new file mode 100644
index 0000000..ba0fb45
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/Money.java
@@ -0,0 +1,104 @@
+package apelet.common.ecommerce.model;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Currency;
+import java.util.Objects;
+
+/**
+ * 金额:{@link BigDecimal} + {@link Currency}。
+ *
+ * 禁止使用 double。保留平台原始精度与币种,不做隐式舍入。
+ */
+public final class Money {
+
+ private final BigDecimal amount;
+ private final Currency currency;
+
+ private Money(BigDecimal amount, Currency currency) {
+ this.amount = Objects.requireNonNull(amount, "amount");
+ this.currency = Objects.requireNonNull(currency, "currency");
+ }
+
+ public static Money of(BigDecimal amount, String currencyCode) {
+ return new Money(amount, Currency.getInstance(currencyCode));
+ }
+
+ public static Money of(String amount, String currencyCode) {
+ return of(new BigDecimal(amount), currencyCode);
+ }
+
+ public static Money zero(String currencyCode) {
+ return of(BigDecimal.ZERO, currencyCode);
+ }
+
+ public BigDecimal amount() {
+ return amount;
+ }
+
+ public Currency currency() {
+ return currency;
+ }
+
+ public String currencyCode() {
+ return currency.getCurrencyCode();
+ }
+
+ public Money add(Money other) {
+ requireSameCurrency(other);
+ return new Money(amount.add(other.amount), currency);
+ }
+
+ public Money subtract(Money other) {
+ requireSameCurrency(other);
+ return new Money(amount.subtract(other.amount), currency);
+ }
+
+ public Money multiply(BigDecimal factor) {
+ return new Money(amount.multiply(factor), currency);
+ }
+
+ /** 按币种最小单位舍入,仅用于展示或平台要求的定长格式,不用于内部累计。 */
+ public Money round() {
+ return new Money(amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP), currency);
+ }
+
+ public boolean isNegative() {
+ return amount.signum() < 0;
+ }
+
+ public int compareTo(Money other) {
+ requireSameCurrency(other);
+ return amount.compareTo(other.amount);
+ }
+
+ private void requireSameCurrency(Money other) {
+ Objects.requireNonNull(other, "other");
+ if (!currency.equals(other.currency)) {
+ throw new IllegalArgumentException(
+ "currency mismatch: " + currency.getCurrencyCode() + " vs " + other.currency.getCurrencyCode());
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Money)) {
+ return false;
+ }
+ Money that = (Money) o;
+ return amount.compareTo(that.amount) == 0 && currency.equals(that.currency);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(amount.stripTrailingZeros(), currency);
+ }
+
+ @Override
+ public String toString() {
+ return amount.toPlainString() + " " + currency.getCurrencyCode();
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/OrderLine.java b/core/src/main/java/apelet/common/ecommerce/model/OrderLine.java
new file mode 100644
index 0000000..dd4b68d
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/OrderLine.java
@@ -0,0 +1,97 @@
+package apelet.common.ecommerce.model;
+
+import java.util.Objects;
+
+/**
+ * 订单明细行。
+ */
+public final class OrderLine {
+
+ private final String lineId;
+ private final String platformSku;
+ private final int quantity;
+ private final Money unitPrice;
+ private final Money tax;
+ private final Money discount;
+
+ private OrderLine(Builder b) {
+ this.lineId = b.lineId;
+ this.platformSku = Objects.requireNonNull(b.platformSku, "platformSku");
+ this.quantity = b.quantity;
+ this.unitPrice = b.unitPrice;
+ this.tax = b.tax;
+ this.discount = b.discount;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public String lineId() {
+ return lineId;
+ }
+
+ public String platformSku() {
+ return platformSku;
+ }
+
+ public int quantity() {
+ return quantity;
+ }
+
+ public Money unitPrice() {
+ return unitPrice;
+ }
+
+ public Money tax() {
+ return tax;
+ }
+
+ public Money discount() {
+ return discount;
+ }
+
+ public static final class Builder {
+
+ private String lineId;
+ private String platformSku;
+ private int quantity;
+ private Money unitPrice;
+ private Money tax;
+ private Money discount;
+
+ public Builder lineId(String lineId) {
+ this.lineId = lineId;
+ return this;
+ }
+
+ public Builder platformSku(String platformSku) {
+ this.platformSku = platformSku;
+ return this;
+ }
+
+ public Builder quantity(int quantity) {
+ this.quantity = quantity;
+ return this;
+ }
+
+ public Builder unitPrice(Money unitPrice) {
+ this.unitPrice = unitPrice;
+ return this;
+ }
+
+ public Builder tax(Money tax) {
+ this.tax = tax;
+ return this;
+ }
+
+ public Builder discount(Money discount) {
+ this.discount = discount;
+ return this;
+ }
+
+ public OrderLine build() {
+ return new OrderLine(this);
+ }
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/Page.java b/core/src/main/java/apelet/common/ecommerce/model/Page.java
new file mode 100644
index 0000000..c793e45
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/Page.java
@@ -0,0 +1,52 @@
+package apelet.common.ecommerce.model;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 分页结果的通用载体。
+ *
+ * @param 条目类型
+ */
+public final class Page {
+
+ private final List items;
+ private final String nextCursor;
+ private final boolean hasMore;
+
+ private Page(List items, String nextCursor, boolean hasMore) {
+ this.items = Collections.unmodifiableList(new ArrayList(items));
+ this.nextCursor = nextCursor;
+ this.hasMore = hasMore;
+ }
+
+ public static Page of(List items, String nextCursor) {
+ return new Page(items, nextCursor, nextCursor != null && !nextCursor.isEmpty());
+ }
+
+ public static Page last(List items) {
+ return new Page(items, null, false);
+ }
+
+ public static Page empty() {
+ return new Page(Collections.emptyList(), null, false);
+ }
+
+ public List items() {
+ return items;
+ }
+
+ /** 下一页游标;null 表示结束。 */
+ public String nextCursor() {
+ return nextCursor;
+ }
+
+ public boolean hasMore() {
+ return hasMore;
+ }
+
+ public int size() {
+ return items.size();
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/Platform.java b/core/src/main/java/apelet/common/ecommerce/model/Platform.java
new file mode 100644
index 0000000..3439f04
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/Platform.java
@@ -0,0 +1,42 @@
+package apelet.common.ecommerce.model;
+
+/**
+ * 业务平台枚举。
+ *
+ * 不含 provider:直连与领星等聚合器都映射到同一个平台值,
+ * 保证业务主键(canonicalKey)在 provider 切换时不变。
+ */
+public enum Platform {
+
+ /** 亚马逊卖家平台(3P) */
+ AMAZON_SC,
+ /** 亚马逊供应商平台(1P) */
+ AMAZON_VC,
+ /** 沃尔玛 3P */
+ WALMART_SC,
+ /** 沃尔玛 1P */
+ WALMART_1P,
+ /** TikTok Shop */
+ TIKTOK,
+ /** Temu */
+ TEMU,
+ /** eBay(首期经领星接入) */
+ EBAY,
+ /** SHEIN(首期经领星接入) */
+ SHEIN,
+ /** Mirakl 市场 */
+ MIRAKL,
+ /** Target(领星平台码待确认) */
+ TARGET,
+ /** BestBuy(Mirakl/自定义,待确认) */
+ BESTBUY,
+ /** AEPH(平台码待确认) */
+ AEPH,
+ /** 自定义平台 */
+ CUSTOM;
+
+ /** 是否属于 1P(平台向供应商采购)语义。 */
+ public boolean isFirstParty() {
+ return this == AMAZON_VC || this == WALMART_1P;
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/PlatformTime.java b/core/src/main/java/apelet/common/ecommerce/model/PlatformTime.java
new file mode 100644
index 0000000..d140834
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/PlatformTime.java
@@ -0,0 +1,85 @@
+package apelet.common.ecommerce.model;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Objects;
+
+/**
+ * 三轴时间:UTC 是唯一真值,市场当地时间与北京时间是派生/物化视图。
+ *
+ * 夏令时一律通过 {@link ZoneId} 解析,禁止使用固定偏移量。
+ */
+public final class PlatformTime {
+
+ /** 北京时间固定时区。 */
+ public static final ZoneId BEIJING_ZONE = ZoneId.of("Asia/Shanghai");
+
+ private final Instant utc;
+ private final ZoneId marketplaceZone;
+
+ private PlatformTime(Instant utc, ZoneId marketplaceZone) {
+ this.utc = Objects.requireNonNull(utc, "utc");
+ this.marketplaceZone = Objects.requireNonNull(marketplaceZone, "marketplaceZone");
+ }
+
+ public static PlatformTime of(Instant utc, ZoneId marketplaceZone) {
+ return new PlatformTime(utc, marketplaceZone);
+ }
+
+ public static PlatformTime of(Instant utc, String marketplaceZone) {
+ return of(utc, ZoneId.of(marketplaceZone));
+ }
+
+ /** 以当前 UTC 时刻构造。 */
+ public static PlatformTime now(Clock clock, ZoneId marketplaceZone) {
+ return of(Instant.now(clock), marketplaceZone);
+ }
+
+ /** 存储与计算基准。 */
+ public Instant utc() {
+ return utc;
+ }
+
+ public ZoneId marketplaceZone() {
+ return marketplaceZone;
+ }
+
+ /** 平台/买家当地展示时间。 */
+ public ZonedDateTime marketplaceLocal() {
+ return utc.atZone(marketplaceZone);
+ }
+
+ /** 运营/财务/客服展示时间。 */
+ public ZonedDateTime beijing() {
+ return utc.atZone(BEIJING_ZONE);
+ }
+
+ /** 在市场当地时区上做偏移,返回新的三轴时间。 */
+ public PlatformTime plus(java.time.Duration duration) {
+ return new PlatformTime(utc.plus(duration), marketplaceZone);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof PlatformTime)) {
+ return false;
+ }
+ PlatformTime that = (PlatformTime) o;
+ return utc.equals(that.utc) && marketplaceZone.equals(that.marketplaceZone);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(utc, marketplaceZone);
+ }
+
+ @Override
+ public String toString() {
+ return utc + " (" + marketplaceZone + ")";
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/ProductListing.java b/core/src/main/java/apelet/common/ecommerce/model/ProductListing.java
new file mode 100644
index 0000000..be18252
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/ProductListing.java
@@ -0,0 +1,112 @@
+package apelet.common.ecommerce.model;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 统一商品 / Listing。
+ */
+public final class ProductListing {
+
+ private final String omsStoreId;
+ private final String platformSku;
+ private final String title;
+ private final List imageUrls;
+ private final String status;
+ private final PlatformTime updatedAt;
+ private final ChannelProvider sourceProvider;
+
+ private ProductListing(Builder b) {
+ this.omsStoreId = Objects.requireNonNull(b.omsStoreId, "omsStoreId");
+ this.platformSku = Objects.requireNonNull(b.platformSku, "platformSku");
+ this.title = b.title;
+ this.imageUrls = Collections.unmodifiableList(new ArrayList(b.imageUrls));
+ this.status = b.status;
+ this.updatedAt = b.updatedAt;
+ this.sourceProvider = b.sourceProvider;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public String omsStoreId() {
+ return omsStoreId;
+ }
+
+ public String platformSku() {
+ return platformSku;
+ }
+
+ public String title() {
+ return title;
+ }
+
+ public List imageUrls() {
+ return imageUrls;
+ }
+
+ public String status() {
+ return status;
+ }
+
+ public PlatformTime updatedAt() {
+ return updatedAt;
+ }
+
+ public ChannelProvider sourceProvider() {
+ return sourceProvider;
+ }
+
+ public static final class Builder {
+
+ private String omsStoreId;
+ private String platformSku;
+ private String title;
+ private List imageUrls = Collections.emptyList();
+ private String status;
+ private PlatformTime updatedAt;
+ private ChannelProvider sourceProvider;
+
+ public Builder omsStoreId(String omsStoreId) {
+ this.omsStoreId = omsStoreId;
+ return this;
+ }
+
+ public Builder platformSku(String platformSku) {
+ this.platformSku = platformSku;
+ return this;
+ }
+
+ public Builder title(String title) {
+ this.title = title;
+ return this;
+ }
+
+ public Builder imageUrls(List imageUrls) {
+ this.imageUrls = imageUrls == null ? Collections.emptyList() : imageUrls;
+ return this;
+ }
+
+ public Builder status(String status) {
+ this.status = status;
+ return this;
+ }
+
+ public Builder updatedAt(PlatformTime updatedAt) {
+ this.updatedAt = updatedAt;
+ return this;
+ }
+
+ public Builder sourceProvider(ChannelProvider sourceProvider) {
+ this.sourceProvider = sourceProvider;
+ return this;
+ }
+
+ public ProductListing build() {
+ return new ProductListing(this);
+ }
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/ProductQuery.java b/core/src/main/java/apelet/common/ecommerce/model/ProductQuery.java
new file mode 100644
index 0000000..a62cbef
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/ProductQuery.java
@@ -0,0 +1,74 @@
+package apelet.common.ecommerce.model;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * 商品查询条件。
+ */
+public final class ProductQuery {
+
+ private final Instant updatedAfter;
+ private final Instant updatedBefore;
+ private final int pageSize;
+ private final String cursor;
+
+ private ProductQuery(Builder b) {
+ this.updatedAfter = b.updatedAfter;
+ this.updatedBefore = b.updatedBefore;
+ this.pageSize = b.pageSize;
+ this.cursor = b.cursor;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public Instant updatedAfter() {
+ return updatedAfter;
+ }
+
+ public Instant updatedBefore() {
+ return updatedBefore;
+ }
+
+ public int pageSize() {
+ return pageSize;
+ }
+
+ public String cursor() {
+ return cursor;
+ }
+
+ public static final class Builder {
+
+ private Instant updatedAfter;
+ private Instant updatedBefore;
+ private int pageSize = 100;
+ private String cursor;
+
+ public Builder updatedAfter(Instant updatedAfter) {
+ this.updatedAfter = updatedAfter;
+ return this;
+ }
+
+ public Builder updatedBefore(Instant updatedBefore) {
+ this.updatedBefore = updatedBefore;
+ return this;
+ }
+
+ public Builder pageSize(int pageSize) {
+ this.pageSize = pageSize;
+ return this;
+ }
+
+ public Builder cursor(String cursor) {
+ this.cursor = cursor;
+ return this;
+ }
+
+ public ProductQuery build() {
+ return new ProductQuery(this);
+ }
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/ProductSyncStrategy.java b/core/src/main/java/apelet/common/ecommerce/model/ProductSyncStrategy.java
new file mode 100644
index 0000000..6409d33
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/ProductSyncStrategy.java
@@ -0,0 +1,12 @@
+package apelet.common.ecommerce.model;
+
+/** 商品同步策略,适配器声明自己支持哪一种。 */
+public enum ProductSyncStrategy {
+
+ /** 时间窗增量 */
+ INCREMENTAL_TIME_WINDOW,
+ /** 全量拉取 + 差异比对 */
+ FULL_PULL_DIFF,
+ /** 报告 / 文件 */
+ REPORT
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/SalesOrder.java b/core/src/main/java/apelet/common/ecommerce/model/SalesOrder.java
new file mode 100644
index 0000000..d38c1ae
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/SalesOrder.java
@@ -0,0 +1,151 @@
+package apelet.common.ecommerce.model;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 3P 销售订单。
+ *
+ * 统一带 {@code omsStoreId + sourceProvider + sourceAccountId},
+ * 用于跨 provider 去重与追溯。
+ */
+public final class SalesOrder {
+
+ private final String omsStoreId;
+ private final String channelOrderId;
+ private final String status;
+ private final String buyerName;
+ private final String shipToCountryCode;
+ private final List lines;
+ private final Money totalAmount;
+ private final PlatformTime occurredAt;
+ private final ChannelProvider sourceProvider;
+ private final String sourceAccountId;
+
+ private SalesOrder(Builder b) {
+ this.omsStoreId = Objects.requireNonNull(b.omsStoreId, "omsStoreId");
+ this.channelOrderId = Objects.requireNonNull(b.channelOrderId, "channelOrderId");
+ this.status = b.status;
+ this.buyerName = b.buyerName;
+ this.shipToCountryCode = b.shipToCountryCode;
+ this.lines = Collections.unmodifiableList(new ArrayList(b.lines));
+ this.totalAmount = b.totalAmount;
+ this.occurredAt = Objects.requireNonNull(b.occurredAt, "occurredAt");
+ this.sourceProvider = Objects.requireNonNull(b.sourceProvider, "sourceProvider");
+ this.sourceAccountId = b.sourceAccountId;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public String omsStoreId() {
+ return omsStoreId;
+ }
+
+ public String channelOrderId() {
+ return channelOrderId;
+ }
+
+ public String status() {
+ return status;
+ }
+
+ public String buyerName() {
+ return buyerName;
+ }
+
+ public String shipToCountryCode() {
+ return shipToCountryCode;
+ }
+
+ public List lines() {
+ return lines;
+ }
+
+ public Money totalAmount() {
+ return totalAmount;
+ }
+
+ public PlatformTime occurredAt() {
+ return occurredAt;
+ }
+
+ public ChannelProvider sourceProvider() {
+ return sourceProvider;
+ }
+
+ public String sourceAccountId() {
+ return sourceAccountId;
+ }
+
+ public static final class Builder {
+
+ private String omsStoreId;
+ private String channelOrderId;
+ private String status;
+ private String buyerName;
+ private String shipToCountryCode;
+ private List lines = Collections.emptyList();
+ private Money totalAmount;
+ private PlatformTime occurredAt;
+ private ChannelProvider sourceProvider;
+ private String sourceAccountId;
+
+ public Builder omsStoreId(String omsStoreId) {
+ this.omsStoreId = omsStoreId;
+ return this;
+ }
+
+ public Builder channelOrderId(String channelOrderId) {
+ this.channelOrderId = channelOrderId;
+ return this;
+ }
+
+ public Builder status(String status) {
+ this.status = status;
+ return this;
+ }
+
+ public Builder buyerName(String buyerName) {
+ this.buyerName = buyerName;
+ return this;
+ }
+
+ public Builder shipToCountryCode(String shipToCountryCode) {
+ this.shipToCountryCode = shipToCountryCode;
+ return this;
+ }
+
+ public Builder lines(List lines) {
+ this.lines = lines == null ? Collections.emptyList() : lines;
+ return this;
+ }
+
+ public Builder totalAmount(Money totalAmount) {
+ this.totalAmount = totalAmount;
+ return this;
+ }
+
+ public Builder occurredAt(PlatformTime occurredAt) {
+ this.occurredAt = occurredAt;
+ return this;
+ }
+
+ public Builder sourceProvider(ChannelProvider sourceProvider) {
+ this.sourceProvider = sourceProvider;
+ return this;
+ }
+
+ public Builder sourceAccountId(String sourceAccountId) {
+ this.sourceAccountId = sourceAccountId;
+ return this;
+ }
+
+ public SalesOrder build() {
+ return new SalesOrder(this);
+ }
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/model/SalesOrderQuery.java b/core/src/main/java/apelet/common/ecommerce/model/SalesOrderQuery.java
new file mode 100644
index 0000000..dd7ec9e
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/model/SalesOrderQuery.java
@@ -0,0 +1,82 @@
+package apelet.common.ecommerce.model;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * 销售订单查询条件(单次查询,不推进水位线)。
+ */
+public final class SalesOrderQuery {
+
+ private final Instant updatedAfter;
+ private final Instant updatedBefore;
+ private final int pageSize;
+ private final String cursor;
+
+ private SalesOrderQuery(Builder b) {
+ this.updatedAfter = Objects.requireNonNull(b.updatedAfter, "updatedAfter");
+ this.updatedBefore = b.updatedBefore;
+ this.pageSize = b.pageSize;
+ this.cursor = b.cursor;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public Instant updatedAfter() {
+ return updatedAfter;
+ }
+
+ public Instant updatedBefore() {
+ return updatedBefore;
+ }
+
+ public int pageSize() {
+ return pageSize;
+ }
+
+ public String cursor() {
+ return cursor;
+ }
+
+ public Builder toBuilder() {
+ return builder()
+ .updatedAfter(updatedAfter)
+ .updatedBefore(updatedBefore)
+ .pageSize(pageSize)
+ .cursor(cursor);
+ }
+
+ public static final class Builder {
+
+ private Instant updatedAfter;
+ private Instant updatedBefore;
+ private int pageSize = 100;
+ private String cursor;
+
+ public Builder updatedAfter(Instant updatedAfter) {
+ this.updatedAfter = updatedAfter;
+ return this;
+ }
+
+ public Builder updatedBefore(Instant updatedBefore) {
+ this.updatedBefore = updatedBefore;
+ return this;
+ }
+
+ public Builder pageSize(int pageSize) {
+ this.pageSize = pageSize;
+ return this;
+ }
+
+ public Builder cursor(String cursor) {
+ this.cursor = cursor;
+ return this;
+ }
+
+ public SalesOrderQuery build() {
+ return new SalesOrderQuery(this);
+ }
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/sdk/AdapterFactory.java b/core/src/main/java/apelet/common/ecommerce/sdk/AdapterFactory.java
new file mode 100644
index 0000000..ebf92dc
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/sdk/AdapterFactory.java
@@ -0,0 +1,47 @@
+package apelet.common.ecommerce.sdk;
+
+import apelet.common.ecommerce.capability.PlatformAdapter;
+import apelet.common.ecommerce.model.AccountKey;
+import apelet.common.ecommerce.model.ChannelProvider;
+import apelet.common.ecommerce.model.Platform;
+
+import java.util.EnumSet;
+
+/**
+ * 适配器工厂。直连与聚合器统一注册,SDK 按 AccountKey 的 provider 解析。
+ */
+public interface AdapterFactory {
+
+ String adapterId();
+
+ ChannelProvider provider();
+
+ EnumSet platforms();
+
+ PlatformAdapter create(AdapterConfig config);
+
+ /** 适配器实例化所需的最小配置;凭证与账户信息在调用时经 ChannelContext 传入。 */
+ final class AdapterConfig {
+
+ private final String endpointBaseUrl;
+ private final boolean sandbox;
+
+ public AdapterConfig(String endpointBaseUrl, boolean sandbox) {
+ this.endpointBaseUrl = endpointBaseUrl;
+ this.sandbox = sandbox;
+ }
+
+ public String endpointBaseUrl() {
+ return endpointBaseUrl;
+ }
+
+ public boolean sandbox() {
+ return sandbox;
+ }
+ }
+
+ /** 兜底:由平台推导默认 adapterId,便于日志定位。 */
+ static String defaultAdapterId(AccountKey key) {
+ return key.platform().name().toLowerCase() + "-" + key.provider().name().toLowerCase();
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/sdk/ChannelContext.java b/core/src/main/java/apelet/common/ecommerce/sdk/ChannelContext.java
new file mode 100644
index 0000000..d8582fa
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/sdk/ChannelContext.java
@@ -0,0 +1,48 @@
+package apelet.common.ecommerce.sdk;
+
+import apelet.common.ecommerce.model.ChannelAccount;
+import apelet.common.ecommerce.model.Platform;
+
+import java.time.Clock;
+
+/**
+ * 单次调用的上下文。由 SDK 统一创建并注入适配器,适配器不得自行构造。
+ */
+public final class ChannelContext {
+
+ private final ChannelAccount account;
+ private final String requestId;
+ private final Clock clock;
+
+ private ChannelContext(ChannelAccount account, String requestId, Clock clock) {
+ this.account = account;
+ this.requestId = requestId;
+ this.clock = clock;
+ }
+
+ public static ChannelContext of(ChannelAccount account, String requestId, Clock clock) {
+ return new ChannelContext(account, requestId, clock);
+ }
+
+ public ChannelAccount account() {
+ return account;
+ }
+
+ public String omsStoreId() {
+ return account.omsStoreId();
+ }
+
+ public Platform platform() {
+ return account.platform();
+ }
+
+ /** 链路 ID,用于日志与原始报文关联。 */
+ public String requestId() {
+ return requestId;
+ }
+
+ /** UTC 时钟;测试可注入固定时间。 */
+ public Clock clock() {
+ return clock;
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/sdk/ChannelSdk.java b/core/src/main/java/apelet/common/ecommerce/sdk/ChannelSdk.java
new file mode 100644
index 0000000..93aa220
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/sdk/ChannelSdk.java
@@ -0,0 +1,19 @@
+package apelet.common.ecommerce.sdk;
+
+/** 渠道 SDK 门面(骨架,逐个能力补齐)。 */
+public final class ChannelSdk {
+
+ private ChannelSdk() {
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+
+ public ChannelSdk build() {
+ return new ChannelSdk();
+ }
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/spi/AccessToken.java b/core/src/main/java/apelet/common/ecommerce/spi/AccessToken.java
new file mode 100644
index 0000000..ee0aa91
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/spi/AccessToken.java
@@ -0,0 +1,84 @@
+package apelet.common.ecommerce.spi;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * 访问令牌。
+ */
+public final class AccessToken {
+
+ private final String value;
+ private final String refreshToken;
+ private final Instant expiresAt;
+ private final String tokenType;
+
+ private AccessToken(Builder b) {
+ this.value = Objects.requireNonNull(b.value, "value");
+ this.refreshToken = b.refreshToken;
+ this.expiresAt = Objects.requireNonNull(b.expiresAt, "expiresAt");
+ this.tokenType = b.tokenType == null ? "Bearer" : b.tokenType;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public String value() {
+ return value;
+ }
+
+ public String refreshToken() {
+ return refreshToken;
+ }
+
+ public Instant expiresAt() {
+ return expiresAt;
+ }
+
+ public String tokenType() {
+ return tokenType;
+ }
+
+ /** 是否已过期或将在 {@code earlyRefreshSeconds} 内过期。 */
+ public boolean needsRefresh(Instant now, long earlyRefreshSeconds) {
+ return !now.plusSeconds(earlyRefreshSeconds).isBefore(expiresAt);
+ }
+
+ /** 日志用指纹,不打印令牌本体。 */
+ public String fingerprint() {
+ return "token#" + Integer.toHexString(value.hashCode());
+ }
+
+ public static final class Builder {
+
+ private String value;
+ private String refreshToken;
+ private Instant expiresAt;
+ private String tokenType;
+
+ public Builder value(String value) {
+ this.value = value;
+ return this;
+ }
+
+ public Builder refreshToken(String refreshToken) {
+ this.refreshToken = refreshToken;
+ return this;
+ }
+
+ public Builder expiresAt(Instant expiresAt) {
+ this.expiresAt = expiresAt;
+ return this;
+ }
+
+ public Builder tokenType(String tokenType) {
+ this.tokenType = tokenType;
+ return this;
+ }
+
+ public AccessToken build() {
+ return new AccessToken(this);
+ }
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/spi/ChannelAccountRepository.java b/core/src/main/java/apelet/common/ecommerce/spi/ChannelAccountRepository.java
new file mode 100644
index 0000000..f5080ba
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/spi/ChannelAccountRepository.java
@@ -0,0 +1,16 @@
+package apelet.common.ecommerce.spi;
+
+import apelet.common.ecommerce.model.ChannelAccount;
+
+import java.util.Optional;
+
+/**
+ * 渠道账户读取 SPI。由宿主实现(典型 MySQL)。
+ */
+public interface ChannelAccountRepository {
+
+ Optional findByKey(String provider, String platform, String omsStoreId);
+
+ /** 同一 omsStoreId 下所有 provider 账户。 */
+ java.util.List findByOmsStoreId(String omsStoreId);
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/spi/ChannelSecret.java b/core/src/main/java/apelet/common/ecommerce/spi/ChannelSecret.java
new file mode 100644
index 0000000..0957020
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/spi/ChannelSecret.java
@@ -0,0 +1,91 @@
+package apelet.common.ecommerce.spi;
+
+import java.util.Objects;
+
+/**
+ * 凭证内容。按 provider 需要取用,未使用的字段为 null。
+ */
+public final class ChannelSecret {
+
+ private final String appKey;
+ private final String appSecret;
+ private final String clientId;
+ private final String clientSecret;
+ private final String refreshToken;
+
+ private ChannelSecret(Builder b) {
+ this.appKey = b.appKey;
+ this.appSecret = b.appSecret;
+ this.clientId = b.clientId;
+ this.clientSecret = b.clientSecret;
+ this.refreshToken = b.refreshToken;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public String appKey() {
+ return appKey;
+ }
+
+ public String appSecret() {
+ return appSecret;
+ }
+
+ public String clientId() {
+ return clientId;
+ }
+
+ public String clientSecret() {
+ return clientSecret;
+ }
+
+ public String refreshToken() {
+ return refreshToken;
+ }
+
+ /** 用于日志的安全指纹,不泄露内容。 */
+ public String fingerprint() {
+ String seed = String.valueOf(Objects.hash(appKey, clientId));
+ return "secret#" + Integer.toHexString(seed.hashCode());
+ }
+
+ public static final class Builder {
+
+ private String appKey;
+ private String appSecret;
+ private String clientId;
+ private String clientSecret;
+ private String refreshToken;
+
+ public Builder appKey(String appKey) {
+ this.appKey = appKey;
+ return this;
+ }
+
+ public Builder appSecret(String appSecret) {
+ this.appSecret = appSecret;
+ return this;
+ }
+
+ public Builder clientId(String clientId) {
+ this.clientId = clientId;
+ return this;
+ }
+
+ public Builder clientSecret(String clientSecret) {
+ this.clientSecret = clientSecret;
+ return this;
+ }
+
+ public Builder refreshToken(String refreshToken) {
+ this.refreshToken = refreshToken;
+ return this;
+ }
+
+ public ChannelSecret build() {
+ return new ChannelSecret(this);
+ }
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/spi/LockProvider.java b/core/src/main/java/apelet/common/ecommerce/spi/LockProvider.java
new file mode 100644
index 0000000..10e31d3
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/spi/LockProvider.java
@@ -0,0 +1,24 @@
+package apelet.common.ecommerce.spi;
+
+import java.util.Optional;
+
+/**
+ * 锁 SPI。用于 token 刷新防击穿、账户级同步互斥。
+ */
+public interface LockProvider {
+
+ /**
+ * 尝试获取锁,不阻塞。
+ *
+ * @param key 锁键
+ * @param ttlSeconds 锁自动过期时间
+ * @return 锁句柄;未获取到返回 empty
+ */
+ Optional tryLock(String key, long ttlSeconds);
+
+ interface Lock extends AutoCloseable {
+
+ @Override
+ void close();
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/spi/SecretProvider.java b/core/src/main/java/apelet/common/ecommerce/spi/SecretProvider.java
new file mode 100644
index 0000000..c3cf0c1
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/spi/SecretProvider.java
@@ -0,0 +1,14 @@
+package apelet.common.ecommerce.spi;
+
+/**
+ * 凭证读取 SPI。{@code credentialRef} 由 {@code ChannelAccount} 提供。
+ *
+ * 真实密钥只在内存中出现,禁止落配置、禁止写日志。
+ */
+public interface SecretProvider {
+
+ /**
+ * @param credentialRef 形如 {@code kms://oms/lingxing/store-001}
+ */
+ ChannelSecret get(String credentialRef);
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/spi/Sink.java b/core/src/main/java/apelet/common/ecommerce/spi/Sink.java
new file mode 100644
index 0000000..33170db
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/spi/Sink.java
@@ -0,0 +1,18 @@
+package apelet.common.ecommerce.spi;
+
+import apelet.common.ecommerce.model.Page;
+import apelet.common.ecommerce.model.ProductListing;
+import apelet.common.ecommerce.model.SalesOrder;
+
+/**
+ * 领域对象落库 SPI。SDK 只调用,不碰数据库。
+ *
+ * 实现方必须按 canonicalKey 做 upsert:同一实体从直连与聚合器同步时
+ * 不得产生两条记录。
+ */
+public interface Sink {
+
+ void upsertSalesOrders(Page page);
+
+ void upsertProducts(Page page);
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/spi/SyncCursorStore.java b/core/src/main/java/apelet/common/ecommerce/spi/SyncCursorStore.java
new file mode 100644
index 0000000..306637e
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/spi/SyncCursorStore.java
@@ -0,0 +1,18 @@
+package apelet.common.ecommerce.spi;
+
+import apelet.common.ecommerce.model.ChannelProvider;
+
+import java.time.Instant;
+
+/**
+ * 增量水位线 SPI。
+ *
+ * 按 {@code provider + account + stream} 分开维护;只在整个批次成功后推进。
+ */
+public interface SyncCursorStore {
+
+ /** 返回 null 表示还没有水位线,需要首次全量建底。 */
+ Instant get(ChannelProvider provider, String accountKey, String stream);
+
+ void advance(ChannelProvider provider, String accountKey, String stream, Instant watermark);
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/spi/TokenStore.java b/core/src/main/java/apelet/common/ecommerce/spi/TokenStore.java
new file mode 100644
index 0000000..f4e7872
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/spi/TokenStore.java
@@ -0,0 +1,15 @@
+package apelet.common.ecommerce.spi;
+
+import java.util.Optional;
+
+/**
+ * 访问令牌读取/写入 SPI。宿主负责加密存储。
+ */
+public interface TokenStore {
+
+ Optional get(String tokenKey);
+
+ void save(String tokenKey, AccessToken token);
+
+ void remove(String tokenKey);
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/sync/SyncEngine.java b/core/src/main/java/apelet/common/ecommerce/sync/SyncEngine.java
new file mode 100644
index 0000000..b574df9
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/sync/SyncEngine.java
@@ -0,0 +1,77 @@
+package apelet.common.ecommerce.sync;
+
+import apelet.common.ecommerce.model.Page;
+import apelet.common.ecommerce.model.SalesOrder;
+import apelet.common.ecommerce.model.SalesOrderQuery;
+import apelet.common.ecommerce.spi.Sink;
+import apelet.common.ecommerce.spi.SyncCursorStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.function.BiFunction;
+
+/**
+ * 增量同步引擎。
+ *
+ *
+ * from = lastWatermark - overlapWindow
+ * to = now
+ * loop 拉一页 → 经 Sink upsert → cursor = page.nextCursor
+ * 直到 cursor == null
+ * 全部成功才推进水位线,失败不推进
+ *
+ *
+ * 引擎不持有线程或定时器,由宿主调度触发。
+ */
+public final class SyncEngine {
+
+ private static final Logger LOG = LoggerFactory.getLogger(SyncEngine.class);
+
+ private final SyncCursorStore cursorStore;
+ private final Sink sink;
+
+ public SyncEngine(SyncCursorStore cursorStore, Sink sink) {
+ this.cursorStore = cursorStore;
+ this.sink = sink;
+ }
+
+ /**
+ * 同步销售订单。
+ *
+ * @param spec 同步维度(provider + account + stream + overlap)
+ * @param to 本次增量终点
+ * @param fetch 拉一页的函数:(from, cursor) → page
+ */
+ public SyncResult syncSalesOrders(SyncSpec spec, Instant to,
+ BiFunction> fetch) {
+ Instant watermark = cursorStore.get(spec.provider(), spec.accountKey(), spec.stream());
+ Instant from = spec.from(watermark);
+ if (from == null) {
+ throw new IllegalStateException("no watermark for " + spec.accountKey()
+ + "/" + spec.stream() + ", run full bootstrap first");
+ }
+
+ int pages = 0;
+ int items = 0;
+ String cursor = null;
+ // 首页需要 from,后续页沿用同一 from(时间窗固定,靠游标翻页)
+ while (true) {
+ Page page = fetch.apply(from, cursor);
+ if (page == null || page.size() == 0) {
+ break;
+ }
+ sink.upsertSalesOrders(page);
+ pages++;
+ items += page.size();
+ cursor = page.nextCursor();
+ if (cursor == null) {
+ break;
+ }
+ }
+
+ cursorStore.advance(spec.provider(), spec.accountKey(), spec.stream(), to);
+ LOG.debug("sync {} pages={} items={} from={} to={}", spec.stream(), pages, items, from, to);
+ return SyncResult.of(pages, items, true);
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/sync/SyncResult.java b/core/src/main/java/apelet/common/ecommerce/sync/SyncResult.java
new file mode 100644
index 0000000..fd3ed72
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/sync/SyncResult.java
@@ -0,0 +1,43 @@
+package apelet.common.ecommerce.sync;
+
+/**
+ * 单次同步结果。
+ */
+public final class SyncResult {
+
+ private final int pages;
+ private final int items;
+ private final boolean watermarkAdvanced;
+
+ private SyncResult(int pages, int items, boolean watermarkAdvanced) {
+ this.pages = pages;
+ this.items = items;
+ this.watermarkAdvanced = watermarkAdvanced;
+ }
+
+ public static SyncResult of(int pages, int items, boolean watermarkAdvanced) {
+ return new SyncResult(pages, items, watermarkAdvanced);
+ }
+
+ public static SyncResult empty() {
+ return new SyncResult(0, 0, false);
+ }
+
+ public int pages() {
+ return pages;
+ }
+
+ public int items() {
+ return items;
+ }
+
+ /** 只有全部页成功才为 true。 */
+ public boolean watermarkAdvanced() {
+ return watermarkAdvanced;
+ }
+
+ @Override
+ public String toString() {
+ return "SyncResult{pages=" + pages + ", items=" + items + ", watermarkAdvanced=" + watermarkAdvanced + '}';
+ }
+}
diff --git a/core/src/main/java/apelet/common/ecommerce/sync/SyncSpec.java b/core/src/main/java/apelet/common/ecommerce/sync/SyncSpec.java
new file mode 100644
index 0000000..561e4aa
--- /dev/null
+++ b/core/src/main/java/apelet/common/ecommerce/sync/SyncSpec.java
@@ -0,0 +1,59 @@
+package apelet.common.ecommerce.sync;
+
+import apelet.common.ecommerce.model.ChannelProvider;
+
+import java.time.Duration;
+import java.time.Instant;
+
+/**
+ * 同步流的执行配置。
+ */
+public final class SyncSpec {
+
+ /** 订单流标识,用于水位线维度。 */
+ public static final String STREAM_SALES_ORDERS = "salesOrders";
+ /** 商品流标识。 */
+ public static final String STREAM_PRODUCTS = "products";
+
+ private final ChannelProvider provider;
+ private final String accountKey;
+ private final String stream;
+ private final Duration overlap;
+
+ private SyncSpec(ChannelProvider provider, String accountKey, String stream, Duration overlap) {
+ this.provider = provider;
+ this.accountKey = accountKey;
+ this.stream = stream;
+ this.overlap = overlap;
+ }
+
+ public static SyncSpec of(ChannelProvider provider, String accountKey, String stream, Duration overlap) {
+ return new SyncSpec(provider, accountKey, stream, overlap);
+ }
+
+ public ChannelProvider provider() {
+ return provider;
+ }
+
+ public String accountKey() {
+ return accountKey;
+ }
+
+ public String stream() {
+ return stream;
+ }
+
+ /** 上次水位线回退的重叠窗口,防边界丢单。 */
+ public Duration overlap() {
+ return overlap;
+ }
+
+ /**
+ * 计算增量起点。
+ *
+ * 没有水位线时返回 null,由调用方走首次全量。
+ */
+ public Instant from(Instant watermark) {
+ return watermark == null ? null : watermark.minus(overlap);
+ }
+}
diff --git a/core/src/test/java/apelet/common/ecommerce/model/CanonicalKeysTest.java b/core/src/test/java/apelet/common/ecommerce/model/CanonicalKeysTest.java
new file mode 100644
index 0000000..925b5fd
--- /dev/null
+++ b/core/src/test/java/apelet/common/ecommerce/model/CanonicalKeysTest.java
@@ -0,0 +1,77 @@
+package apelet.common.ecommerce.model;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.ZoneId;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class CanonicalKeysTest {
+
+ @Test
+ void canonicalKeyMustNotContainProvider() {
+ // 直连和领星拉到同一订单,canonicalKey 必须相同
+ String direct = CanonicalKeys.salesOrder(Platform.WALMART_SC, "oms-wm-001", "PO-123");
+ String lingxing = CanonicalKeys.salesOrder(Platform.WALMART_SC, "oms-wm-001", "PO-123");
+ assertEquals(direct, lingxing);
+ assertEquals("WALMART_SC:oms-wm-001:PO-123", direct);
+ }
+
+ @Test
+ void sourceKeyIncludesProvider() {
+ String direct = CanonicalKeys.source(ChannelProvider.DIRECT, Platform.WALMART_SC, "wm-seller-01", "PO-123");
+ String lingxing = CanonicalKeys.source(ChannelProvider.LINGXING, Platform.WALMART_SC, "1108", "PO-123");
+ assertNotEquals(direct, lingxing);
+ }
+
+ @Test
+ void statementFingerprintIgnoresAmount() {
+ String a = CanonicalKeys.statementFingerprint("2026-09-01", "2026-09-30", "USD", "STMT-1");
+ String b = CanonicalKeys.statementFingerprint("2026-09-01", "2026-09-30", "USD", "STMT-1");
+ assertEquals(a, b);
+ assertNotEquals(a, CanonicalKeys.statementFingerprint("2026-09-01", "2026-09-30", "USD", "STMT-2"));
+ }
+
+ @Test
+ void businessKeyDiffersFromProviderKey() {
+ String business = CanonicalKeys.businessIdempotencyKey(Platform.WALMART_SC, "oms-wm-001", "PO-1:pkg-1", "shipping");
+ String providerKey = CanonicalKeys.providerRequestKey(ChannelProvider.DIRECT, Platform.WALMART_SC,
+ "wm-seller-01", "shipping", "PO-1:pkg-1");
+ assertNotEquals(business, providerKey);
+ }
+
+ @Test
+ void blankIdRejected() {
+ assertThrows(IllegalArgumentException.class,
+ () -> CanonicalKeys.canonical(Platform.TEMU, " ", "order-1"));
+ }
+
+ @Test
+ void platformTimeKeepsThreeAxes() {
+ Instant utc = Instant.parse("2026-10-29T02:00:00Z");
+ PlatformTime t = PlatformTime.of(utc, ZoneId.of("America/Los_Angeles"));
+ assertEquals(utc, t.utc());
+ assertEquals(19, t.marketplaceLocal().getHour()); // 前一天 19:00 PDT
+ assertEquals(10, t.beijing().getHour());
+ }
+
+ @Test
+ void dstHandledByZoneId() {
+ // 2026-03-08 美国太平洋时区进入夏令时
+ PlatformTime before = PlatformTime.of(Instant.parse("2026-03-08T09:59:00Z"), "America/Los_Angeles");
+ PlatformTime after = PlatformTime.of(Instant.parse("2026-03-08T10:00:00Z"), "America/Los_Angeles");
+ assertEquals(1, before.marketplaceLocal().getHour()); // PST UTC-8
+ assertEquals(3, after.marketplaceLocal().getHour()); // PDT UTC-7
+ }
+
+ @Test
+ void moneyRejectsCurrencyMix() {
+ Money usd = Money.of("10.00", "USD");
+ Money cny = Money.of("10.00", "CNY");
+ assertThrows(IllegalArgumentException.class, () -> usd.add(cny));
+ assertEquals("15.00", usd.add(Money.of("5.00", "USD")).amount().toPlainString());
+ }
+}