商品列表和商品详情页面的接口实现

首页接口和分类接口已经介绍完毕,接下来进行商品列表和商品详情页面的功能完善,需要实现商品搜索和商品详情页面的后端接口,本节的源代码是在 newbee-mall-cloud-dev-step13 工程的基础上改造的,将工程命名为 newbee-mall-cloud-dev-step13-2

接口传参解析及返回字段定义

获取用户输入的搜索关键字或分类 id,跳转到搜索结果页面,以及搜索结果页面渲染,这些都是前端开发人员来实现的。前端页面在实现这些效果时,需要获取当前页面要渲染的数据,也就是在跳转到搜索结果页面后,组装商品搜索的请求参数并向后端接口发送搜索请求,获得后端返回的数据后进行页面渲染。后端开发人员需要定义接口参数、接口地址、接口返回字段,后端接口需要接收前端传过来的参数,先根据参数查询数据,再组装数据并返回给前端,最后由前端进行渲染。

  1. 接口传参

    页面 UI 还原和样式编写是由前端开发人员来实现的,前端开发人员包括 Web 开发人员和移动端原生 App 开发人员,负责页面及交互。后端开发人员则需要实现一个列表数据的返回接口,根据用户输入的关键字或用户单击的分类来返回数据,即商品搜索接口。因此,搜索接口传参的两个重要参数为搜索关键字和商品的三级分类 id,将其分别定义为 keywordgoodsCategoryId,类型分别为 StringLong。另外,还有两个参数是排序方式 orderBy 和分页参数 pageNumber

    商品详情页面是让用户看到更多的商品信息,以便更好地进行选择和比较。不过,获取商品详情信息这个接口并不是一个复杂的逻辑,实现逻辑就是根据商品 id 查询商品表中的记录并返回给前端。

  2. 分页逻辑

    虽然只是返回一个列表,但是有一个隐藏的知识点,那就是该接口返回的数据条数可能很多,需要加入分页的逻辑。在展示搜索内容后,商品列表页面可以不断往上拉,数据会不断地加载进来,在移动端的实现效果就是人们常说的 “上拉加载”,这里就用到了分页逻辑。虽然没有分页页码和翻页按钮,但依然是分页展示的逻辑,移动端页面通常的做法就是如此,毕竟人们在移动端的操作习惯与在 PC 端的操作习惯不同,移动端页面的面积也比 PC 端页面的面积小了很多,不可能完全做成 PC 端的分页效果。

    分页数据还做了一个封装,由于所有的分页结果基本上都包括以下几个属性,因此分页结果集的数据格式定义如下(注:完整代码位于 ltd.common.cloud.newbee.dto.PageResult):

    //分页的通用结果类
    public class PageResult implements Serializable {
        //总记录数
        private int totalCount;
        //每页记录数
        private int pageSize;
    
        //总页码
        private int totalPage;
        //当前页的页码
        private int currPage;
        //当前页的所有数据列表
        private List<?> list;
    }

    在实现分页功能的返回对象 PageResult 中定义了以下 4 个参数,即当前页的所有数据列表、当前页的页码、总页码、总记录数,并将它们放入 Result 返回结果的 data 属性中,在商品搜索接口中我们最终得到的返回对象为 Result<PageResult<Lis<NewBeeMallSearchGoodsVO>>>,最外层是 Result 对象,这个不用多说,所有的接口返回类型都是 Result 类型,里面一层是 PageResult 对象,因为搜索接口需要返回分页信息,PageResult 对象中则是具体的当前页所需要的商品列表信息 List<NewBeeMallSearchGoodsVO>

    之后由前端开发人员直接读取对应的参数并对这些数据进行处理,这就是前后端进行数据交互时分页数据的格式定义,希望读者能够结合代码及实际的分页效果进行理解和学习。

  3. 返回数据格式

图 6-14 所示为商品列表页面和商品详情页面中需要渲染的内容。商品分页列表是一个 List 对象,还有分页功能,需要返回分页字段,因此最终接收的结果返回格式为 PageResult 对象,而列表的单项对象中的字段则需要通过图 6-14 中的内容进行确认。

image 2025 04 28 11 19 46 398
Figure 1. 图6-14 商品列表页面和商品详情页面中需要渲染的内容

通过图片可以看到商品预览图字段、商品标题字段、商品简介字段、商品价格字段。当然,这里通常会设计成可跳转的形式,即单击标题或预览图会跳转到对应的商品详情页面中,因此还需要一个商品实体的 id 字段。这样,返回数据的格式就得出来了,代码如下:

package ltd.goods.cloud.newbee.controller.vo;

import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.io.Serializable;

/**
 * 搜索列表页面商品 VO
 */
@Data
public class NewBeeMallSearchGoodsVO implements Serializable {

    @ApiModelProperty("商品 id")
    private Long goodsId;

