YangeIT大约 15 分钟高级服务框架Gateway网关路由统一鉴权GlobalFilterRedisThreadLocalJWT

1.网关统一登录校验

路由只是把请求“送过去”,并不能判断用户是否有资格访问。
真正的登录校验,要借助网关过滤器来完成。

前言

1.1.为什么用 GlobalFilter

网关中的过滤器有两种:

  • GatewayFilter:作用于某些特定路由
  • GlobalFilter:作用于所有路由

登录鉴权属于整个系统统一入口逻辑,因此更适合用 GlobalFilter

1.2.鉴权规则设计

当前项目采用一个简单明确的规则:

  • 路径匹配 /api/**/auth/** 的接口,必须登录
  • 其它接口,允许匿名访问

为什么这样设计?

因为商城中本来就有两类接口:

  1. 不强制登录接口:首页、分类、商品列表、商品详情
  2. 必须登录接口:购物车、地址、收藏、下单、当前用户信息

只要路径规划提前统一,网关做判断就会很简单。

1.3.AuthGlobalFilter

package com.zx.gateway.filter;

import com.alibaba.fastjson2.JSON;
import com.zx.domain.vo.common.Result;
import com.zx.domain.vo.common.ResultCodeEnum;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

@Slf4j
@Component
@Order(0)
public class AuthGlobalFilter implements GlobalFilter {

    private static final String TOKEN_PREFIX = "user:login:token:";

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    private final AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1. 获取请求路径
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getURI().getPath();
        log.info("请求路径:{}", path);

        // 2. 获取请求头 token
        String token = request.getHeaders().getFirst("token");
        log.info("请求头 token:{}", token);

        // 3. 根据 token 查询 Redis 登录态
        String userInfoJsonStr = "";
        if (StringUtils.isNotBlank(token)) {
            userInfoJsonStr = redisTemplate.opsForValue().get(TOKEN_PREFIX + token);
        }
        log.info("Redis用户信息:{}", userInfoJsonStr);

        // 4. 如果登录态存在,则把用户信息放入请求头继续向下传递
        if (StringUtils.isNotBlank(userInfoJsonStr)) {
            ServerHttpRequest newRequest = request.mutate()
                    .headers(headers -> headers.add(
                            "userInfo",
                            URLEncoder.encode(userInfoJsonStr, StandardCharsets.UTF_8)
                    ))
                    .build();
            return chain.filter(exchange.mutate().request(newRequest).build());
        }

        // 5. 如果当前接口是必须登录接口,则直接拦截
        if (antPathMatcher.match("/api/**/auth/**", path)) {
            log.info("命中受保护资源,且未登录");
            return out(exchange.getResponse(), ResultCodeEnum.LOGIN_AUTH);
        }

        // 6. 非受保护接口,直接放行
        return chain.filter(exchange);
    }

    private Mono<Void> out(ServerHttpResponse response, ResultCodeEnum resultCodeEnum) {
        Result<?> result = Result.build(null, resultCodeEnum);
        byte[] bits = JSON.toJSONString(result).getBytes(StandardCharsets.UTF_8);
        DataBuffer buffer = response.bufferFactory().wrap(bits);
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        return response.writeWith(Mono.just(buffer));
    }
}

1.4.这段代码到底做了什么

按执行顺序理解:

  1. 先拿到请求路径
  2. 再看看请求头里有没有 token
  3. 如果有,就去 Redis 查当前登录态
  4. 查到了,说明用户已登录
  5. 已登录时,把用户信息塞进请求头 userInfo
  6. 如果没登录,再判断当前是不是 /auth/ 保护接口
  7. 如果是保护接口,直接拦截
  8. 如果不是保护接口,就允许匿名访问

这里最关键的一步是第 5 步。
它解决了一个很核心的问题:

网关虽然已经知道“当前用户是谁”,但业务微服务还不知道。
因此必须把用户信息继续传给下游服务。

1.5.为什么要给 userInfo 做 URL 编码

原始 JSON 字符串里如果包含中文、空格或特殊符号,直接放进请求头很容易出现乱码或解析异常。
所以更稳妥的做法是:

  • 网关写入请求头前先 URLEncoder.encode(...)
  • 下游微服务读取后再 URLDecoder.decode(...)

这个细节如果漏掉,调试时最容易出现“昵称中文乱码”的问题。

2.微服务中获取当前登录用户

网关已经把 userInfo 放进请求头了,但控制器方法仍然不会自动拿到这个对象。
因此还需要在每个业务微服务内部,把用户信息提取出来,存入一个“当前线程可直接访问的位置”。

这个位置通常就是 ThreadLocal

前言

2.1.为什么要放进 ThreadLocal

因为后续很多业务都需要“当前登录用户”:

  • 新增收货地址时,需要知道 userId
  • 收藏商品时,需要知道是谁在收藏
  • 提交订单时,需要知道当前下单人

如果每个控制器方法都自己从请求头解析一次,会非常重复。
因此更常见的做法是:

image
image
  1. 进入服务时统一解析一次
  2. 存入 ThreadLocal
  3. 任意业务代码都可以随时获取

2.2.AuthThreadLocalUtils

package com.zx.common;

import com.zx.domain.entity.user.UserInfo;

public class AuthThreadLocalUtils {

    private static final ThreadLocal<UserInfo> THREAD_LOCAL = new ThreadLocal<>();

    public static UserInfo get() {
        return THREAD_LOCAL.get();
    }

    public static void set(UserInfo userInfo) {
        THREAD_LOCAL.set(userInfo);
    }

    public static void clear() {
        THREAD_LOCAL.remove();
    }
}

2.3.公共过滤器 MyFilter

package com.zx.common;

import com.alibaba.fastjson.JSON;
import com.zx.domain.entity.user.UserInfo;
import com.zx.domain.vo.common.Result;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

@Slf4j
@WebFilter(urlPatterns = "/*")
@Component
public class MyFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest,
                         ServletResponse servletResponse,
                         FilterChain filterChain) throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        String uri = request.getRequestURI();

        String userInfoJson = request.getHeader("userInfo");
        if (StringUtils.isNotBlank(userInfoJson)) {
            userInfoJson = URLDecoder.decode(userInfoJson, StandardCharsets.UTF_8);
        }

        log.info("MyFilter uri:{} userInfoJson:{}", uri, userInfoJson);

        try {
            if (StringUtils.isBlank(userInfoJson) && uri.contains("/auth/")) {
                response.setHeader("Content-Type", "application/json;charset=UTF-8");
                Result<?> result = Result.build(null, 208, "请先登录");
                response.getOutputStream()
                        .write(JSON.toJSONString(result).getBytes(StandardCharsets.UTF_8));
                return;
            }

            if (StringUtils.isNotBlank(userInfoJson)) {
                UserInfo userInfo = JSON.parseObject(userInfoJson, UserInfo.class);
                AuthThreadLocalUtils.set(userInfo);
            }

            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            // 非常重要:避免线程复用导致脏数据
            AuthThreadLocalUtils.clear();
        }
    }
}

2.3.让过滤器生效

因为 MyFilter 放在 zx-common 中,所以业务微服务启动类需要加:

@ServletComponentScan("com.zx.common")

例如 ProductServiceApplicationUserServiceApplication 都要加上。

4.6.Controller 中如何使用

例如“获取当前登录用户信息”接口,可以直接从 ThreadLocal 中拿:

@Operation(summary = "获取当前登录用户信息")
@GetMapping("/auth/getCurrentUserInfo")
public Result<UserInfoVo> getCurrentUserInfo() {
    UserInfo userInfo = AuthThreadLocalUtils.get();
    UserInfoVo vo = new UserInfoVo();
    vo.setNickName(userInfo.getNickName());
    vo.setAvatar(userInfo.getAvatar());
    return Result.build(vo, ResultCodeEnum.SUCCESS);
}

这样一来,控制层就不再需要手动:

  • 从请求头取 token
  • 再查 Redis
  • 再自己解析 JSON

这就是“统一鉴权后,业务层更轻”的实际体现。

到这里,整个登录鉴权链路已经闭环,建议一定要自己顺着请求走一遍:👇👇

  1. 用户先登录
  2. user-service 生成 token,并把登录信息写入 Redis
  3. 前端后续访问受保护接口时,把 token 放入请求头
  4. 请求先进入 zx-gateway
  5. AuthGlobalFilter 校验 token
  6. 校验通过后,把 userInfo 写进请求头
  7. 请求到达 product-serviceuser-service
  8. MyFilter 读取请求头中的 userInfo
  9. 将用户信息写入 ThreadLocal
  10. 控制器或业务层直接从 AuthThreadLocalUtils 获取当前用户

在上一节里,我们已经完成了登录鉴权、网关透传和 ThreadLocal 保存当前用户。
从这一节开始,这些“身份能力”终于要真正参与业务了。

最典型的两个场景就是:

  1. 收货地址必须知道“是谁在维护自己的地址”
  2. 商品收藏必须知道“是谁收藏了哪一个商品”

因此,本篇既是在继续开发商城业务,也是在训练你把“当前登录用户”真正用进业务代码中。

3.收货地址模块

登录成功后,用户最先会接触到的“个人数据”通常就是收货地址。
它看起来只是一个表单,但里面其实包含了很多典型业务规则:

  • 数据只能操作当前登录用户自己的
  • 必须维护默认地址
  • 省市区联动需要单独接口支持
  • 保存时不能只存编码,还要拼出完整地址,方便后续订单直接展示

前言

3.1.需求分析

先看页面效果,再决定接口怎么设计。

收货地址列表页面
收货地址列表页面
编辑收货地址页面
编辑收货地址页面

本节最终要完成 5 个能力:

  1. 查询当前用户地址列表
  2. 根据 id 查询地址详情
  3. 根据 parentCode 查询下级地区,实现三级联动
  4. 新增或修改收货地址
  5. 删除收货地址

为什么这里要先拆成这么多接口?

因为前端页面虽然只看到一个“地址管理”,但背后其实是多个动作:

  • 打开页面,需要先拉地址列表
  • 点击编辑,需要拉一条地址详情
  • 切换省份,需要继续拉市列表
  • 点提交,才是保存或修改

把动作拆开,后端职责会更清晰,排错也更容易。

3.2.查询当前用户地址列表

先做列表接口,因为它是地址页的入口。

3.2.1.接口说明

GET /api/user/userAddress/auth/findUserAddressList
请求头:
token: 当前登录用户令牌

这里路径里带有 auth,表示它必须经过网关登录校验。
用户没有登录,就不应该看到自己的地址数据。

3.2.2.UserAddress 实体

@Data
@Schema(description = "用户地址实体类")
@TableName("user_address")
public class UserAddress extends BaseEntity {

    private Long userId;
    private String name;
    private String phone;
    private String tagName;
    private String provinceCode;
    private String cityCode;
    private String districtCode;
    private String address;
    private String fullAddress;
    private Integer isDefault;
}

字段里最容易忽略的是 fullAddress
它不是“多余字段”,而是为了让订单页、地址列表页可以直接展示完整地址,而不用每次再去拼省市区名称。

3.2.1.UserAddressController

@RestController
@RequestMapping("user/userAddress")
public class UserAddressController {

    @Resource
    private UserAddressService userAddressService;

    //#获取用户地址列表
    @GetMapping("auth/findUserAddressList")
    public Result<List<UserAddress>> findUserAddressList() {
        List<UserAddress> list = userAddressService.findListByCurrentUserId();
        return Result.build(list, ResultCodeEnum.SUCCESS);
    }

    //#根据id获取用户地址详情
    @GetMapping("auth/getById/{id}")
    public Result<UserAddress> getById(@PathVariable Long id) {
        UserAddress userAddress = userAddressService.getCurrentUserAddressById(id);
        return Result.build(userAddress, ResultCodeEnum.SUCCESS);
    }
}

3.2.2.UserAddressService

public interface UserAddressService extends IService<UserAddress> {

    List<UserAddress> findListByCurrentUserId();

    UserAddress getCurrentUserAddressById(Long id);
}

这里顺手把“按当前用户查询详情”的方法也先定义出来,后面编辑地址时直接复用。

3.2.3.UserAddressServiceImpl

@Service
public class UserAddressServiceImpl
        extends ServiceImpl<UserAddressMapper, UserAddress>
        implements UserAddressService {

    @Override
    public List<UserAddress> findListByCurrentUserId() {
        Long userId = AuthThreadLocalUtils.getThreadLocal().getId();
        return lambdaQuery()
                .eq(UserAddress::getUserId, userId)
                .orderByDesc(UserAddress::getIsDefault)
                .orderByDesc(BaseEntity::getUpdateTime)
                .list();
    }

    @Override
    public UserAddress getCurrentUserAddressById(Long id) {
        Long userId = AuthThreadLocalUtils.getThreadLocal().getId();
        return lambdaQuery()
                .eq(BaseEntity::getId, id)
                .eq(UserAddress::getUserId, userId)
                .one();
    }
}

3.3.省市区三级联动

地址编辑页里最典型的交互就是“选省 -> 拉市 -> 拉区县”。
这就是三级联动。

它为什么要单独设计接口,而不是一次把全国所有地区都查出来?

  • 全国地区数据量不小,一次性全量下发浪费带宽
  • 前端真正需要的只是“当前节点的下一级”
  • 后端按 parentCode 查询,下拉框加载速度更稳定

2.3.1.接口说明

GET /api/user/region/findByParentCode/{parentCode}
三级联动
三级联动

2.3.2.Region 实体

@Data
@Schema(description = "区域实体类")
public class Region extends BaseEntity {

    private String code;
    private String parentCode;
    private String name;
    private Integer level;
}

2.3.3.RegionController

@RestController
@RequestMapping("user/region")
public class RegionController {

    @Resource
    private RegionService regionService;

    @GetMapping("findByParentCode/{parentCode}")
    public Result<List<Region>> findByParentCode(@PathVariable String parentCode) {
        List<Region> list = regionService.findByParentCode(parentCode);
        return Result.build(list, ResultCodeEnum.SUCCESS);
    }
}

2.3.4.RegionService

public interface RegionService extends IService<Region> {

    List<Region> findByParentCode(String parentCode);
}

2.3.5.RegionServiceImpl

@Service
public class RegionServiceImpl
        extends ServiceImpl<RegionMapper, Region>
        implements RegionService {

    @Override
    public List<Region> findByParentCode(String parentCode) {
        return lambdaQuery()
                .eq(Region::getParentCode, parentCode)
                .orderByAsc(Region::getCode)
                .list();
    }

}

3.4.新增收货地址

新增地址真正难的不是“把一条记录插进去”,而是处理默认地址规则。

业务要求通常是:

  1. 用户至少要有一个默认地址
  2. 如果当前新增地址被设为默认,就要把旧默认地址取消
  3. 保存前要把省、市、区、详细地址拼成 fullAddress

2.4.1.接口说明

POST /api/user/userAddress/auth/save
image
image

2.4.2.UserAddressController

@PostMapping("auth/save")
public Result<String> saveAddress(@RequestBody UserAddress userAddress) {
    String res = userAddressService.saveAddress(userAddress);
    return Result.build(res, ResultCodeEnum.SUCCESS);
}

2.4.3.UserAddressService

public interface UserAddressService extends IService<UserAddress> {

    List<UserAddress> findListByCurrentUserId();

    UserAddress getCurrentUserAddressById(Long id);

    String saveAddress(UserAddress userAddress);

    String updateAddressById(UserAddress userAddress);

    String removeAddressById(Long id);
}

2.4.4.UserAddressServiceImpl

@Service
public class UserAddressServiceImpl
        extends ServiceImpl<UserAddressMapper, UserAddress>
        implements UserAddressService {

    @Resource
    private RegionService regionService;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public String saveAddress(UserAddress userAddress) {
        Long userId = AuthThreadLocalUtils.get().getId();

        userAddress.setUserId(userId);
        userAddress.setFullAddress(buildFullAddress(userAddress));

        UserAddress defaultAddress = getDefaultAddress(userId);

        if (defaultAddress == null) {
            userAddress.setIsDefault(1);
            save(userAddress);
            return "新增成功";
        }

        if (Objects.equals(userAddress.getIsDefault(), 1)) {
            clearDefaultAddress(userId, null);
        } else {
            userAddress.setIsDefault(0);
        }

        save(userAddress);
        return "新增成功";
    }

    private UserAddress getDefaultAddress(Long userId) {
        return lambdaQuery()
                .eq(UserAddress::getUserId, userId)
                .eq(UserAddress::getIsDefault, 1)
                .one();
    }

    private void clearDefaultAddress(Long userId, Long excludeId) {
        LambdaUpdateChainWrapper<UserAddress> wrapper = lambdaUpdate()
                .set(UserAddress::getIsDefault, 0)
                .eq(UserAddress::getUserId, userId);
        if (excludeId != null) {
            wrapper.ne(BaseEntity::getId, excludeId);
        }
        wrapper.update();
    }

    private String buildFullAddress(UserAddress userAddress) {
        Region province = regionService.getByCode(userAddress.getProvinceCode());
        Region city = regionService.getByCode(userAddress.getCityCode());
        Region district = regionService.getByCode(userAddress.getDistrictCode());
        return province.getName()
                + city.getName()
                + district.getName()
                + userAddress.getAddress();
    }
}

上面这段逻辑可以按顺序理解:

  1. ThreadLocal 中拿到当前用户
  2. 根据地区编码查询省、市、区名称
  3. 组装出 fullAddress
  4. 判断当前用户是否已有默认地址
  5. 没有默认地址,本次新增自动设为默认
  6. 有默认地址且本次勾选默认,就把旧默认取消

这样设计后,默认地址规则就不会失控。

新增地址测试
新增地址测试

3.5.修改收货地址

修改地址时需要同时保证三件事:

  1. 只能修改当前用户自己的地址
  2. 不能让用户最终一个默认地址都没有
  3. 如果把当前地址设为默认,要先取消其他默认地址

2.5.1.UserAddressController

@PutMapping("auth/updateById")
public Result<String> updateAddressById(@RequestBody UserAddress userAddress) {
    String res = userAddressService.updateAddressById(userAddress);
    return Result.build(res, ResultCodeEnum.SUCCESS);
}

2.5.2.UserAddressServiceImpl

 @Override
    @Transactional(rollbackFor = Exception.class)
    public String updateAddressById(UserAddress userAddress) {
        //1.获得用户id
        Long userId = AuthThreadLocalUtils.get().getId();
        //2.通过地址id查询 地址
        UserAddress dbAddress = getCurrentUserAddressById(userAddress.getId());
        if (dbAddress == null) {
            throw new CustomException(ResultCodeEnum.ACCOUNT_STOP);
        }
        //3.如果取消默认地址,则判断该用户是否还有默认地址
        if (Objects.equals(userAddress.getIsDefault(), 0)) {
            long defaultCount = lambdaQuery()
                    .eq(UserAddress::getUserId, userId)
                    .eq(UserAddress::getIsDefault, 1)
                    .count();
            if (defaultCount < 1) {
                throw new CustomException(ResultCodeEnum.USER_ADDRESS_EXISTS);
            }
        }
        //4.如果设置默认地址,则取消该用户其他地址的默认地址
        if (Objects.equals(userAddress.getIsDefault(), 1)) {
            clearDefaultAddress(userId, userAddress.getId());
        }

        userAddress.setUserId(userId);
        userAddress.setFullAddress(buildFullAddress(userAddress));
        updateById(userAddress);
        return "更新成功";
    }

3.6.删除收货地址

删除操作看起来很简单,但也要遵守业务规则。

最常见的约束是:

  • 默认地址不允许直接删除
  • 删除时同样要校验用户归属

2.6.1.UserAddressController

@DeleteMapping("auth/removeById/{id}")
public Result<String> removeAddressById(@PathVariable Long id) {
    String res = userAddressService.removeAddressById(id);
    return Result.build(res, ResultCodeEnum.SUCCESS);
}

2.6.2.UserAddressServiceImpl

@Override
public String removeAddressById(Long id) {
    UserAddress userAddress = getCurrentUserAddressById(id);
    if (userAddress == null) {
        throw new CustomException(ResultCodeEnum.USER_ADDRESS_NOT_EXISTS);
    }
    if (Objects.equals(userAddress.getIsDefault(), 1)) {
        throw new CustomException(ResultCodeEnum.USER_ADDRESS_EXISTS);
    }
    removeById(id);
    return "删除成功";
}

3.7.Long 精度问题为什么会影响地址修改

这也是原笔记里提到但学生经常一知半解的地方。

问题本质是:

  • Java 的 Long 没问题
  • 但前端 JavaScriptNumber 对超大整数精度有限
  • 如果后端把 Long id 直接按数字返回,前端拿到后可能发生精度丢失

一旦 id 变了,修改接口再把错误的 id 传回来,就会出现:

  • 查不到记录
  • 或者修改到错误数据

常见处理方式是在基础实体的 id 上统一加字符串序列化:

@JsonSerialize(using = ToStringSerializer.class)
private Long id;

如果你的 zx-commonzx-model 已经统一处理过这一点,就不需要在每个实体里重复写。

4.收藏模块

收藏功能比购物车简单,但它非常适合练习“当前用户 + 业务表 + 远程调用”这三个点如何配合。

它的核心问题有两个:

  1. 当前用户是否收藏过这个商品
  2. 我的收藏页如何展示商品名称、图片、价格这些不在 user_collect 表里的数据

前言

4.1.需求分析

先看页面行为:

收藏按钮效果
收藏按钮效果
我的收藏页面
我的收藏页面

用户在商品详情页可能发生 4 个动作:

  1. 点击收藏
  2. 再点一次取消收藏
  3. 打开详情页时判断当前商品是否已收藏
  4. 打开“我的收藏”分页查看全部收藏商品

4.2.收藏与取消收藏

4.2.1.接口设计说明

接口文档

4.2.2.UserCollectController

@RestController
@RequestMapping("user/userinfo")
public class UserCollectController {

    @Resource
    private UserCollectService userCollectService;

    @GetMapping("auth/collect/{skuId}")
    public Result<String> collect(@PathVariable Long skuId) {
        return userCollectService.collect(skuId);
    }

    @GetMapping("auth/cancelCollect/{skuId}")
    public Result<String> cancelCollect(@PathVariable Long skuId) {
        return userCollectService.cancelCollect(skuId);
    }

    @GetMapping("isCollect/{skuId}")
    public Result<Boolean> isCollect(@PathVariable Long skuId) {
        return userCollectService.isCollect(skuId);
    }
}

4.2.2.UserCollectService

public interface UserCollectService extends IService<UserCollect> {

    Result<String> collect(Long skuId);

    Result<String> cancelCollect(Long skuId);

    Result<Boolean> isCollect(Long skuId);

    PageInfo<UserCollectVo> findUserCollectPage(Integer pageNum, Integer pageSize);
}

4.2.3.UserCollectServiceImpl

@Service
public class UserCollectServiceImpl
        extends ServiceImpl<UserCollectMapper, UserCollect>
        implements UserCollectService {

    @Override
    public Result<String> collect(Long skuId) {
        Long userId = AuthThreadLocalUtils.get().getId();

        UserCollect one = lambdaQuery()
                .eq(UserCollect::getUserId, userId)
                .eq(UserCollect::getSkuId, skuId)
                .one();
        if (one != null) {
            return Result.build("已收藏", ResultCodeEnum.SUCCESS);
        }

        UserCollect collect = new UserCollect();
        collect.setUserId(userId);
        collect.setSkuId(skuId);
        save(collect);
        return Result.build("收藏成功", ResultCodeEnum.SUCCESS);
    }

    @Override
    public Result<String> cancelCollect(Long skuId) {
        Long userId = AuthThreadLocalUtils.get().getId();
        LambdaQueryWrapper<UserCollect> wrapper = Wrappers.lambdaQuery(UserCollect.class)
                .eq(UserCollect::getUserId, userId)
                .eq(UserCollect::getSkuId, skuId);
        remove(wrapper);
        return Result.build("取消收藏成功", ResultCodeEnum.SUCCESS);
    }

    @Override
    public Result<Boolean> isCollect(Long skuId) {
        UserInfo userInfo = AuthThreadLocalUtils.get();
        if (userInfo == null) {
            return Result.build(false, ResultCodeEnum.SUCCESS);
        }
        long count = lambdaQuery()
                .eq(UserCollect::getUserId, userInfo.getId())
                .eq(UserCollect::getSkuId, skuId)
                .count();
        return Result.build(count > 0, ResultCodeEnum.SUCCESS);
    }
}

4.3.我的收藏为什么必须远程调用商品服务

user_collect 表里只记录了两类信息:

  • 谁收藏了
  • 收藏了哪个 skuId

但收藏列表页真正要展示的是:

  • 商品名称
  • 商品主图
  • 商品价格
image
image

这些都属于商品域数据,应该由 product-service 负责。

所以正确做法不是在 user-service 里直接跨表查商品表,而是:

  1. 先从 user_collect 中查出当前页的 skuId
  2. 再远程调用商品服务批量拿商品信息
  3. 最后组装成收藏列表返回给前端
收藏列表远程调用流程
收藏列表远程调用流程

接口文档

4.3.1.分页接口

@GetMapping("auth/findUserCollectPage/{pageNum}/{pageSize}")
public Result<PageInfo<UserCollectVo>> findUserCollectPage(@PathVariable Integer pageNum,
                                                           @PathVariable Integer pageSize) {
    PageInfo<UserCollectVo> page = userCollectService.findUserCollectPage(pageNum, pageSize);
    return Result.build(page, ResultCodeEnum.SUCCESS);
}

4.3.2.分页查询我的收藏

@Override
public PageInfo<UserCollectVo> findUserCollectPage(Integer pageNum, Integer pageSize) {
    if (pageNum == null || pageSize == null) {
        throw new CustomException(ResultCodeEnum.PARAM_ERROR);
    }

    Long userId = AuthThreadLocalUtils.getThreadLocal().getId();

    Page<UserCollect> page = new Page<>(pageNum, pageSize);
    Page<UserCollect> collectPage = lambdaQuery()
            .eq(UserCollect::getUserId, userId)
            .orderByDesc(BaseEntity::getCreateTime)
            .page(page);

    List<UserCollect> records = collectPage.getRecords();
    if (records.isEmpty()) {
        PageInfo<UserCollectVo> empty = new PageInfo<>(Collections.emptyList());
        empty.setPageNum(pageNum);
        empty.setPageSize(pageSize);
        empty.setTotal(0);
        empty.setPages(0);
        return empty;
    }

    List<Long> skuIds = records.stream().map(UserCollect::getSkuId).toList();

    //调用feign接口,批量查询商品信息
    List<ProductSku> skuList =new ArrayList<>();

    Map<Long, ProductSku> skuMap = skuList.stream()
            .collect(Collectors.toMap(ProductSku::getId, sku -> sku));
    
    List<UserCollectVo> voList = records.stream()
            .map(record -> {
                ProductSku sku = skuMap.get(record.getSkuId());
                if (sku == null) {
                    return null;
                }
                UserCollectVo vo = new UserCollectVo();
                vo.setSkuId(sku.getId());
                vo.setSkuName(sku.getSkuName());
                vo.setThumbImg(sku.getThumbImg());
                vo.setSalePrice(sku.getSalePrice());
                vo.setCreateTime(record.getCreateTime());
                vo.setUpdateTime(record.getUpdateTime());
                return vo;
            })
            .filter(Objects::nonNull)
            .toList();

    PageInfo<UserCollectVo> pageInfo = new PageInfo<>(voList);
    pageInfo.setPageNum(pageNum);
    pageInfo.setPageSize(pageSize);
    pageInfo.setTotal(collectPage.getTotal());
    pageInfo.setPages((int) collectPage.getPages());
    return pageInfo;
}

4.3.1.商品服务提供批量查询接口

product-service

public interface ProductSkuService extends IService<ProductSku> {

    List<ProductSku> getSkuListBySkuIds(List<Long> ids);
}

@Service
public class ProductSkuServiceImpl
        extends ServiceImpl<ProductSkuMapper, ProductSku>
        implements ProductSkuService {

    @Override
    public List<ProductSku> getSkuListBySkuIds(List<Long> ids) {
        return lambdaQuery()
                .in(BaseEntity::getId, ids)
                .list();
    }
}

@RestController
@RequestMapping("productSku")
public class ProductSkuController {

    @Resource
    private ProductSkuService productSkuService;

    @PostMapping("getSkuListBySkuIds")
    public List<ProductSku> getSkuListBySkuIds(@RequestParam("ids") List<Long> ids) {
        return productSkuService.getSkuListBySkuIds(ids);
    }
}

4.3.2.用户服务定义 FeignClient

@FeignClient(value = "product-service")
public interface ProductSkuFeignClient {

    @PostMapping("productSku/getSkuListBySkuIds")
    List<ProductSku> getSkuListBySkuIds(@RequestParam("ids") List<Long> ids);
}

启动类别忘了开启:

@EnableFeignClients(basePackages = "com.zx.user.feignclient")