diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5376bb2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto eol=lf +*.java text eol=lf +*.xml text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf diff --git a/core/src/main/java/apelet/common/ecommerce/capability/AggregatorCapable.java b/core/src/main/java/apelet/common/ecommerce/capability/AggregatorCapable.java new file mode 100644 index 0000000..44bd285 --- /dev/null +++ b/core/src/main/java/apelet/common/ecommerce/capability/AggregatorCapable.java @@ -0,0 +1,54 @@ +package apelet.common.ecommerce.capability; + +import apelet.common.ecommerce.model.Capability; +import apelet.common.ecommerce.model.ChannelAccount; +import apelet.common.ecommerce.model.Platform; + +import java.util.List; + +/** + * 聚合器扩展:列出子渠道(如领星店铺/平台码)。 + * + *

子渠道由 {@code ChannelAccount.platformCode} + {@code externalStoreId} 配置, + * 不新增平台模块。

+ */ +public interface AggregatorCapable extends PlatformAdapter { + + List listSubChannels(ChannelAccount account); + + /** 聚合器通过子平台的 platform_code 细化能力。 */ + @Override + boolean supports(ChannelAccount account, Capability capability); + + /** 子渠道。 */ + final class SubChannel { + + private final int platformCode; + private final String externalStoreId; + private final Platform platform; + private final String name; + + public SubChannel(int platformCode, String externalStoreId, Platform platform, String name) { + this.platformCode = platformCode; + this.externalStoreId = externalStoreId; + this.platform = platform; + this.name = name; + } + + public int platformCode() { + return platformCode; + } + + public String externalStoreId() { + return externalStoreId; + } + + public Platform platform() { + return platform; + } + + public String name() { + return name; + } + } +} diff --git a/core/src/main/java/apelet/common/ecommerce/capability/PlatformAdapter.java b/core/src/main/java/apelet/common/ecommerce/capability/PlatformAdapter.java new file mode 100644 index 0000000..28f498c --- /dev/null +++ b/core/src/main/java/apelet/common/ecommerce/capability/PlatformAdapter.java @@ -0,0 +1,36 @@ +package apelet.common.ecommerce.capability; + +import apelet.common.ecommerce.model.Capability; +import apelet.common.ecommerce.model.ChannelAccount; +import apelet.common.ecommerce.model.ChannelProvider; +import apelet.common.ecommerce.model.Platform; + +import java.util.EnumSet; +import java.util.Set; + +/** + * 平台适配器基座。直连适配器与聚合器都实现它。 + */ +public interface PlatformAdapter { + + /** 适配器标识,如 amazon-sc / lingxing。 */ + String adapterId(); + + ChannelProvider provider(); + + /** 该适配器覆盖的平台。 */ + Set platforms(); + + /** 适配器能力并集。 */ + EnumSet capabilities(); + + /** + * 按账户细化能力。 + * + *

直连适配器默认使用 {@link #capabilities()};聚合器按账户的子平台覆盖, + * 例如领星对 Walmart 支持标发、对 eBay 只支持订单。

+ */ + default boolean supports(ChannelAccount account, Capability capability) { + return capabilities().contains(capability); + } +} diff --git a/core/src/main/java/apelet/common/ecommerce/capability/ProductCapable.java b/core/src/main/java/apelet/common/ecommerce/capability/ProductCapable.java new file mode 100644 index 0000000..31d6871 --- /dev/null +++ b/core/src/main/java/apelet/common/ecommerce/capability/ProductCapable.java @@ -0,0 +1,25 @@ +package apelet.common.ecommerce.capability; + +import apelet.common.ecommerce.model.Capability; +import apelet.common.ecommerce.model.Page; +import apelet.common.ecommerce.model.ProductListing; +import apelet.common.ecommerce.model.ProductQuery; +import apelet.common.ecommerce.model.ProductSyncStrategy; +import apelet.common.ecommerce.sdk.ChannelContext; + +/** + * 商品 / Listing 能力。 + */ +public interface ProductCapable extends PlatformAdapter { + + /** 适配器声明自己支持哪一种同步策略。 */ + ProductSyncStrategy syncStrategy(); + + Page pull(ChannelContext ctx, ProductQuery query); + + ProductListing get(ChannelContext ctx, String platformSku); + + default Capability requiredCapability() { + return Capability.PRODUCT; + } +} diff --git a/core/src/main/java/apelet/common/ecommerce/capability/SalesOrderCapable.java b/core/src/main/java/apelet/common/ecommerce/capability/SalesOrderCapable.java new file mode 100644 index 0000000..eb20624 --- /dev/null +++ b/core/src/main/java/apelet/common/ecommerce/capability/SalesOrderCapable.java @@ -0,0 +1,29 @@ +package apelet.common.ecommerce.capability; + +import apelet.common.ecommerce.error.ChannelException; +import apelet.common.ecommerce.model.SalesOrder; +import apelet.common.ecommerce.model.SalesOrderQuery; +import apelet.common.ecommerce.model.Page; +import apelet.common.ecommerce.model.Capability; +import apelet.common.ecommerce.sdk.ChannelContext; + +/** + * 3P 销售订单能力:消费者订单。 + */ +public interface SalesOrderCapable extends PlatformAdapter { + + /** 分页拉取。时间窗与游标由适配器解释。 */ + Page 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()); + } +}