    @ApiModelProperty("商品名称")
    private String goodsName;

    @ApiModelProperty("商品简介")
    private String goodsIntro;

    @ApiModelProperty("商品图片地址")
    private String goodsCoverImg;

    @ApiModelProperty("商品价格")
    private Integer sellingPrice;
}

商品详情页面需要展示的字段更多一些,数据格式如下:

/**
 * 商品详情页面 VO
 */
@Data
public class NewBeeMallGoodsDetailVO implements Serializable {

    @ApiModelProperty("商品 id")
    private Long goodsId;

    @ApiModelProperty("商品名称")
    private String goodsName;

    @ApiModelProperty("商品简介")
    private String goodsIntro;

    @ApiModelProperty("商品图片地址")
    private String goodsCoverImg;

    @ApiModelProperty("商品价格")
    private Integer sellingPrice;

    @ApiModelProperty("商品标签")
    private String tag;

    @ApiModelProperty("商品图片")
    private String[] goodsCarouselList;

    @ApiModelProperty("商品原价")
    private Integer originalPrice;

    @ApiModelProperty("商品详情内容")
    private String goodsDetailContent;
}

打开 newbee-mall-cloud-goods-web 工程,在 ltd.goods.cloud.newbee.controller.vo 包中增加上述两个 VO 类即可。

业务层代码的实现

获取商品列表的数据只涉及查询操作,因此业务层方法中定义了 searchNewBeeMallGoods() 方法用于查询分类页面中所需要的数据,查询到数据后进行分页对象的封装。查询商品详情数据则更加简单,根据主键查询对应的数据库记录即可,代码如下:

public PageResult searchNewBeeMallGoods(PageQueryUtil pageUtil) {
    List<NewBeeMallGoods> goodsList = goodsMapper.findNewBeeMallGoodsListBySearch(pageUtil);
    int total = goodsMapper.getTotalNewBeeMallGoodsBySearch(pageUtil);
    List<NewBeeMallSearchGoodsVO> newBeeMallSearchGoodsVOS = new ArrayList<NewBeeMallSearchGoodsVO>();
    if (!CollectionUtils.isEmpty(goodsList)) {
        List<NewBeeMallSearchGoodsVO> newBeeMallSearchGoodsVOS = BeanUtil.copyList(goodsList, NewBeeMallSearchGoodsVO.class);
        for (NewBeeMallSearchGoodsVO newBeeMallSearchGoodsVO : newBeeMallSearchGoodsVOS) {
            String goodsName = newBeeMallSearchGoodsVO.getGoodsName();
            String goodsIntro = newBeeMallSearchGoodsVO.getGoodsIntro();
            // 字符串过长导致文字超出的问题
            if (goodsName.length() > 28) {
                goodsName = goodsName.substring(0, 28) + "...";
                newBeeMallSearchGoodsVO.setGoodsName(goodsName);
            }
            if (goodsIntro.length() > 30) {
                goodsIntro = goodsIntro.substring(0, 30) + "...";
                newBeeMallSearchGoodsVO.setGoodsIntro(goodsIntro);
            }
        }
        PageResult pageResult = new PageResult(newBeeMallSearchGoodsVOS, total, pageUtil.getLimit(), pageUtil.getPage());
        return pageResult;
    }

    public NewBeeMallGoods getNewBeeMallGoodsById (Long id){
        NewBeeMallGoods newBeeMallGoods = goodsMapper.selectByPrimaryKey(id);
        if (newBeeMallGoods == null) {
            NewBeeMallException.fail(ServiceResultEnum.GOODS_NOT_EXIST.getResult());
        }
        return newBeeMallGoods;
    }
}

searchNewBeeMallGoods() 方法使用 PageQueryUtil 对象作为参数,商品类目 id、关键字 keyword 字段、分页所需的 page 字段、排序字段等都作为属性放在了这个对象中,关键字或商品类目 id 用来过滤想要的商品列表,page 字段用于确定查询第几页的数据,之后通过 SQL 语句查询对应的分页数据,并填充数据。某些字段太长导致页面上的展示效果不好,所以对这些字段进行了简单的字符串处理并设置到 NewBeeMallSearchGoodsVO 对象中,最终返回的数据是 PageResult 对象,包含当前页返回的商品列表数据和分页信息。

打开 newbee-mall-cloud-goods-web 工程,在 service.impl 包的 NewBeeMallCategoryServiceImpl 类中添加上述方法即可。

具体执行的 SQL 语句如下(注:完整代码位于 resources/mapper/NewBeeMallGoodsMapper.xml 文件中):

