📦 本文基于的完整项目源码:https://github.com/meirongdev/shop

2026-04 实践更新 当前主线代码里,游客购物车已经从 StringRedisTemplate 统一切到 RedissonClientRBucket<String>,但仍然保留“JSON 可读 + TTL 续期”的设计。下面示例已按当前实现同步。

在电商平台中,注册/登录常常是转化链路里的明显阻力之一。Baymard 长期跟踪 checkout 可用性时,也一直把“被迫注册账号”列为常见流失原因之一。Baymard Institute, Cart Abandonment Rate

Shop Platform 目前已经在 buyer-portal 这条链路上初步支持 Guest-First(游客优先):用户可以不注册先创建游客订单、保存 order_token 并回查状态。需要补充说明的是,KMP buyer-app 目前仍要求登录后再触发结账,所以这里更接近“逐步 guest-first”,而不是所有前端入口都已经完全等价。


整体流程#

flowchart TD Visit["访问网站"] Browse["浏览商品"] AddCart["加入购物车"] Checkout["结账"] Pay{"支付"} Guest["auth-server 签发 guest JWT"] RedisCart["Redis 购物车
TTL 48 小时"] GuestOrder["创建 GUEST 订单
带 order_token"] Track["通过 order_token 追踪订单"] Login["注册/登录"] Merge["购物车合并
Redis → DB"] SignedIn["登录态购物车"] Visit --> Browse --> AddCart --> Checkout --> Pay Pay -->|"成功"| GuestOrder GuestOrder --> Track Track -->|"可选"| Login Login --> Merge --> SignedIn Guest -.->|"guest JWT 标识身份"| AddCart AddCart -.->|"游客态"| RedisCart RedisCart -.->|"结账时"| Checkout

游客身份由 auth-server 签发的 guest JWT 标识(principalId 形如 guest-buyer-*),而不是由 BFF 自己生成会话标识。这保证了整个信任链的一致性。Spring Security OAuth2 Resource Server


游客身份识别#

guest JWT#

游客买家 ID 以 "guest-buyer-" 为前缀,这个身份来自 auth-server 签发的 guest JWT:

public boolean isGuestBuyer(String buyerId) {
    return buyerId != null && buyerId.startsWith("guest-buyer-");
}

通过 JWT 而不是 Cookie 或 Session 来标识游客,有几个好处:

  • 信任链一致:Gateway 统一验 JWT,注入 X-Buyer-Id 等 Trusted Headers,下游服务不需要区分游客和登录用户
  • 跨域友好:JWT 放在 Authorization header 中,天然支持前后端分离和 KMP WASM 场景
  • 可追踪guest-buyer-* 前缀让下游服务和日志都能直接识别游客身份

参考:JWT IntroductionSpring Security JWT Resource Server


Redis 游客购物车#

为什么用 Redis 而不是数据库#

游客购物车的特点决定了它不需要持久化到 MySQL:

维度 Redis 方案 DB 方案
TTL 自动过期 ✅ Redis key TTL 48 小时 ❌ 需要定时任务清理
读写性能 ✅ 内存操作,微秒级 ⚠️ 磁盘 I/O,毫秒级
数据重要性 ✅ 游客购物车可丢失(用户可重新加) ❌ 登录态购物车不可丢失
合并后清理 ✅ 删除 key 即可 ⚠️ 需要事务删除

GuestCartStore 实现#

@Component
public class GuestCartStore {

    private static final String CART_KEY_PREFIX = "buyer:guest:cart:";
    private static final Duration DEFAULT_TTL = Duration.ofHours(48);

    private final RedissonClient redissonClient;
    private final ObjectMapper objectMapper;

    public List<OrderApi.CartItemResponse> listCart(String buyerId) {
        RBucket<String> bucket = redissonClient.getBucket(CART_KEY_PREFIX + buyerId);
        String json = bucket.get();
        if (json == null) return List.of();
        List<OrderApi.CartItemResponse> items = objectMapper.readValue(json, new TypeReference<>() {});
        bucket.expire(DEFAULT_TTL);
        return items;
    }

    public void addToCart(String buyerId, OrderApi.CartItemRequest item) {
        List<OrderApi.CartItemResponse> items = listCart(buyerId);
        // 合并同类项:同 productId 的累加 quantity
        redissonClient.getBucket(CART_KEY_PREFIX + buyerId)
                .set(objectMapper.writeValueAsString(items), DEFAULT_TTL.toMillis(), TimeUnit.MILLISECONDS);
    }

    public void clearCart(String buyerId) {
        redissonClient.getBucket(CART_KEY_PREFIX + buyerId).delete();
    }
}

关键设计:

  • RBucket<String> + JSON:仍然保留可读 JSON,但 Redis 接入风格已统一到 RedissonClient
  • TTL 48 小时:足够用户多次访问完成购买,又不会长期占用内存
  • 读写都会续期:写操作直接带 TTL,读取后也会 expire(DEFAULT_TTL),活跃用户的购物车更不容易被动过期

参考:Spring Data Redis ReferenceRedis EXPIRE Command

购物车自动路由#

BFF 中的购物车操作根据买家身份自动路由到 Redis 或 order-service:

public OrderApi.CartView listCart(String buyerId) {
    if (guestCartStore.isGuestBuyer(buyerId)) {
        return guestCartStore.listCart(buyerId);  // 走 Redis
    }
    return call("orderService", false,
            () -> orderClient.listCart(buyerId),  // 走 order-service DB
            "Cart service unavailable");
}

这保证了前端调用方不需要知道底层存储差异,BFF 内部自动处理。


游客结账链路#

GUEST_CHECKOUT 端点#

游客结账走 order-service 的专用端点,创建带 order_token 的 GUEST 订单:

// GuestBuyerController
@PostMapping("/checkout")
public ApiResponse<OrderApi.OrderResponse> guestCheckout(
        @Valid @RequestBody OrderApi.GuestCheckoutRequest request) {
    return ApiResponse.success(service.guestCheckout(request));
}

GUEST 订单与登录用户订单的区别:

  • order_token:一个 UUID,作为游客追踪订单的核心回查凭证
  • buyer_id:标记为 guest-buyer-*
  • type = "GUEST":当前实体上用订单类型区分游客订单,同时保留 guest_email

游客订单追踪#

游客通过 order_token 追踪订单,不需要登录:

@GetMapping("/order/track")
public ApiResponse<OrderApi.OrderResponse> trackOrder(
        @RequestParam String token) {
    return ApiResponse.success(service.trackOrder(token));
}

这保证了用户即使不注册也能知道自己的订单状态。当前仓库里可以直接验证的是:buyer-portal 会在确认页展示 order_token。如果后续要补邮件或短信通知,也可以继续沿用同一个 token 作为回查凭证。

参考:REST API 设计实践


登录后购物车合并#

当游客注册/登录时,BFF 提供购物车合并端点:

@PostMapping("/cart/merge")
public ApiResponse<OrderApi.CartView> mergeCart(
        @RequestHeader("X-Buyer-Id") String headerBuyerId,
        @RequestHeader("X-Roles") String headerRoles,
        @Valid @RequestBody BuyerApi.MergeGuestCartRequest request) {
    requireSignedInBuyer(headerRoles, "Buyer cart merge");
    return ApiResponse.success(service.mergeGuestCart(
            requireAuthenticatedBuyerId(headerBuyerId, "Buyer cart merge"),
            request.guestBuyerId()));
}

合并逻辑#

public OrderApi.CartView mergeGuestCart(String signedInBuyerId, String guestBuyerId) {
    // 1. 读取 Redis 中游客购物车
    List<OrderApi.CartItemResponse> guestItems = guestCartStore.listCart(guestBuyerId);

    // 2. 逐个加入登录用户的正式购物车
    for (OrderApi.CartItemResponse item : guestItems) {
        orderClient.addToCart(signedInBuyerId,
                new OrderApi.CartItemRequest(item.productId(), item.quantity()));
    }

    // 3. 删除 Redis 中的游客购物车
    guestCartStore.clearCart(guestBuyerId);

    // 4. 返回合并后的购物车
    return orderClient.listCart(signedInBuyerId);
}

合并时按 productId 去重累加,避免同一商品出现两行。这由 order-serviceaddToCart 逻辑保证——如果已存在则累加 quantity,不存在则新建。

权限校验#

合并购物车这条接口当前只允许已登录用户发起,不对游客开放:

private void requireSignedInBuyer(String headerRoles, String action) {
    if (headerRoles.contains("ROLE_BUYER_GUEST")) {
        throw new BusinessException(CommonErrorCode.FORBIDDEN,
                action + " requires signed-in buyer");
    }
}

游客态 vs 登录态的完整对比#

维度 游客态 登录态
身份标识 guest-buyer-* (guest JWT) buyer-{uuid} (正式 JWT)
购物车存储 Redis(TTL 48 小时) order-service MySQL
结账端点 POST /buyer/v1/guest/checkout POST /buyer/v1/checkout/create
订单类型 GUEST 订单(带 order_token 正式订单(关联 buyer_id)
订单追踪 通过 order_token 通过 buyer_id 查询历史订单
积分/优惠券 ❌ 不可用 ✅ 可用
支付方式 buyer-portal 当前以创建 PENDING_PAYMENT 订单 + token 回查为主 登录态 checkout 已接入钱包支付与外部 PaymentIntent / redirect provider
登录后行为 购物车合并到正式购物车 无需合并

设计取舍#

为什么选 guest JWT 而不是 Cookie/Session#

维度 guest JWT Cookie/Session
跨域 ✅ 天然支持(header 传递) ❌ 需要 SameSite/CORS 配置
KMP WASM ✅ 与登录态同一套机制 ❌ WASM 中 Cookie 管理复杂
Gateway 统一验签 ✅ 验 JWT 即可,不需要额外逻辑 ⚠️ 需要额外处理 Cookie
无状态 ✅ JWT 自包含 ❌ Session 需要服务端存储

在 shop 项目中,前端策略同时包含 KMP WASM SPA(buyer-app / seller-app)和 SSR buyer-portal。当前 buyer-app 已经复用了 guest JWT 来完成浏览和加购,但 checkout 仍要求登录;SSR 门户则把 guest checkout 补齐到了可直接下单的程度。统一使用 JWT 仍然让这两条前端线共享同一套 Gateway / BFF / 领域服务信任模型。

参考:WebAssembly JavaScript APISameSite Cookies

为什么游客购物车 TTL 设为 48 小时#

48 小时是基于以下考虑:

  • 足够:大多数用户在一次或几次访问中完成购买
  • 不浪费:超过 48 小时的游客购物车大概率不会再被访问
  • 可调整:通过 BUYER_BFF_GUEST_CART_TTL 环境变量随时修改

如果未来数据分析发现用户的平均购买周期更长,可以调整到 72 或 96 小时。


参考与实现位置#


小结#

当前这套 Guest-First 设计里,我觉得比较关键的是:

  • guest JWT 标识身份:通过 guest-buyer-* 前缀识别游客,信任链与登录用户一致
  • Redis 购物车:TTL 48 小时、JSON 序列化、自动路由(游客走 Redis,登录走 DB)
  • 游客结账buyer-portal 已接入专用 GUEST_CHECKOUT 端点,创建带 order_token 的订单
  • 订单追踪:通过 order_token 查询,不需要登录
  • 登录后合并:逐个加入正式购物车 + 清理 Redis,自动去重累加
  • 统一 JWT 策略:游客和登录用户都用 JWT,Gateway 统一验签,简化信任链

项目仓库:github.com/meirongdev/shop