Browse Source
- 标识体系: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
44 changed files with 2310 additions and 0 deletions
@ -0,0 +1,7 @@
@@ -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 |
||||
@ -0,0 +1,54 @@
@@ -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; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,36 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,25 @@
@@ -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; |
||||
} |
||||
} |
||||
@ -0,0 +1,29 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,17 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,154 @@
@@ -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; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,22 @@
@@ -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; |
||||
} |
||||
} |
||||
@ -0,0 +1,18 @@
@@ -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; |
||||
} |
||||
} |
||||
@ -0,0 +1,13 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,20 @@
@@ -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; |
||||
} |
||||
} |
||||
@ -0,0 +1,13 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,17 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,65 @@
@@ -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; |
||||
} |
||||
} |
||||
@ -0,0 +1,112 @@
@@ -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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,45 @@
@@ -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 |
||||
} |
||||
@ -0,0 +1,167 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@
@@ -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; |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -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 |
||||
} |
||||
@ -0,0 +1,104 @@
@@ -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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,97 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,52 @@
@@ -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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,42 @@
@@ -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; |
||||
} |
||||
} |
||||
@ -0,0 +1,85 @@
@@ -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 + ")"; |
||||
} |
||||
} |
||||
@ -0,0 +1,112 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,74 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
package apelet.common.ecommerce.model; |
||||
|
||||
/** 商品同步策略,适配器声明自己支持哪一种。 */ |
||||
public enum ProductSyncStrategy { |
||||
|
||||
/** 时间窗增量 */ |
||||
INCREMENTAL_TIME_WINDOW, |
||||
/** 全量拉取 + 差异比对 */ |
||||
FULL_PULL_DIFF, |
||||
/** 报告 / 文件 */ |
||||
REPORT |
||||
} |
||||
@ -0,0 +1,151 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,82 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,47 @@
@@ -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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,48 @@
@@ -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; |
||||
} |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -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(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,84 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,16 @@
@@ -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); |
||||
} |
||||
@ -0,0 +1,91 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,14 @@
@@ -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); |
||||
} |
||||
@ -0,0 +1,18 @@
@@ -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); |
||||
} |
||||
@ -0,0 +1,18 @@
@@ -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); |
||||
} |
||||
@ -0,0 +1,15 @@
@@ -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); |
||||
} |
||||
@ -0,0 +1,77 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,43 @@
@@ -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 + '}'; |
||||
} |
||||
} |
||||
@ -0,0 +1,59 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,77 @@
@@ -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…
Reference in new issue