部分效果

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

一、引言:二手商品模块的核心地位

在校园综合服务平台中,二手交易是两大核心模块之一。作为商品模块的负责人,我负责整个二手商品的生命周期管理:从用户发布闲置,到商品上架展示,再到交易完成下架。

这个模块看似简单,实则涉及多个关键技术点:

  • 商品信息的完整录入(标题、描述、分类、价格、图片);
  • 商品状态的流转管理(在售、已售出、下架);
  • 与订单模块的联动(支付成功后自动下架);
  • 与收藏模块的关联(用户收藏/取消收藏)。

本文将从代码层面详细讲解二手闲置商品发布与管理的完整实现。

二、数据库设计:商品表的"极简主义"

2.1 商品表结构设计

商品表(shangpin)是二手交易模块的核心表,我在设计时遵循"够用就好"的原则:

CREATE TABLE `shangpin` (
  `id` int NOT NULL AUTO_INCREMENT,
  `user_id` int NOT NULL,                    -- 发布者ID
  `title` varchar(200) NOT NULL,             -- 商品标题
  `description` text,                        -- 商品描述
  `category` varchar(50),                    -- 商品分类
  `image` varchar(255),                      -- 商品图片URL
  `original_price` decimal(10, 2),           -- 原价
  `sale_price` decimal(10, 2) NOT NULL,      -- 售价
  `status` int DEFAULT 1,                    -- 状态:1-在售 0-已下架
  `create_time` datetime,                    -- 创建时间
  `update_time` datetime,                    -- 更新时间
  PRIMARY KEY (`id`),
  INDEX `idx_user_id`(`user_id`),
  INDEX `idx_category`(`category`)
);

2.2 设计决策分析

决策1:为什么用 sale_price 而不是 price

数据库字段用 sale_price(下划线命名),但 Java 实体中用 price 属性,通过 @TableField 注解映射:

@TableField("sale_price")
private Double price;

这样设计的好处是:

  • 数据库字段语义更清晰(明确是"售价");
  • Java 代码中用 price 更简洁;
  • MyBatis-Plus 的 map-underscore-to-camel-case 会自动处理 user_id -> userId 的转换,但 sale_price 是特殊情况,需要显式注解。

决策2:状态字段为什么用 int

status int DEFAULT 1 用数字表示状态:

  • 1:在售(默认值);
  • 0:已下架。

用数字的好处是:

  • 数据库查询效率更高;
  • 排序时更直观;
  • 状态值较少,用数字足够表达。

决策3:图片字段为什么只存一个?

image varchar(255) 只存一个图片 URL。这是因为:

  • 实训项目时间有限,单图上传已满足基本需求;
  • 多图上传需要额外处理(多张图片的存储和展示);
  • 如果需要扩展多图,可以改为存 JSON 数组或创建独立的图片表。

三、实体层设计:Goods 实体类

3.1 Goods 实体完整代码

package com.hlw.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("shangpin")
public class Goods {
    @TableId(type = IdType.AUTO)
    private Integer id;
    private Integer userId;
    private String title;
    private String description;
    private String category;
    private String image;
    private Double originalPrice;
    @TableField("sale_price")
    private Double price;
    private Integer status;
    private String createTime;
    private String updateTime;
}

3.2 关键注解解析

注解作用使用场景
@TableName("shangpin")指定映射的数据库表名表名不是驼峰命名时必须指定
@TableId(type = IdType.AUTO)指定主键生成策略为自增主键是自增主键时使用
@TableField("sale_price")指定字段映射字段名与属性名不一致时使用
@DataLombok 注解,自动生成 getter/setter简化代码
@NoArgsConstructorLombok 注解,生成无参构造器MyBatis-Plus 需要
@AllArgsConstructorLombok 注解,生成全参构造器方便创建对象

3.3 字段映射关系

数据库字段Java 属性类型说明
ididInteger主键,自增
user_iduserIdInteger发布者ID(驼峰自动转换)
titletitleString商品标题
descriptiondescriptionString商品描述
categorycategoryString商品分类
imageimageString商品图片URL
original_priceoriginalPriceDouble原价(驼峰自动转换)
sale_pricepriceDouble售价(需显式注解)
statusstatusInteger状态
create_timecreateTimeString创建时间
update_timeupdateTimeString更新时间

