管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

多来买:匿名与登录购物车合并实战

2363 字
12 分钟
多来买:匿名与登录购物车合并实战

多来买:匿名与登录购物车合并实战#

事实来源:当前 gateway、common-util、cart-service 的身份透传、身份读取与合并源码
证据状态:代码已确认,未启动 Gateway、购物车服务或 Redis。
阅读目标:理解正式身份与临时身份怎样进入购物车、两辆车怎样合并,以及并发窗口在哪里。

1. 为什么需要两种身份#

匿名用户也应能先加购;登录后又要把这批商品带入正式账号,而不是让用户二选一。

当前项目使用:

  • userId:Gateway 校验 Redis 登录态后透传;
  • userTempId:客户端通过 Header 或 Cookie 提供;
  • GET /cart:同时读取两种身份,必要时执行合并和迁移。

本文聚焦身份与合并,不重复介绍 RMap CRUD 或订单下单协作。

2. 身份流转#

sequenceDiagram participant C as 客户端 participant G as Gateway AuthFilter participant CC as CartController participant CS as CartServiceImpl participant R as Redis C->>G: Token + userTempId G->>G: Token 换取 userId G->>CC: Header userId + userTempId CC->>CS: getCartList(userId, userTempId) CS->>R: 读取正式 RMap CS->>R: 读取临时 RMap alt 未登录 CS-->>CC: 返回临时车 else 已登录且临时车为空 CS-->>CC: 返回正式车 else 已登录且临时车非空 CS->>CS: JVM 内存合并 CS->>R: 逐项写入正式车 CS->>R: delete 临时车 CS-->>CC: 返回合并结果 end

3. Gateway 只透传临时身份#

源码:duolaimall-gateway/src/main/java/com/cskaoyan/mall/gateway/filter/AuthFilter.javaAuthFilter#filter

ServerHttpRequest.Builder builder = request.mutate();
if (StringUtils.isNotBlank(userId)) {
builder.header("userId", userId);
}
String userTempId = request.getHeaders().getFirst("userTempId");
if (StringUtils.isBlank(userTempId)) {
HttpCookie cookie = request.getCookies().getFirst("userTempId");
if (cookie != null) {
userTempId = cookie.getValue();
}
}
if (StringUtils.isNotBlank(userTempId)) {
builder.header("userTempId", userTempId);
}

准确边界:

  • Gateway 不在这段代码中生成临时 ID;
  • Header 优先于 Cookie;
  • 两处都没有时不会补发身份;
  • 匿名购物车依赖客户端稳定维护 userTempId

客户端具体如何生成、保存和轮换临时 ID,当前仓库没有前端工程可供确认。

4. AuthContext 的空值归一化#

源码:duolaimall-common/common-util/src/main/java/com/cskaoyan/mall/common/util/AuthContext.javaAuthContext

public static String getUserId(HttpServletRequest request) {
String userId = request.getHeader("userId");
return StringUtils.isEmpty(userId) ? "" : userId;
}
public static String getUserTempId(HttpServletRequest request) {
String userTempId = request.getHeader("userTempId");
return StringUtils.isEmpty(userTempId) ? "" : userTempId;
}

工具把缺失 Header 变为空字符串,不校验格式、唯一性或可信度。

5. 写操作与查询使用身份的差异#

添加、勾选和删除选择一个身份:

String identity = request.getHeader("userId");
if (StringUtils.isBlank(identity)) {
identity = request.getHeader("userTempId");
}

选择规则:

输入写入归属
只有 userId正式车
只有 userTempId临时车
两者都有正式车
两者都无null 继续进入 Service

查询必须同时拿到两种身份:

@GetMapping("/cart")
public Result cartList(HttpServletRequest request) {
String userId = AuthContext.getUserId(request);
String userTempId = AuthContext.getUserTempId(request);
return Result.ok(
cartService.getCartList(userId, userTempId));
}

不同读取方式导致缺失身份有时是 null、有时是 "",最终可能形成不同 Redis key。

6. 先读取两份 RMap#

源码:duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/service/impl/CartServiceImpl.javagetCartList

String userCartKey = buildKey(userId);
String userTempCartKey = buildKey(userTempId);
RMap<Long, CartInfoDTO> userMap =
redissonClient.getMap(userCartKey);
RMap<Long, CartInfoDTO> tempMap =
redissonClient.getMap(userTempCartKey);
List<CartInfoDTO> userItems = userMap.values().stream()
.sorted(Comparator.comparing(
CartInfoDTO::getUpdateTime).reversed())
.collect(Collectors.toList());
List<CartInfoDTO> tempItems = tempMap.values().stream()
.sorted(Comparator.comparing(
CartInfoDTO::getUpdateTime).reversed())
.collect(Collectors.toList());

