Browse Source

feat(core): 领域模型、能力接口、SPI 与同步引擎骨架

- 标识体系:AccountKey(provider+platform+omsStoreId)、CanonicalKeys
  三级标识(canonicalKey 不含 provider)与四类指纹
- 模型:Platform/ChannelProvider/Capability/FulfillmentMode、
  Money(BigDecimal+Currency)、PlatformTime 三轴、SalesOrder/OrderLine/
  ProductListing/Page/Query
- 能力接口:PlatformAdapter、AggregatorCapable、SalesOrderCapable、
  ProductCapable
- 异常:ChannelException 基类 + Auth/RateLimit/Transient/Validation/
  PlatformBusiness/IdempotencyConflict/CrossProviderConflict/Unsupported
- SPI:ChannelAccountRepository、SecretProvider、TokenStore、
  SyncCursorStore、Sink、LockProvider(均只定义,宿主实现)
- SyncEngine:from = watermark - overlap,整批成功才推进水位线

Co-Authored-By: Claude <noreply@anthropic.com>
master
chenchuchuan 19 hours ago
parent
commit
a6dc2d2691
  1. 7
      .gitattributes
  2. 54
      core/src/main/java/apelet/common/ecommerce/capability/AggregatorCapable.java
  3. 36
      core/src/main/java/apelet/common/ecommerce/capability/PlatformAdapter.java
  4. 25
      core/src/main/java/apelet/common/ecommerce/capability/ProductCapable.java
  5. 29
      core/src/main/java/apelet/common/ecommerce/capability/SalesOrderCapable.java
  6. 17
      core/src/main/java/apelet/common/ecommerce/error/AuthException.java
  7. 154
      core/src/main/java/apelet/common/ecommerce/error/ChannelException.java
  8. 22
      core/src/main/java/apelet/common/ecommerce/error/CrossProviderConflictException.java
  9. 18
      core/src/main/java/apelet/common/ecommerce/error/IdempotencyConflictException.java
  10. 13
      core/src/main/java/apelet/common/ecommerce/error/PlatformBusinessException.java
  11. 20
      core/src/main/java/apelet/common/ecommerce/error/RateLimitException.java
  12. 13
      core/src/main/java/apelet/common/ecommerce/error/TransientException.java
  13. 17
      core/src/main/java/apelet/common/ecommerce/error/ValidationException.java
  14. 65
      core/src/main/java/apelet/common/ecommerce/model/AccountKey.java
  15. 112
      core/src/main/java/apelet/common/ecommerce/model/CanonicalKeys.java
  16. 45
      core/src/main/java/apelet/common/ecommerce/model/Capability.java
  17. 167
      core/src/main/java/apelet/common/ecommerce/model/ChannelAccount.java
  18. 23
      core/src/main/java/apelet/common/ecommerce/model/ChannelProvider.java
  19. 12
      core/src/main/java/apelet/common/ecommerce/model/FulfillmentMode.java
  20. 104
      core/src/main/java/apelet/common/ecommerce/model/Money.java
  21. 97
      core/src/main/java/apelet/common/ecommerce/model/OrderLine.java
  22. 52
      core/src/main/java/apelet/common/ecommerce/model/Page.java
  23. 42
      core/src/main/java/apelet/common/ecommerce/model/Platform.java
  24. 85
      core/src/main/java/apelet/common/ecommerce/model/PlatformTime.java
  25. 112
      core/src/main/java/apelet/common/ecommerce/model/ProductListing.java
  26. 74
      core/src/main/java/apelet/common/ecommerce/model/ProductQuery.java
  27. 12
      core/src/main/java/apelet/common/ecommerce/model/ProductSyncStrategy.java
  28. 151
      core/src/main/java/apelet/common/ecommerce/model/SalesOrder.java
  29. 82
      core/src/main/java/apelet/common/ecommerce/model/SalesOrderQuery.java
  30. 47
      core/src/main/java/apelet/common/ecommerce/sdk/AdapterFactory.java
  31. 48
      core/src/main/java/apelet/common/ecommerce/sdk/ChannelContext.java
  32. 19
      core/src/main/java/apelet/common/ecommerce/sdk/ChannelSdk.java
  33. 84
      core/src/main/java/apelet/common/ecommerce/spi/AccessToken.java
  34. 16
      core/src/main/java/apelet/common/ecommerce/spi/ChannelAccountRepository.java
  35. 91
      core/src/main/java/apelet/common/ecommerce/spi/ChannelSecret.java
  36. 24
      core/src/main/java/apelet/common/ecommerce/spi/LockProvider.java
  37. 14
      core/src/main/java/apelet/common/ecommerce/spi/SecretProvider.java
  38. 18
      core/src/main/java/apelet/common/ecommerce/spi/Sink.java
  39. 18
      core/src/main/java/apelet/common/ecommerce/spi/SyncCursorStore.java
  40. 15
      core/src/main/java/apelet/common/ecommerce/spi/TokenStore.java
  41. 77
      core/src/main/java/apelet/common/ecommerce/sync/SyncEngine.java
  42. 43
      core/src/main/java/apelet/common/ecommerce/sync/SyncResult.java
  43. 59
      core/src/main/java/apelet/common/ecommerce/sync/SyncSpec.java
  44. 77
      core/src/test/java/apelet/common/ecommerce/model/CanonicalKeysTest.java