四、Mapper 层:继承 BaseMapper 实现零 SQL

4.1 GoodsMapper 接口

package com.hlw.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hlw.entity.Goods;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface GoodsMapper extends BaseMapper<Goods> {
}

4.2 BaseMapper 的"魔法"

继承 BaseMapper<Goods> 后,GoodsMapper 自动获得以下方法:

方法功能对应 SQL
insert(T entity)插入一条记录INSERT INTO …
deleteById(Serializable id)根据ID删除DELETE FROM … WHERE id = ?
updateById(T entity)根据ID更新UPDATE … SET … WHERE id = ?
selectById(Serializable id)根据ID查询SELECT * FROM … WHERE id = ?
selectList(QueryWrapper<T> queryWrapper)条件查询列表SELECT * FROM … WHERE …
selectPage(Page<T> page, QueryWrapper<T> queryWrapper)分页查询SELECT * FROM … WHERE … LIMIT ?, ?

无需手写一行 SQL,这就是 MyBatis-Plus 的强大之处!

五、Service 层:业务逻辑的封装

5.1 GoodsService 接口

package com.hlw.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.hlw.entity.Goods;

public interface GoodsService extends IService<Goods> {
}

5.2 GoodsServiceImpl 实现类

package com.hlw.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hlw.entity.Goods;
import com.hlw.mapper.GoodsMapper;
import com.hlw.service.GoodsService;
import org.springframework.stereotype.Service;

import java.text.SimpleDateFormat;
import java.util.Date;

@Service
public class GoodsServiceImpl extends ServiceImpl<GoodsMapper, Goods> implements GoodsService {
    
    @Override
    public boolean save(Goods goods) {
        goods.setStatus(1);
        String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
        goods.setCreateTime(now);
        goods.setUpdateTime(now);
        return super.save(goods);
    }

    @Override
    public boolean updateById(Goods goods) {
        goods.setUpdateTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
        return super.updateById(goods);
    }
}

5.3 业务逻辑增强

为什么要重写 saveupdateById 方法?

  1. save 方法增强

    • 自动设置 status = 1(在售);
    • 自动设置 createTimeupdateTime 为当前时间;
    • 这样前端不需要传这些字段,减少出错概率。
  2. updateById 方法增强

    • 自动更新 updateTime 为当前时间;
    • 记录每次修改的时间戳,便于追踪。

5.4 Service 层的设计原则

我在开发时遵循以下原则:

  • 不泄露 Mapper 细节:Controller 只调用 Service,不直接操作 Mapper;
  • 业务逻辑集中:所有业务规则(如状态设置、时间戳)都在 Service 层实现;
  • 复用 BaseService:继承 IService 获得分页、批量操作等能力。

六、Controller 层:RESTful API 的实现

6.1 GoodsController 完整代码

package com.hlw.controller;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hlw.common.Result;
import com.hlw.entity.Goods;
import com.hlw.service.GoodsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/goods")
public class GoodsController {
    @Autowired
    private GoodsService goodsService;

    @GetMapping("/list")
    public Result list(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size,
                       @RequestParam(required = false) String category, @RequestParam(required = false) String keyword,
                       @RequestParam(required = false) Integer user_id) {
        QueryWrapper<Goods> wrapper = new QueryWrapper<>();
        wrapper.eq("status", 1);
        if (user_id != null) {
            wrapper.eq("user_id", user_id);
        }
        if (category != null && !category.isEmpty()) {
            wrapper.eq("category", category);
        }
        if (keyword != null && !keyword.isEmpty()) {
            wrapper.like("title", keyword);
        }
        wrapper.orderByDesc("create_time");
        IPage<Goods> iPage = goodsService.page(new Page<>(page, size), wrapper);
        return Result.success(iPage);
    }

    @GetMapping("/{id}")
    public Result getById(@PathVariable Integer id) {
        return Result.success(goodsService.getById(id));
    }

    @PostMapping
    public Result save(@RequestBody Goods goods) {
        goodsService.save(goods);
        return Result.success("发布成功");
    }

    @PutMapping
    public Result update(@RequestBody Goods goods) {
        goodsService.updateById(goods);
        return Result.success("更新成功");
    }