代码在判断是否登录之前就读取两份 Map。

因此:

  • 匿名请求仍会读取 user:cart:
  • 已登录但没有临时 ID 也会读取 user:cart:
  • 两个 ID 都为空时,两份 Map 可能指向同一个 key。

7. 三个业务分支#

源码同上:CartServiceImpl#getCartList

if (StringUtils.isBlank(userId)) {
return tempItems;
}
if (CollectionUtils.isEmpty(tempItems)) {
return userItems;
}
List<CartInfoDTO> merged =
mergeCartSimple(userItems, tempItems);
List<CartInfoDTO> sorted = merged.stream()
.sorted(Comparator.comparing(
CartInfoDTO::getUpdateTime).reversed())
.collect(Collectors.toList());
for (CartInfoDTO item : sorted) {
userMap.put(item.getSkuId(), item);
}
tempMap.delete();
return sorted;
条件返回Redis 副作用
未登录临时车
已登录、临时车空正式车
已登录、临时车非空合并结果写正式车、删临时车

所以 GET /cart 在登录后第一次访问时可能产生写操作。

8. 合并算法#

源码:CartServiceImpl#mergeCartSimple

private List<CartInfoDTO> mergeCartSimple(
List<CartInfoDTO> userItems,
List<CartInfoDTO> tempItems) {
Map<Long, CartInfoDTO> index = userItems.stream()
.collect(Collectors.toMap(
CartInfoDTO::getSkuId,
Function.identity(),
(first, second) -> first));
tempItems.forEach(tempItem -> {
CartInfoDTO userItem = index.get(tempItem.getSkuId());
if (userItem != null) {
userItem.setSkuNum(
userItem.getSkuNum() + tempItem.getSkuNum());
userItem.setIsChecked(
tempItem.getIsChecked() == 0
&& userItem.getIsChecked() == 0 ? 0 : 1);
if (tempItem.getUpdateTime()
.after(userItem.getUpdateTime())) {
userItem.setUpdateTime(tempItem.getUpdateTime());
}
} else {
index.put(tempItem.getSkuId(), tempItem);
}
});
return new ArrayList<>(index.values());
}

9. 合并规则真值表#

场景规则
正式车已有同 SKU数量相加
同 SKU 的勾选状态只要任一选中,结果为选中
同 SKU 的更新时间取较新时间
临时车独有 SKU原 DTO 直接放入结果
正式车独有 SKU原样保留

勾选状态:

正式临时结果
000
011
101
111

这是项目业务规则,不是 Redis 固有行为。

10. 完整示例#

合并前:

正式车 user:cart:7
sku=101 qty=2 checked=0 update=10:00 value.userId=7
sku=202 qty=1 checked=1 update=09:50 value.userId=7
临时车 user:cart:temp-A
sku=101 qty=3 checked=1 update=10:05 value.userId=temp-A
sku=303 qty=1 checked=0 update=10:02 value.userId=temp-A

合并后:

正式车 user:cart:7
sku=101 qty=5 checked=1 update=10:05 value.userId=7
sku=303 qty=1 checked=0 update=10:02 value.userId=temp-A
sku=202 qty=1 checked=1 update=09:50 value.userId=7
临时车 user:cart:temp-A 已删除

临时车独有 DTO 直接写入正式车,内部 userId 没有改写,所以 key 已迁移而 value 仍可能保存临时身份。

11. 算法与远程操作成本#

设正式车 n 项、临时车 m 项:

步骤复杂度或次数
正式车建索引O(n)
遍历临时车O(m)
最终排序O((n+m) log(n+m))
写回正式车最多 n+m 次远程 put
删除临时车1 次远程 delete

CPU 侧接近线性,但 Redis 网络往返不能被算法复杂度掩盖。

12. 为什么不是原子迁移#

真实序列:

T1 读取正式车
T2 读取临时车
T3 JVM 合并
T4 循环 put 正式车
T5 delete 临时车

整个流程没有用户粒度锁、Lua、Redis 事务、版本号或迁移标记。

单个 put 完成不代表 T1 到 T5 原子。