7
.gitattributes vendored

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

54
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;
/**
* 聚合器扩展:列出子渠道(如领星店铺/平台码)。
*
* <p>子渠道由 {@code ChannelAccount.platformCode} + {@code externalStoreId} 配置,
* 不新增平台模块。</p>
*/
public interface AggregatorCapable extends PlatformAdapter {
List<SubChannel> 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;
}
}
}

36
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<Platform> platforms();
/** 适配器能力并集。 */
EnumSet<Capability> capabilities();
/**
* 按账户细化能力。
*
* <p>直连适配器默认使用 {@link #capabilities()};聚合器按账户的子平台覆盖,
* 例如领星对 Walmart 支持标发、对 eBay 只支持订单。</p>
*/
default boolean supports(ChannelAccount account, Capability capability) {
return capabilities().contains(capability);
}
}

25
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<ProductListing> pull(ChannelContext ctx, ProductQuery query);
ProductListing get(ChannelContext ctx, String platformSku);
default Capability requiredCapability() {
return Capability.PRODUCT;
}
}

29
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<SalesOrder> 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);
}
}
}

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

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

22
core/src/main/java/apelet/common/ecommerce/error/CrossProviderConflictException.java

@ -0,0 +1,22 @@
package apelet.common.ecommerce.error;
/**
* 跨 provider 冲突:同一业务动作已由另一个 provider 完成。
*
* <p>不自动跨 provider 重试,必须人工确认。</p>
*/
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;
}
}

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

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

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

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

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

65
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}。
*
* <p>这是所有门面调用的隔离维度。同一 omsStoreId 可以同时绑定直连与聚合器账户,
* 业务实体不会因此重复——provider 不进入业务主键,见 {@link CanonicalKeys}。</p>
*/
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;
}
}

112
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;
/**
* 三级标识计算。
*
* <ul>
* <li><b>omsStoreId</b>:业务归属,直连与聚合器都映射到同一个店铺</li>
* <li><b>canonicalKey</b>:platform + omsStoreId + 平台实体 ID,用于 upsert、去重、状态流转</li>
* <li><b>sourceKey</b>:provider + platform + externalStoreId + 平台实体 ID,用于原始报文、水位线、审计</li>
* </ul>
*
* <p>provider 不得进入 canonicalKey,否则直连与聚合器切换会把同一实体变成两条。</p>
*/
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)。
*
* <p>刻意不含金额,避免不同 provider 的舍入差异造成重复账单;金额差异由 SourceConflict 记录。</p>
*/
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。
*
* <p>不带 provider,直连与聚合器共用,保证同一业务动作只成功一次。</p>
*/
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。
*
* <p>带 provider,用于单 provider 内的重复请求/重试去重。</p>
*/
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();
}
}

45
core/src/main/java/apelet/common/ecommerce/model/Capability.java

@ -0,0 +1,45 @@
package apelet.common.ecommerce.model;
/**
* 能力枚举。
*
* <p>适配器实现哪些能力接口,就声明哪些能力;门面据此在调用前做能力检查,
* 缺失时抛 UnsupportedCapabilityException,而不是静默失败。</p>
*/
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
}

167
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 账户。
*
* <p>凭证不落配置:本对象只保存 {@code credentialRef},真实密钥由宿主
* {@code SecretProvider} 按引用取出。</p>
*/
public final class ChannelAccount {
private final AccountKey key;
private final String externalStoreId;
private final Integer platformCode;
private final String region;
private final List<String> 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<String>(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<String> 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<String> 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<String> marketplaceIds) {
this.marketplaceIds = marketplaceIds == null ? Collections.<String>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);
}
}
}

23
core/src/main/java/apelet/common/ecommerce/model/ChannelProvider.java

@ -0,0 +1,23 @@
package apelet.common.ecommerce.model;
/**
* 接入 provider 枚举。
*
* <p>provider 是接入通道,不是业务渠道。同一个平台店铺可以同时存在
* {@link #DIRECT} 与 {@link #LINGXING} 两条接入通道,由调用方显式选择;
* SDK 不做推荐、不自动选路、不自动兜底。</p>
*/
public enum ChannelProvider {
/** 平台直连 */
DIRECT,
/** 领星聚合 */
LINGXING,
/** Teapplix 订单源 */
TEAPPLIX,
/** 其他聚合器或自建通道 */
CUSTOM;
/** 直连账户的 platformCode 哨兵值,避免 {@code (provider, externalStoreId, platformCode)} 唯一索引因 NULL 失效。 */
public static final int NO_PLATFORM_CODE = 0;
}

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