    @DeleteMapping("/{id}")
    public Result delete(@PathVariable Integer id) {
        goodsService.removeById(id);
        return Result.success("删除成功");
    }

    @PutMapping("/offline/{id}")
    public Result offline(@PathVariable Integer id) {
        Goods goods = goodsService.getById(id);
        if (goods != null) {
            goods.setStatus(0);
            goodsService.updateById(goods);
            return Result.success("下架成功");
        }
        return Result.fail("商品不存在");
    }

    @GetMapping("/category")
    public Result category() {
        return Result.success(new String[]{"数码产品", "图书教材", "服饰鞋包", "生活用品", "运动户外", "其他"});
    }
}

6.2 API 接口详解

6.2.1 商品列表接口(GET /goods/list)

这是最复杂的接口,支持多条件筛选和分页:

@GetMapping("/list")
public Result list(@RequestParam(defaultValue = "1") int page, 
                   @RequestParam(defaultValue = "10") int size,
                   @RequestParam(required = false) String category, 
                   @RequestParam(required = false) String keyword,
                   @RequestParam(required = false) Integer user_id) {
    QueryWrapper<Goods> wrapper = new QueryWrapper<>();
    wrapper.eq("status", 1);
    if (user_id != null) {
        wrapper.eq("user_id", user_id);
    }
    if (category != null && !category.isEmpty()) {
        wrapper.eq("category", category);
    }
    if (keyword != null && !keyword.isEmpty()) {
        wrapper.like("title", keyword);
    }
    wrapper.orderByDesc("create_time");
    IPage<Goods> iPage = goodsService.page(new Page<>(page, size), wrapper);
    return Result.success(iPage);
}

参数说明

参数类型必填默认值说明
pageint1页码
sizeint10每页条数
categoryString-分类筛选
keywordString-关键词搜索
user_idInteger-用户ID(查用户发布的商品)

查询逻辑

  1. 默认只查 status = 1(在售)的商品;
  2. 如果传入 user_id,按用户筛选;
  3. 如果传入 category,按分类筛选;
  4. 如果传入 keyword,模糊搜索标题;
  5. 按创建时间倒序排列(最新发布的在前)。

返回结果

{
    "code": 200,
    "msg": "操作成功",
    "data": {
        "records": [...],
        "total": 100,
        "size": 10,
        "current": 1,
        "pages": 10
    }
}
6.2.2 商品详情接口(GET /goods/{id})
@GetMapping("/{id}")
public Result getById(@PathVariable Integer id) {
    return Result.success(goodsService.getById(id));
}

返回结果

{
    "code": 200,
    "msg": "操作成功",
    "data": {
        "id": 1,
        "userId": 1,
        "title": "iPhone 12 95新",
        "description": "自用iPhone 12,95新,电池健康89%",
        "category": "数码产品",
        "image": "/uploads/xxx.jpg",
        "originalPrice": 5999.00,
        "price": 1500.00,
        "status": 1,
        "createTime": "2024-01-15 10:30:00",
        "updateTime": "2024-01-15 10:30:00"
    }
}
6.2.3 发布商品接口(POST /goods)
@PostMapping
public Result save(@RequestBody Goods goods) {
    goodsService.save(goods);
    return Result.success("发布成功");
}

请求体

{
    "userId": 1,
    "title": "iPhone 12 95新",
    "description": "自用iPhone 12,95新,电池健康89%",
    "category": "数码产品",
    "image": "/uploads/xxx.jpg",
    "originalPrice": 5999.00,
    "price": 1500.00
}

注意statuscreateTimeupdateTime 不需要传,Service 层会自动设置。

6.2.4 更新商品接口(PUT /goods)
@PutMapping
public Result update(@RequestBody Goods goods) {
    goodsService.updateById(goods);
    return Result.success("更新成功");
}
6.2.5 删除商品接口(DELETE /goods/{id})
@DeleteMapping("/{id}")
public Result delete(@PathVariable Integer id) {
    goodsService.removeById(id);
    return Result.success("删除成功");
}
6.2.6 商品下架接口(PUT /goods/offline/{id})
@PutMapping("/offline/{id}")
public Result offline(@PathVariable Integer id) {
    Goods goods = goodsService.getById(id);
    if (goods != null) {
        goods.setStatus(0);
        goodsService.updateById(goods);
        return Result.success("下架成功");
    }
    return Result.fail("商品不存在");
}