13. 四个并发窗口#

  1. T2 后匿名端又向临时车写入商品,T5 删除整车时新数据可能丢失。
  2. 一个请求已写正式车但未删临时车,第二个请求可能再次累加同一临时数量。
  3. 合并期间另一个请求更新正式车,旧快照可能覆盖新值。
  4. 写回部分条目后进程异常,下次合并可能重复累加已写部分。

这些是有明确代码触发机制的候选风险,没有并发运行证据。

14. 关键身份场景#

14.1 只有临时身份#

userId = ""
userTempId = "temp-A"

Service 会读取 user:cart:user:cart:temp-A,随后返回临时车,不迁移。

14.2 已登录、临时车为空#

返回正式车,不写 Redis。

14.3 两辆车同一 SKU#

正式 qty=2 checked=0
临时 qty=3 checked=1
结果 qty=5 checked=1

14.4 临时车有独有 SKU#

DTO 原样进入正式 RMap,内部 userId 保留临时值。

14.5 两个身份意外相同#

两份 RMap 指向同一 key,算法可能把同一批条目当成两辆车合并,随后删除该 Map。

当前代码没有拒绝相同身份值。

15. 空身份 key#

入口缺失身份表示可能 key
添加、勾选、删除nulluser:cart:null
查询""user:cart:
已登录但无临时 ID正式 ID + ""正式 key 与 user:cart:

这不是 Redis 故障,而是 Controller 与 AuthContext 对空值处理不一致。

16. 失败矩阵#

场景已完成未完成结果
读取正式车失败读取临时车、合并请求异常
合并后首次 put 失败内存已算出结果剩余写回、删临时车两车仍保留
写回部分后失败部分正式车更新剩余写回、删除下一次可能重复累加
正式车全写完、delete 失败正式车已含合并数量删除临时车下一次可能再次合并
delete 前临时新写新条目已进入临时车安全迁移新条目整车删除风险

17. 已确认限制与候选风险#

17.1 已确认限制#

  • userTempId 由客户端提供,Gateway 不生成。
  • 不同入口把缺失身份处理为 null 或空串。
  • 查询在判断身份前读取两份 RMap。
  • GET 查询可能执行迁移写操作。
  • 合并在 JVM 内完成并逐项写回。
  • 临时独有 DTO 不改写内部 userId
  • 整体流程没有原子性或幂等标记。

17.2 候选风险#

风险触发机制缺失证据
临时新数据丢失快照后写入、最终整 key 删除未并发复现
多标签重复累加正式已写、临时未删的窗口未并发调用
空身份数据串用多请求落到空 key未观察客户端请求
value 身份不一致临时独有 DTO 原样迁移未检查下游是否依赖字段

18. 如果重做(非当前实现)#

  1. 服务端签发或校验临时身份;
  2. 在入口统一空值与身份规则;
  3. 使用用户粒度锁、Lua 或迁移状态保证合并幂等;
  4. 写正式车时重写 DTO 内部归属;
  5. 避免在普通 GET 中隐藏迁移副作用,或明确接口语义;
  6. 为部分失败提供可重试的迁移记录。

19. 关键源码导航#

阅读问题项目相对路径类/方法
Gateway 身份透传duolaimall-gateway/src/main/java/com/cskaoyan/mall/gateway/filter/AuthFilter.javafilter
身份读取工具duolaimall-common/common-util/src/main/java/com/cskaoyan/mall/common/util/AuthContext.java两个 getter
查询入口duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/controller/CartController.javacartList
两车读取与写回duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/service/impl/CartServiceImpl.javagetCartList
合并算法duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/service/impl/CartServiceImpl.javamergeCartSimple
购物车 DTOduolaimall-cart/cart-api/src/main/java/com/cskaoyan/mall/cart/api/dto/CartInfoDTO.javaCartInfoDTO

20. 短复习点#

  1. Gateway 透传而不生成 userTempId,匿名车依赖客户端稳定身份。
  2. 未登录返回临时车,已登录且临时车非空才触发迁移。
  3. 同 SKU 数量相加、勾选做“或”、时间取较新。
  4. 合并是读取、内存计算、逐项写和整车删除,不是原子事务。
  5. 临时独有 DTO 写入正式 key 后,内部 userId 仍可能是临时值。

21. 一句话总结#

多来买通过正式和临时两份 RMap 支持匿名加购与登录迁移,合并规则明确,但身份可信度、空 key、非原子写回和重复合并窗口必须作为代码边界说明。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

多来买:匿名与登录购物车合并实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-cart-anonymous-login-merge/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录