<select id="findNewBeeMallGoodsListBySearch" parameterType="Map" resultMap="BaseResultMap">
  select
    <include refid="Base_Column_List"/>
  from tb_newbee_mall_goods_info
  <where>
    <if test="keyword!=null and keyword!=''">
      and (goods_name like CONCAT('%',#{keyword},'%') or goods_intro like CONCAT('%',#{keyword},'%'))
    </if>
    <if test="goodsCategoryId!=null and goodsCategoryId!=''">
      and goods_category_id = #{goodsCategoryId}
    </if>
  </where>
  <if test="orderBy!=null and orderBy!=''">
    <choose>
      <when test="orderBy == 'new'">
        order by goods_id desc
      </when>
      <when test="orderBy == 'price'">
        order by selling_price asc
      </when>
      <otherwise>
        order by stock_num desc
      </otherwise>
    </choose>
  </if>
  <if test="start!=null and limit!=null">
    limit #{start},#{limit}
  </if>
</select>

<select id="getTotalNewBeeMallGoodsBySearch" parameterType="Map" resultType="int">
  select count(*) from tb_newbee_mall_goods_info
  <where>
    <if test="keyword!=null and keyword!=''">
      and (goods_name like CONCAT('%',#{keyword},'%') or goods_intro like CONCAT('%',#{keyword},'%'))
    </if>
    <if test="goodsCategoryId!=null and goodsCategoryId!=''">
      and goods_category_id = #{goodsCategoryId}
    </if>
  </where>
</select>

根据前端传过来的关键字和商品类目 id 对商品记录进行检索,使用 MySQL 数据库的 LIKE 语法对关键字进行过滤,或者根据 goods_category_id 字段对商品类目进行过滤,之后根据 orderBy 字段进行商品搜索分页结果的排序,最后使用 startlimit 两个分页所必需的关键字。

控制层代码的实现

增加商品列表接口的定义与编码。打开 newbee-mall-cloud-goods-web 工程,选择 ltd.goods.cloud.newbee.controller 包并单击鼠标右键,在弹出的快捷菜单中选择 “New→Java Class” 选项,在弹出的窗口中输入 “NewBeeMallGoodsController”,在 NewBeeMallGoodsController 类中新增如下代码:

package ltd.goods.cloud.newbee.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import ltd.common.cloud.newbee.enums.ServiceResultEnum;
import ltd.common.cloud.newbee.dto.PageQueryUtil;
import ltd.common.cloud.newbee.dto.PageResult;
import ltd.common.cloud.newbee.dto.Result;
import ltd.common.cloud.newbee.dto.ResultGenerator;
import ltd.common.cloud.newbee.exception.NewBeeMallException;
import ltd.common.cloud.newbee.pojo.MallUserToken;
import ltd.common.cloud.newbee.util.BeanUtil;
import ltd.goods.cloud.newbee.config.annotation.TokenToMallUser;
import ltd.goods.cloud.newbee.controller.vo.NewBeeMallGoodsDetailVO;
import ltd.goods.cloud.newbee.controller.vo.NewBeeMallSearchGoodsVO;
import ltd.goods.cloud.newbee.entity.NewBeeMallGoods;
import ltd.goods.cloud.newbee.service.NewBeeMallGoodsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestHeader;

import com.vi.mall.common.Result;
import com.vi.mall.goods.service.NewBeeMallGoodsService;
import com.vi.mall.goods.controller.vo.NewBeeMallSearchGoodsVO;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.springframework.beans.factory.annotation.Resource;

/**
 * @author vi
 * @since 2023/03/23 14:27
 */
@RestController
@Api(value = "vi", tags = "新蜂商城商品相关接口")
@RequestMapping("/goods/mall")
public class NewBeeMallGoodsController {

    private static final Logger logger = LoggerFactory.getLogger(NewBeeMallGoodsController.class);

    @Resource
    private NewBeeMallGoodsService newBeeMallGoodsService;

    @GetMapping("/search")
    @ApiOperation(value = "商品搜索接口", notes = "根据关键字和分类 id 进行搜索")
    public Result<PageResult<List<NewBeeMallSearchGoodsVO>>> search(@RequestParam(required = false) @ApiParam(value = "搜索关键字") String keyword,
                                                                    @RequestParam(required = false) @ApiParam(value = "分类 id") Long goodsCategoryId,
                                                                    @RequestParam(required = false) @ApiParam(value = "orderBy") String orderBy,
                                                                    @RequestParam(required = false) @ApiParam(value = "页码") Integer pageNumber,
                                                                    @RequestHeader("token") String loginMallUserToken) {
        logger.info("goods search api, keyword={}, goodsCategoryId={}, orderBy={}, pageNumber={}, userId={}", keyword, goodsCategoryId, orderBy, pageNumber, loginMallUserToken.getUserId());
        Map<String, Object> params = new HashMap<>(8);
        //两个搜索参数为空,直接返回异常
        if (goodsCategoryId == null && !StringUtils.hasText(keyword)) {
            NewBeeMallException.fail("非法搜索参数");
        }
        if (pageNumber == null || pageNumber < 1) {
            pageNumber = 1;
        }
        params.put("goodsCategoryId", goodsCategoryId);
        params.put("page", pageNumber);
        params.put("limit", 10);
        //对 keyword 做过滤 去掉空格
        if (StringUtils.hasText(keyword)) {
            params.put("keyword", keyword);
        }
        if (StringUtils.hasText(orderBy)) {
            params.put("orderBy", orderBy);
        }
        //搜索上架状态的商品
        params.put("goodsSellStatus", 0);
        //封装商品数据
        PageQueryUtil pageUtil = new PageQueryUtil(params);
        return ResultGenerator.genSuccessResult(newBeeMallGoodsService.searchNewBeeMallGoods(pageUtil));
    }

    @GetMapping("/detail/{goodsId}")
    @ApiOperation(value = "商品详情接口", notes = "传参为商品id")
    public Result<NewBeeMallGoodsDetailVO> goodsDetail(@ApiParam(value = "商品id") @PathVariable("goodsId") Long goodsId, @TokenToMallUser MallUserToken loginMallUserToken) {
        logger.info("detail api,goodsId={},userId={}", goodsId, loginMallUserToken.getUserId());
        if (goodsId < 1) {
            return ResultGenerator.genFailResult("参数异常");
        }
        NewBeeMallGoods goods = newBeeMallGoodsService.getNewBeeMallGoodsById(goodsId);
        if (goods == null || goods.getGoodsSellStatus() != 0) {
            NewBeeMallException.fail(ServiceResultEnum.GOODS_PUT_DOWN.getResult());
        }
        NewBeeMallGoodsDetailVO goodsDetailVO = new NewBeeMallGoodsDetailVO();
        BeanUtil.copyProperties(goods, goodsDetailVO);
        goodsDetailVO.setGoodsCarouselList(Arrays.asList(goods.getGoodsCarousel().split(",")));
        return ResultGenerator.genSuccessResult(goodsDetailVO);
    }
}

商品列表接口的方法名称为 search(),请求类型为 GET,路径映射为 /goods/mall/search,所有的传参都用 @RequestParam 注解进行接收,前端传过来的参数主要有:

  1. keyword;

  2. goodsCategoryId;

  3. orderBy;

  4. pageNumber.

参数 pageNumber 是分页所必需的字段,如果不传,则默认为第 1 页。参数 keyword 是关键字,用来过滤商品名称和商品简介。参数 goodsCategoryId 是用来过滤商品分类 id 的字段。参数 orderBy 是排序字段,传过来不同的排序方式,返回的数据也会不同。另外,还有一个参数是当前登录用户的信息,已经用 @TokenToMallUser 注解来接收,相关逻辑已经介绍过,这里不再赘述。根据以上字段进行查询参数的封装,之后通过 SQL 语句查询对应的分页数据 pageResult。响应结果类型为 Result,实际的 data 属性类型为 PageResult<List> 对象,即商品搜索结果的分页列表数据。

实现逻辑就是调用商品业务实现类 NewBeeMallGoodsService 中的查询方法,将所需的数据进行查询并响应给前端,所有的实现逻辑都是在业务实现类中处理的,包括查询和返回字段的内容设置。在控制层代码中,主要进行参数判断和参数的封装,以及将获得的数据结果设置给 Result 对象并返回。

商品详情接口的方法名称为 goodsDetail(),请求类型为 GET,路径映射为 /goods/mall/detail/{goodsId},参数 goodsId 就是商品主键 id,通过 @PathVariable 注解读取路径中的这个字段值,并根据这个字段值调用 NewBeeMallGoodsService 中的 getNewBeeMallGoodsById() 方法,获取 NewBeeMallGoods 对象。getNewBeeMallGoodsById() 方法的实现方式是根据主键 id 查询数据库中的商品表并返回商品实体数据,将查询到的商品详情数据转换为 NewBeeMallGoodsDetailVO 对象并返回给前端。因为在商品表中并不是所有的字段都需要返回,所以这里做了一次对象的转换。

商品接口网关配置

打开 newbee-mall-cloud-gateway-mall 项目中的 application.properties 文件,新增关于商品微服务的路由信息,配置项为 spring.cloud.gateway.routes.*,新增内容如下:

# 商品接口的路由配置
spring.cloud.gateway.routes[3].id=goods-service-route2
spring.cloud.gateway.routes[3].uri=lb://newbee-mall-cloud-goods-service
spring.cloud.gateway.routes[3].order=1
spring.cloud.gateway.routes[3].predicates[0]=Path=/goods/mall/**

这里配置 newbee-mall-cloud-gateway-mall 到商品微服务的路由信息,主要配置商品列表和商品详情页面的接口。如果访问商城端网关项目的路径是以 /goods/mall/ 开头的,就路由到商品微服务实例。