多来买:匿名与登录购物车合并实战
多来买:匿名与登录购物车合并实战
事实来源:当前 gateway、common-util、cart-service 的身份透传、身份读取与合并源码
证据状态:代码已确认,未启动 Gateway、购物车服务或 Redis。
阅读目标:理解正式身份与临时身份怎样进入购物车、两辆车怎样合并,以及并发窗口在哪里。
1. 为什么需要两种身份
匿名用户也应能先加购;登录后又要把这批商品带入正式账号,而不是让用户二选一。
当前项目使用:
userId:Gateway 校验 Redis 登录态后透传;userTempId:客户端通过 Header 或 Cookie 提供;GET /cart:同时读取两种身份,必要时执行合并和迁移。
本文聚焦身份与合并,不重复介绍 RMap CRUD 或订单下单协作。
2. 身份流转
3. Gateway 只透传临时身份
源码:duolaimall-gateway/src/main/java/com/cskaoyan/mall/gateway/filter/AuthFilter.java,AuthFilter#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.java,AuthContext。
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.java,getCartList。
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 | 原样保留 |
勾选状态:
| 正式 | 临时 | 结果 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
这是项目业务规则,不是 Redis 固有行为。
10. 完整示例
合并前:
正式车 user:cart:7sku=101 qty=2 checked=0 update=10:00 value.userId=7sku=202 qty=1 checked=1 update=09:50 value.userId=7
临时车 user:cart:temp-Asku=101 qty=3 checked=1 update=10:05 value.userId=temp-Asku=303 qty=1 checked=0 update=10:02 value.userId=temp-A合并后:
正式车 user:cart:7sku=101 qty=5 checked=1 update=10:05 value.userId=7sku=303 qty=1 checked=0 update=10:02 value.userId=temp-Asku=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. 四个并发窗口
- T2 后匿名端又向临时车写入商品,T5 删除整车时新数据可能丢失。
- 一个请求已写正式车但未删临时车,第二个请求可能再次累加同一临时数量。
- 合并期间另一个请求更新正式车,旧快照可能覆盖新值。
- 写回部分条目后进程异常,下次合并可能重复累加已写部分。
这些是有明确代码触发机制的候选风险,没有并发运行证据。
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=114.4 临时车有独有 SKU
DTO 原样进入正式 RMap,内部 userId 保留临时值。
14.5 两个身份意外相同
两份 RMap 指向同一 key,算法可能把同一批条目当成两辆车合并,随后删除该 Map。
当前代码没有拒绝相同身份值。
15. 空身份 key
| 入口 | 缺失身份表示 | 可能 key |
|---|---|---|
| 添加、勾选、删除 | null | user: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. 如果重做(非当前实现)
- 服务端签发或校验临时身份;
- 在入口统一空值与身份规则;
- 使用用户粒度锁、Lua 或迁移状态保证合并幂等;
- 写正式车时重写 DTO 内部归属;
- 避免在普通 GET 中隐藏迁移副作用,或明确接口语义;
- 为部分失败提供可重试的迁移记录。
19. 关键源码导航
| 阅读问题 | 项目相对路径 | 类/方法 |
|---|---|---|
| Gateway 身份透传 | duolaimall-gateway/src/main/java/com/cskaoyan/mall/gateway/filter/AuthFilter.java | filter |
| 身份读取工具 | 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.java | cartList |
| 两车读取与写回 | duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/service/impl/CartServiceImpl.java | getCartList |
| 合并算法 | duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/service/impl/CartServiceImpl.java | mergeCartSimple |
| 购物车 DTO | duolaimall-cart/cart-api/src/main/java/com/cskaoyan/mall/cart/api/dto/CartInfoDTO.java | CartInfoDTO |
20. 短复习点
- Gateway 透传而不生成
userTempId,匿名车依赖客户端稳定身份。 - 未登录返回临时车,已登录且临时车非空才触发迁移。
- 同 SKU 数量相加、勾选做“或”、时间取较新。
- 合并是读取、内存计算、逐项写和整车删除,不是原子事务。
- 临时独有 DTO 写入正式 key 后,内部
userId仍可能是临时值。
21. 一句话总结
多来买通过正式和临时两份 RMap 支持匿名加购与登录迁移,合并规则明确,但身份可信度、空 key、非原子写回和重复合并窗口必须作为代码边界说明。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!