设计意图

  • 不直接删除商品,而是将状态改为 0(下架);
  • 这样商品数据仍然保留,便于后续统计和追溯;
  • 订单模块支付成功后会调用此接口。
6.2.7 分类列表接口(GET /goods/category)
@GetMapping("/category")
public Result category() {
    return Result.success(new String[]{"数码产品", "图书教材", "服饰鞋包", "生活用品", "运动户外", "其他"});
}

返回结果

{
    "code": 200,
    "msg": "操作成功",
    "data": ["数码产品", "图书教材", "服饰鞋包", "生活用品", "运动户外", "其他"]
}

七、商品状态管理:完整的状态流转

7.1 状态机设计

┌─────────────────────────────────────────────────────────────┐
│                    商品状态流转                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   [发布商品]              [用户主动下架]                      │
│        │                          │                          │
│        ▼                          ▼                          │
│   ┌─────────┐              ┌─────────┐                      │
│   │  在售   │  ──────────→ │  已下架  │                      │
│   │ status=1│   支付成功    │ status=0│                      │
│   └─────────┘              └─────────┘                      │
│        │                          │                          │
│        │                          │ [重新上架]                │
│        ▼                          │                          │
│   [用户浏览、搜索]                 │                          │
│   [用户收藏]                      │                          │
│   [用户下单]                      │                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

7.2 状态流转场景

场景触发条件状态变化实现方式
发布商品用户提交表单1(在售)Service.save() 自动设置
主动下架用户点击下架按钮10(已下架)PUT /goods/offline/{id}
支付下架订单支付成功10(已下架)OrderController.pay() 调用
重新上架用户点击上架按钮01(在售)PUT /goods 更新 status

八、与其他模块的联动

8.1 与订单模块的联动

订单模块支付成功后,会调用商品模块的下架接口:

// OrderController.java
@PutMapping("/pay/{id}")
public Result pay(@PathVariable Integer id) {
    Order order = orderService.getById(id);
    if (order != null) {
        order.setStatus("paid");
        orderService.updateById(order);
        // 支付成功后,将商品从市场下架(status=0)
        Goods goods = goodsService.getById(order.getGoodsId());
        if (goods != null) {
            goods.setStatus(0);
            goodsService.updateById(goods);
        }
        return Result.success("支付成功");
    }
    return Result.fail("订单不存在");
}

设计原则

  • 通过 Service 层调用,不是直接操作数据库;
  • 模块间解耦,订单模块只关心商品状态,不关心商品表结构;
  • 如果商品表结构变化,只需要改 GoodsService,订单模块不需要修改。

8.2 与收藏模块的关联

收藏模块(FavoriteController)通过 goods_id 关联商品:

// FavoriteController.java
@PostMapping
public Result save(@RequestBody Favorite favorite, HttpSession session) {
    User user = (User) session.getAttribute("user");
    if (user == null) {
        return Result.unauthorized();
    }
    QueryWrapper<Favorite> wrapper = new QueryWrapper<>();
    wrapper.eq("user_id", user.getId()).eq("goods_id", favorite.getGoodsId());
    if (favoriteService.getOne(wrapper) != null) {
        return Result.fail("已收藏");
    }
    favorite.setUserId(user.getId());
    favoriteService.save(favorite);
    return Result.success("收藏成功");
}

设计原则

  • 收藏表只存 user_idgoods_id,不存商品详情;
  • 显示收藏列表时,需要通过 goods_id 查询商品详情;
  • 避免数据冗余,商品信息变更时收藏记录不需要同步更新。

九、前端交互:商品模块的页面实现

9.1 发布商品页面

前端通过 AJAX 调用接口:

// 发布商品
$.ajax({
    url: '/goods',
    type: 'POST',
    contentType: 'application/json',
    data: JSON.stringify({
        userId: App.user.id,
        title: $('#title').val(),
        description: $('#description').val(),
        category: $('#category').val(),
        image: $('#image').val(),
        originalPrice: parseFloat($('#originalPrice').val()),
        price: parseFloat($('#price').val())
    }),
    success: function(res) {
        if (res.code === 200) {
            App.showToast('发布成功');
            window.location.href = 'goods-list.html';
        } else {
            App.showToast(res.msg);
        }
    }
});