104
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}。
*
* <p>禁止使用 double。保留平台原始精度与币种,不做隐式舍入。</p>
*/
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();
}
}

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

52
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 <T> 条目类型
*/
public final class Page<T> {
private final List<T> items;
private final String nextCursor;
private final boolean hasMore;
private Page(List<T> items, String nextCursor, boolean hasMore) {
this.items = Collections.unmodifiableList(new ArrayList<T>(items));
this.nextCursor = nextCursor;
this.hasMore = hasMore;
}
public static <T> Page<T> of(List<T> items, String nextCursor) {
return new Page<T>(items, nextCursor, nextCursor != null && !nextCursor.isEmpty());
}
public static <T> Page<T> last(List<T> items) {
return new Page<T>(items, null, false);
}
public static <T> Page<T> empty() {
return new Page<T>(Collections.<T>emptyList(), null, false);
}
public List<T> items() {
return items;
}
/** 下一页游标;null 表示结束。 */
public String nextCursor() {
return nextCursor;
}
public boolean hasMore() {
return hasMore;
}
public int size() {
return items.size();
}
}

42
core/src/main/java/apelet/common/ecommerce/model/Platform.java

@ -0,0 +1,42 @@
package apelet.common.ecommerce.model;
/**
* 业务平台枚举。
*
* <p>不含 provider:直连与领星等聚合器都映射到同一个平台值,
* 保证业务主键(canonicalKey)在 provider 切换时不变。</p>
*/
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;
}
}

85
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 是唯一真值,市场当地时间与北京时间是派生/物化视图。
*
* <p>夏令时一律通过 {@link ZoneId} 解析,禁止使用固定偏移量。</p>
*/
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 + ")";
}
}

112
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<String> 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<String>(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<String> 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<String> 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<String> imageUrls) {
this.imageUrls = imageUrls == null ? Collections.<String>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);
}
}
}

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

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

151
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 销售订单。
*
* <p>统一带 {@code omsStoreId + sourceProvider + sourceAccountId},
* 用于跨 provider 去重与追溯。</p>
*/
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<OrderLine> 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<OrderLine>(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<OrderLine> 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<OrderLine> 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<OrderLine> lines) {
this.lines = lines == null ? Collections.<OrderLine>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);
}
}
}

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

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

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

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

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

16
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<ChannelAccount> findByKey(String provider, String platform, String omsStoreId);
/** 同一 omsStoreId 下所有 provider 账户。 */
java.util.List<ChannelAccount> findByOmsStoreId(String omsStoreId);
}

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

24
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<Lock> tryLock(String key, long ttlSeconds);
interface Lock extends AutoCloseable {
@Override
void close();
}
}

14
core/src/main/java/apelet/common/ecommerce/spi/SecretProvider.java

@ -0,0 +1,14 @@
package apelet.common.ecommerce.spi;
/**
* 凭证读取 SPI。{@code credentialRef} 由 {@code ChannelAccount} 提供。
*
* <p>真实密钥只在内存中出现,禁止落配置、禁止写日志。</p>
*/
public interface SecretProvider {
/**
* @param credentialRef 形如 {@code kms://oms/lingxing/store-001}
*/
ChannelSecret get(String credentialRef);
}

18
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 只调用,不碰数据库。
*
* <p>实现方必须按 canonicalKey 做 upsert:同一实体从直连与聚合器同步时
* 不得产生两条记录。</p>
*/
public interface Sink {
void upsertSalesOrders(Page<SalesOrder> page);
void upsertProducts(Page<ProductListing> page);
}

18
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。
*
* <p>按 {@code provider + account + stream} 分开维护;只在整个批次成功后推进。</p>
*/
public interface SyncCursorStore {
/** 返回 null 表示还没有水位线,需要首次全量建底。 */
Instant get(ChannelProvider provider, String accountKey, String stream);
void advance(ChannelProvider provider, String accountKey, String stream, Instant watermark);
}

15
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<AccessToken> get(String tokenKey);
void save(String tokenKey, AccessToken token);
void remove(String tokenKey);
}

77
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;
/**
* 增量同步引擎。
*
* <pre>
* from = lastWatermark - overlapWindow
* to = now
* loop 拉一页 → 经 Sink upsert → cursor = page.nextCursor
* 直到 cursor == null
* 全部成功才推进水位线,失败不推进
* </pre>
*
* <p>引擎不持有线程或定时器,由宿主调度触发。</p>
*/
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<Instant, String, Page<SalesOrder>> 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<SalesOrder> 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);
}
}

43
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 + '}';
}
}

59
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;
}
/**
* 计算增量起点。
*
* <p>没有水位线时返回 null,由调用方走首次全量。</p>
*/
public Instant from(Instant watermark) {
return watermark == null ? null : watermark.minus(overlap);
}
}

77
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());
}
}
Loading…
Cancel
Save