电商 Guest-First 购物体验:无需注册也能完整下单
目录
📦 本文基于的完整项目源码:https://github.com/meirongdev/shop
2026-04 实践更新 当前主线代码里,游客购物车已经从
StringRedisTemplate统一切到RedissonClient的RBucket<String>,但仍然保留“JSON 可读 + TTL 续期”的设计。下面示例已按当前实现同步。
在电商平台中,注册/登录常常是转化链路里的明显阻力之一。Baymard 长期跟踪 checkout 可用性时,也一直把“被迫注册账号”列为常见流失原因之一。Baymard Institute, Cart Abandonment Rate
Shop Platform 目前已经在 buyer-portal 这条链路上初步支持 Guest-First(游客优先):用户可以不注册先创建游客订单、保存 order_token 并回查状态。需要补充说明的是,KMP buyer-app 目前仍要求登录后再触发结账,所以这里更接近“逐步 guest-first”,而不是所有前端入口都已经完全等价。
整体流程#
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-*前缀让下游服务和日志都能直接识别游客身份
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),活跃用户的购物车更不容易被动过期
购物车自动路由#
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 作为回查凭证。
登录后购物车合并#
当游客注册/登录时,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-service 的 addToCart 逻辑保证——如果已存在则累加 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 / 领域服务信任模型。
为什么游客购物车 TTL 设为 48 小时#
48 小时是基于以下考虑:
- 足够:大多数用户在一次或几次访问中完成购买
- 不浪费:超过 48 小时的游客购物车大概率不会再被访问
- 可调整:通过
BUYER_BFF_GUEST_CART_TTL环境变量随时修改
如果未来数据分析发现用户的平均购买周期更长,可以调整到 72 或 96 小时。
参考与实现位置#
- Baymard Institute 结账流失原因研究:https://baymard.com/lists/cart-abandonment-rate
- Spring Data Redis Reference:https://docs.spring.io/spring-data/redis/reference/
- Spring Security JWT Resource Server:https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html
- REST API 设计最佳实践:https://learn.microsoft.com/en-us/azure/architecture/best-practices/api-design
- JWT Introduction:https://jwt.io/introduction
- Redis EXPIRE Command:https://redis.io/docs/latest/commands/expire/
- WebAssembly JavaScript API:https://developer.mozilla.org/en-US/docs/WebAssembly/Reference/JavaScript_interface
- SameSite Cookies:https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#samesitesamesite-value
- 仓库实现入口:
services/buyer-bff/src/main/java/dev/meirong/shop/buyerbff/service/GuestCartStore.java、services/buyer-bff/src/main/java/dev/meirong/shop/buyerbff/controller/GuestBuyerController.java、services/order-service/src/main/java/dev/meirong/shop/order/domain/ShopOrderEntity.java、frontend/buyer-portal/src/main/kotlin/dev/meirong/shop/buyerportal/controller/BuyerPortalController.kt
小结#
当前这套 Guest-First 设计里,我觉得比较关键的是:
- guest JWT 标识身份:通过
guest-buyer-*前缀识别游客,信任链与登录用户一致 - Redis 购物车:TTL 48 小时、JSON 序列化、自动路由(游客走 Redis,登录走 DB)
- 游客结账:
buyer-portal已接入专用GUEST_CHECKOUT端点,创建带order_token的订单 - 订单追踪:通过
order_token查询,不需要登录 - 登录后合并:逐个加入正式购物车 + 清理 Redis,自动去重累加
- 统一 JWT 策略:游客和登录用户都用 JWT,Gateway 统一验签,简化信任链