9.2 商品列表页面

// 加载商品列表
function loadGoodsList(page) {
    var category = $('#categoryFilter').val();
    var keyword = $('#keyword').val();
    $.ajax({
        url: '/goods/list',
        type: 'GET',
        data: {
            page: page,
            size: 10,
            category: category,
            keyword: keyword
        },
        success: function(res) {
            if (res.code === 200) {
                renderGoods(res.data.records);
                renderPagination(res.data);
            }
        }
    });
}

9.3 商品详情页面

// 加载商品详情
function loadGoodsDetail(id) {
    $.ajax({
        url: '/goods/' + id,
        type: 'GET',
        success: function(res) {
            if (res.code === 200) {
                var goods = res.data;
                $('#goodsTitle').text(goods.title);
                $('#goodsDescription').text(goods.description);
                $('#goodsPrice').text('¥' + goods.price);
                $('#goodsImage').attr('src', goods.image);
            }
        }
    });
}

十、开发过程中的踩坑记录

10.1 坑1:字段映射错误

问题:前端传 salePrice,后端接收不到值。

原因:数据库字段是 sale_price,Java 属性是 price,前端应该传 price

解决方案:统一前端字段名与 Java 属性名一致。

10.2 坑2:分页插件未生效

问题:调用 goodsService.page() 返回的是全部数据,没有分页。

原因:MyBatis-Plus 的分页插件没有注册。

解决方案:在 MybatisPlusConfig.java 中注册分页插件:

@Configuration
public class MybatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

10.3 坑3:状态值不一致

问题:前端判断 status === 1,但有时返回 status === "1"(字符串)。

原因:数据库中 statusint 类型,但 JSON 序列化时可能转为字符串。

解决方案:前端统一用 parseInt(status) === 1 判断。

10.4 坑4:图片路径问题

问题:上传图片后,前端无法访问 /uploads/xxx.jpg

原因:Spring Boot 没有配置静态资源映射。

解决方案:在 FileUploadConfig.java 中配置:

@Configuration
public class FileUploadConfig implements WebMvcConfigurer {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/uploads/**").addResourceLocations("file:uploads/");
    }
}

十一、代码优化建议

11.1 当前代码的不足

  1. 缺少参数校验:Controller 没有对请求参数进行校验;
  2. 缺少异常处理:Service 层没有统一的异常捕获;
  3. 图片只支持单张:无法满足多图展示需求;
  4. 缺少浏览量统计:商品详情没有浏览量记录。

11.2 优化方案

// 优化后的 GoodsController(增加参数校验和异常处理)
@RestController
@RequestMapping("/goods")
public class GoodsController {
    @Autowired
    private GoodsService goodsService;

    @PostMapping
    public Result save(@RequestBody @Valid Goods goods) {
        try {
            goodsService.save(goods);
            return Result.success("发布成功");
        } catch (Exception e) {
            return Result.fail("发布失败:" + e.getMessage());
        }
    }

    @GetMapping("/{id}")
    public Result getById(@PathVariable @Min(1) Integer id) {
        Goods goods = goodsService.getById(id);
        if (goods == null) {
            return Result.fail("商品不存在");
        }
        return Result.success(goods);
    }
}

十二、总结

二手商品模块是校园综合服务平台的核心模块之一,本文从数据库设计、实体层、Mapper层、Service层、Controller层五个维度详细讲解了商品发布与管理的完整实现。

核心收获

  1. MyBatis-Plus 的强大:继承 BaseMapperIService 即可获得完整的 CRUD 能力,无需手写 SQL;
  2. 分层架构的优势:Controller 处理请求、Service 封装业务、Mapper 操作数据库,职责清晰;
  3. 状态管理的重要性:通过 status 字段控制商品生命周期,避免直接删除数据;
  4. 模块间解耦:通过 Service 层调用实现模块联动,降低耦合度。

作为组员,我深刻体会到:好的代码设计不仅要实现功能,还要考虑扩展性、可维护性和团队协作

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