二手闲置商品发布与管理实现——Spring Boot + MyBatis-Plus 实战
部分效果



一、引言:二手商品模块的核心地位
在校园综合服务平台中,二手交易是两大核心模块之一。作为商品模块的负责人,我负责整个二手商品的生命周期管理:从用户发布闲置,到商品上架展示,再到交易完成下架。
这个模块看似简单,实则涉及多个关键技术点:
- 商品信息的完整录入(标题、描述、分类、价格、图片);
- 商品状态的流转管理(在售、已售出、下架);
- 与订单模块的联动(支付成功后自动下架);
- 与收藏模块的关联(用户收藏/取消收藏)。
本文将从代码层面详细讲解二手闲置商品发布与管理的完整实现。
二、数据库设计:商品表的"极简主义"
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") | 指定字段映射 | 字段名与属性名不一致时使用 |
@Data | Lombok 注解,自动生成 getter/setter | 简化代码 |
@NoArgsConstructor | Lombok 注解,生成无参构造器 | MyBatis-Plus 需要 |
@AllArgsConstructor | Lombok 注解,生成全参构造器 | 方便创建对象 |
3.3 字段映射关系
| 数据库字段 | Java 属性 | 类型 | 说明 |
|---|---|---|---|
id | id | Integer | 主键,自增 |
user_id | userId | Integer | 发布者ID(驼峰自动转换) |
title | title | String | 商品标题 |
description | description | String | 商品描述 |
category | category | String | 商品分类 |
image | image | String | 商品图片URL |
original_price | originalPrice | Double | 原价(驼峰自动转换) |
sale_price | price | Double | 售价(需显式注解) |
status | status | Integer | 状态 |
create_time | createTime | String | 创建时间 |
update_time | updateTime | String | 更新时间 |
四、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 业务逻辑增强
为什么要重写 save 和 updateById 方法?
-
save方法增强:- 自动设置
status = 1(在售); - 自动设置
createTime和updateTime为当前时间; - 这样前端不需要传这些字段,减少出错概率。
- 自动设置
-
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);
}
参数说明:
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
page | int | 否 | 1 | 页码 |
size | int | 否 | 10 | 每页条数 |
category | String | 否 | - | 分类筛选 |
keyword | String | 否 | - | 关键词搜索 |
user_id | Integer | 否 | - | 用户ID(查用户发布的商品) |
查询逻辑:
- 默认只查
status = 1(在售)的商品; - 如果传入
user_id,按用户筛选; - 如果传入
category,按分类筛选; - 如果传入
keyword,模糊搜索标题; - 按创建时间倒序排列(最新发布的在前)。
返回结果:
{
"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
}
注意:status、createTime、updateTime 不需要传,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() 自动设置 |
| 主动下架 | 用户点击下架按钮 | 1 → 0(已下架) | PUT /goods/offline/{id} |
| 支付下架 | 订单支付成功 | 1 → 0(已下架) | OrderController.pay() 调用 |
| 重新上架 | 用户点击上架按钮 | 0 → 1(在售) | 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_id和goods_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"(字符串)。
原因:数据库中 status 是 int 类型,但 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 当前代码的不足
- 缺少参数校验:Controller 没有对请求参数进行校验;
- 缺少异常处理:Service 层没有统一的异常捕获;
- 图片只支持单张:无法满足多图展示需求;
- 缺少浏览量统计:商品详情没有浏览量记录。
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层五个维度详细讲解了商品发布与管理的完整实现。
核心收获:
- MyBatis-Plus 的强大:继承
BaseMapper和IService即可获得完整的 CRUD 能力,无需手写 SQL; - 分层架构的优势:Controller 处理请求、Service 封装业务、Mapper 操作数据库,职责清晰;
- 状态管理的重要性:通过
status字段控制商品生命周期,避免直接删除数据; - 模块间解耦:通过 Service 层调用实现模块联动,降低耦合度。
作为组员,我深刻体会到:好的代码设计不仅要实现功能,还要考虑扩展性、可维护性和团队协作。
更多推荐




所有评论(0)