05_进销存系统_功能实现
本章节将逐一实现系统的核心功能模块。我们将按照 登录 -> 仪表盘 -> 分类 -> 商品 -> 客户 -> 订单 的顺序进行开发。
在实现过程中,我们会结合实际场景,适时讲解 DTO/VO、MyBatis-Plus 分页、全局时间格式化 以及 常用注解 等核心概念。
一、登录模块
登录是系统的入口,主要功能是验证用户身份并创建会话。我们首先通过这个模块来熟悉 Controller 的基本写法和常用注解。
1.1 常用注解说明
在下面的代码中,你会看到许多“陌生”的注解。为了让你不再困惑,这里整理了最常用的几个:
1. Knife4j (接口文档) 注解
用于生成在线接口文档 (http://localhost:8080/doc.html)。
@Tag(name = "xxx"): 用在 Controller 类上,表示这个类是关于“xxx”模块的(例如:登录模块)。@Operation(summary = "xxx"): 用在 方法上,说明这个接口是干什么的(例如:用户登录)。@Schema(name = "SysUser",description="系统用户信息"): 用在 实体类上,说明这个类是干什么的。@Schema(name = "id",title = "主键",description = "自动生成用户唯一标识"): 用在 实体类属性上,说明这个属性是干什么的。
2. Spring MVC 注解
@RestController: 标记这是一个控制器,并且所有方法返回 JSON 数据。@PostMapping/@GetMapping: 定义 HTTP 请求方式。@RequestBody: 接收前端传递的 JSON 参数。
1.2 全局配置 (逻辑删除 & 时间格式)
为了简化开发,我们在 application.yml 中进行全局配置,解决两个常见问题:
- 逻辑删除:MyBatis-Plus 自动处理
is_deleted字段,查询时自动过滤,删除时自动更新。 - 时间格式化:Jackson 自动将
LocalDateTime格式化为yyyy-MM-dd HH:mm:ss,无需在每个字段上加注解。
配置文件 (application.yml):
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
mybatis-plus:
global-config:
db-config:
logic-delete-field: isDeleted # 全局逻辑删除字段名
logic-delete-value: 1 # 删除状态的值
logic-not-delete-value: 0 # 未删除状态的值
1.3 接口实现 (LoginController)
@RestController
@RequestMapping("/api")
@Tag(name = "登录模块") // Knife4j: 模块名称
public class LoginController {
@Autowired
private SysUserMapper sysUserMapper;
@PostMapping("/login")
@Operation(summary = "用户登录") // Knife4j: 接口描述
public Result<SysUser> login(@RequestBody SysUser loginUser, HttpSession session) {
// 1. 构建查询条件:WHERE username = ?
QueryWrapper<SysUser> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", loginUser.getUsername());
// 2. 查询数据库
SysUser user = sysUserMapper.selectOne(queryWrapper);
// 3. 校验密码
if (user != null && user.getPassword().equals(loginUser.getPassword())) {
// 登录成功,将用户信息存入 Session
session.setAttribute("user", user);
return Result.success("登录成功", user);
}
return Result.fail(401, "登录失败:用户名或密码错误");
}
@GetMapping("/logout")
@Operation(summary = "用户登出")
public Result<Object> logout(HttpSession session) {
session.invalidate(); // 销毁 Session
return Result.success("退出成功", null);
}
}
1.4 登录拦截器
为了防止未登录用户访问后台接口,我们需要配置拦截器。
拦截器实现 (LoginInterceptor):
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 放行 OPTIONS 请求 (跨域预检)
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
HttpSession session = request.getSession();
SysUser user = (SysUser) session.getAttribute("user");
if (user == null) {
response.setStatus(401);
response.setContentType("application/json;charset=utf-8");
response.getWriter().write("{\"code\":401, \"message\":\"未登录,请先登录\"}");
return false;
}
return true;
}
}
注册拦截器 (WebConfig):
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/api/**") // 拦截所有接口
.excludePathPatterns("/api/login", "/api/logout"); // 放行登录登出
}
}
二、仪表盘模块
仪表盘展示系统的关键指标:商品总数、今日订单数、总销售额。
2.1 核心概念:VO (View Object)
在开发中,我们通常不会直接返回数据库实体对象 (Entity) 给前端,而是定义专门的 VO (视图对象)。
- Entity: 对应数据库表结构(如
SaleOrder表)。 - VO: 对应前端页面展示需求(如
DashboardVO聚合了多个表的数据)。
2.2 定义 VO (DashboardVO)
@Data
public class DashboardVO {
@Schema(description = "商品总数") // Knife4j: 字段描述
private Long productCount;
@Schema(description = "今日订单数")
private Long todayOrderCount;
@Schema(description = "今日销售额")
private BigDecimal totalSales;
}
2.3 业务逻辑 (DashboardServiceImpl)
Service 层负责处理复杂的业务逻辑,Controller 层只负责接收参数和返回结果。
@Service
public class DashboardServiceImpl implements DashboardService {
@Autowired
private ProductMapper productMapper;
@Autowired
private SaleOrderMapper saleOrderMapper;
@Override
public DashboardVO getStats() {
DashboardVO vo = new DashboardVO();
// 1. 统计商品总数 (MyBatis-Plus 会自动过滤 is_deleted=1 的数据)
vo.setProductCount(productMapper.selectCount(null));
// 2. 统计今日订单
// 获取今天的开始和结束时间
LocalDateTime start = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);
LocalDateTime end = LocalDateTime.of(LocalDate.now(), LocalTime.MAX);
vo.setTodayOrderCount(saleOrderMapper.selectCount(new QueryWrapper<SaleOrder>()
.between("create_time", start, end)));
// 3. 统计总销售额 (需要自定义SQL)
vo.setTotalSales(saleOrderMapper.selectTotalSales());
return vo;
}
}
2.4 数据层实现 (SaleOrderMapper)
由于统计总销售额涉及到求和操作,这是 MyBatis-Plus 默认方法无法直接覆盖的,我们需要在 Mapper 接口中使用注解或 XML 编写 SQL。
@Mapper
public interface SaleOrderMapper extends BaseMapper<SaleOrder> {
/**
* 统计所有有效订单的总金额
* 注意:使用 @Select 注解手写 SQL 时,MyBatis-Plus 的自动逻辑删除过滤不会生效,必须手动添加 WHERE is_deleted = 0
* @return 总销售额
*/
@Select("SELECT IFNULL(SUM(total_amount), 0) FROM sale_order WHERE is_deleted = 0 AND status != 2")
BigDecimal selectTotalSales();
}
2.5 接口实现 (DashboardController)
@RestController
@RequestMapping("/api/dashboard")
@Tag(name = "仪表盘")
public class DashboardController {
@Autowired
private DashboardService dashboardService;
@GetMapping("/stats")
@Operation(summary = "获取统计数据")
public Result<DashboardVO> getStats() {
// 调用 Service 层方法
return Result.success(dashboardService.getStats());
}
}
三、分类管理 (逻辑删除)
3.1 实体类 (Category)
得益于我们在 1.2 节 配置的全局逻辑删除,我们只需要在实体类字段上加上 @TableLogic 注解即可。
- 查询时:MP 自动追加
WHERE is_deleted = 0。 - 删除时:MP 自动执行
UPDATE ... SET is_deleted = 1。
@Data
@TableName("category")
public class Category {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
priavte Integer sort;
private String description;
private Integer status;
@Schema(name = "updateTime", title = "更新时间", description = "分类信息最后修改时间", readOnly = true)
private LocalDateTime updateTime;
@TableLogic // 核心注解:标记这是逻辑删除字段
@JsonIgnore // 核心注解:前端不需要看到这个字段
private Integer isDeleted;
}
3.2 接口实现 (CategoryController)
MyBatis-Plus 会自动处理逻辑删除,调用 removeById 实际上执行的是 UPDATE。
@RestController
@RequestMapping("/api/categories")
@Tag(name = "分类管理")
public class CategoryController {
@Autowired
private CategoryService categoryService;
@GetMapping
@Operation(summary = "获取所有分类")
public Result<List<Category>> list() {
// 自动追加 WHERE is_deleted = 0
return Result.success(categoryService.list());
}
@PostMapping
@Operation(summary = "添加分类")
public Result<Object> add(@RequestBody Category category) {
return categoryService.save(category) ? Result.success(null) : Result.fail("添加失败");
}
@PutMapping
@Operation(summary = "更新分类")
public Result<Object> update(@RequestBody Category category) {
return categoryService.updateById(category) ? Result.success(null) : Result.fail("更新失败");
}
@DeleteMapping("/{id}")
@Operation(summary = "删除分类")
public Result<Object> delete(@PathVariable Long id) {
// 自动执行 UPDATE category SET is_deleted=1 WHERE id=? AND is_deleted=0
return categoryService.removeById(id) ? Result.success(null) : Result.fail("删除失败");
}
}
四、商品管理 (分页插件与关联查询)
商品列表需要分页展示,并且需要显示“分类名称”(数据库只有 category_id),这是学习 分页配置 和 关联查询 的好机会。
4.1 核心概念:MyBatis-Plus 分页配置
如果不配置分页插件,调用分页接口时 total(总记录数)将始终为 0,分页功能失效。
创建 config/MybatisPlusConfig.java:
@Configuration // 标记为配置类
public class MybatisPlusConfig {
/**
* 添加分页拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 指定数据库类型为 MySQL,开启分页功能
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
4.2 定义 ProductVO
我们需要在商品列表中展示分类名称。
@Data
public class ProductVO {
private Long id;
private String name;
private BigDecimal price;
private Integer stock;
@Schema(description = "分类名称 (数据库中无此字段)")
private String categoryName;
private Integer status; //状态
// Jackson注解:格式化时间
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createTime;
}
4.3 数据层实现 (ProductMapper)
关联查询也需要我们自定义 SQL。
1. Mapper 接口 (ProductMapper.java):
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
/**
* 分页查询商品列表(包含分类名称)
* @param page 分页参数
* @param name 商品名称 (可选)
* @param categoryId 分类ID (可选)
* @return 分页结果
*/
IPage<ProductVO> selectProductList(Page<ProductVO> page,
@Param("name") String name,
@Param("categoryId") Long categoryId);
}
2. Mapper XML (ProductMapper.xml):
编写 SQL 语句,通过 LEFT JOIN 关联分类表。
<select id="selectProductList" resultType="com.atguigu.liteims.vo.ProductVO">
SELECT p.*, c.name AS category_name
FROM product p
LEFT JOIN category c ON p.category_id = c.id
WHERE p.is_deleted = 0
<if test="name != null"> AND p.name LIKE CONCAT('%', #{name}, '%') </if>
<if test="categoryId != null"> AND p.category_id = #{categoryId} </if>
ORDER BY p.create_time DESC
</select>
4.3 业务逻辑层 (ProductService)
虽然 MyBatis-Plus 提供了基础的 CRUD,但对于自定义的分页查询,我们建议封装在 Service 层,保持 Controller 的整洁。
1. Service 接口 (ProductService.java):
public interface ProductService extends IService<Product> {
/**
* 分页查询商品(包含分类名称)
*/
IPage<ProductVO> getProductList(Page<ProductVO> page, String name, Long categoryId);
}
2. Service 实现类 (ProductServiceImpl.java):
@Service
public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product> implements ProductService {
@Autowired
private ProductMapper productMapper;
@Override
public IPage<ProductVO> getProductList(Page<ProductVO> page, String name, Long categoryId) {
// 直接调用 Mapper 自定义方法
return productMapper.selectProductList(page, name, categoryId);
}
}
4.4 接口实现 (ProductController)
@RestController
@RequestMapping("/api/products")
@Tag(name = "商品管理")
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping
@Operation(summary = "分页查询商品")
public Result<IPage<ProductVO>> list(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
String name, Long categoryId) {
Page<ProductVO> pageParam = new Page<>(page, size);
// 调用 Service 层方法
IPage<ProductVO> result = productService.getProductList(pageParam, name, categoryId);
return Result.success(result);
}
@PostMapping
@Operation(summary = "添加商品")
public Result<Object> add(@RequestBody Product product) {
return productService.save(product) ? Result.success(null) : Result.fail("添加失败");
}
@PutMapping
@Operation(summary = "更新商品")
public Result<Object> update(@RequestBody Product product) {
return productService.updateById(product) ? Result.success(null) : Result.fail("更新失败");
}
@DeleteMapping("/{id}")
@Operation(summary = "删除商品")
public Result<Object> delete(@PathVariable Long id) {
return productService.removeById(id) ? Result.success(null) : Result.fail("删除失败");
}
}
五、客户管理 (标准CRUD)
客户管理是标准的单表增删改查,用于巩固前面的知识。
5.1 接口实现 (CustomerController)
@RestController
@RequestMapping("/api/customers")
@Tag(name = "客户管理")
public class CustomerController {
@Autowired
private CustomerService customerService;
@GetMapping
@Operation(summary = "获取客户列表")
public Result<List<Customer>> list() {
return Result.success(customerService.list());
}
@PostMapping
@Operation(summary = "添加客户")
public Result<Object> add(@RequestBody Customer customer) {
return customerService.save(customer) ? Result.success(null) : Result.fail("添加失败");
}
@PutMapping
@Operation(summary = "更新客户")
public Result<Object> update(@RequestBody Customer customer) {
return customerService.updateById(customer) ? Result.success(null) : Result.fail("更新失败");
}
@DeleteMapping("/{id}")
@Operation(summary = "删除客户")
public Result<Object> delete(@PathVariable Long id) {
return customerService.removeById(id) ? Result.success(null) : Result.fail("删除失败");
}
}
六、订单管理 (DTO与事务)
订单模块最为复杂,涉及 DTO 接收复杂参数 和 事务控制。
6.1 核心概念:DTO (Data Transfer Object)
为什么需要 DTO?
在开发“创建订单”功能时,我们会发现一个问题:前端传递的 JSON 参数结构,与数据库中的任何一张表都无法直接对应。
- 前端参数结构:是一个复杂的嵌套对象,包含“谁买的” (
customerId) 和 “买了什么” (items数组)。 - 数据库表结构:订单数据被拆分到了
sale_order(主表) 和sale_order_item(明细表) 两张表中。
如果强行使用 SaleOrder 实体类来接收参数,就无法接收 items 列表数据。因此,我们需要定义一个专门用于数据传输的对象 —— DTO (Data Transfer Object)。
DTO 的作用就是根据前端接口的参数需求,量身定制一个 Java 类,用于接收和封装数据,然后再由 Service 层将其拆解并保存到对应的数据库表中。
OrderDTO 定义:
@Data
@Schema(description = "创建订单参数")
public class OrderDTO {
@Schema(description = "客户ID")
private Long customerId;
@Schema(description = "系统用户ID")
private Long userId;
@Schema(description = "商品明细列表")
private List<OrderItemDTO> items;
@Data
public static class OrderItemDTO {
@Schema(description = "商品ID")
private Long productId;
@Schema(description = "购买数量")
private Integer quantity;
}
}
6.2 订单列表查询 (关联查询)
订单列表需要显示客户名称,这也是一个典型的关联查询场景。
1. 定义 VO (SaleOrderVO)
@Data
public class SaleOrderVO {
private Long id;
private String orderNo;
private BigDecimal totalAmount;
private Integer status; // 0-待处理, 1-已完成, 2-已取消
private Long customerId; //客户ID
@Schema(description = "客户名称")
private String customerName;
private Long userId; //系统用户ID
private String userName; //系统用户名称
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createTime;
}
2. Mapper 接口 (SaleOrderMapper.java)
在之前的基础上,添加分页查询方法。
/**
* 分页查询订单列表
*/
IPage<SaleOrderVO> findPage(IPage<SaleOrderVO> pageParam, String orderNo);
/**
* 根据订单id查询订单项列表
* @param orderId 订单id
* @return 订单项列表
*/
List<SaleOrderItem> selectItemByOrderId(Long orderId);
3. Mapper XML (SaleOrderMapper.xml)
<resultMap id="SaleOrderVoResultMap" type="com.atguigu.liteims.vo.SaleOrderVO">
<id property="id" column="id"/>
<result property="orderNo" column="order_no"/>
<result property="customerId" column="customer_id"/>
<result property="customerName" column="customer_name"/>
<result property="userId" column="user_id"/>
<result property="userName" column="username"/>
<result property="totalAmount" column="total_amount"/>
<result property="status" column="status"/>
<result property="createTime" column="create_time"/>
<collection property="items" column="id" select="selectItemByOrderId"/>
</resultMap>
<select id="findPage" resultMap="SaleOrderVoResultMap">
SELECT o.*, c.name AS customer_name,u.username,u.id as userId
FROM sale_order o
LEFT JOIN customer c ON o.customer_id = c.id
LEFT JOIN sys_user u ON o.user_id = u.id
WHERE o.is_deleted = 0
<if test="orderNo != null and orderNo != ''">
AND o.order_no LIKE CONCAT('%', #{orderNo}, '%')
</if>
ORDER BY o.create_time DESC
</select>
<select id="selectItemByOrderId" resultType="SaleOrderItem">
select * from sale_order_item where order_id=#{orderId}
</select>
6.4 业务逻辑 (SaleOrderServiceImpl)
订单创建需要保证数据的一致性:主表插入、明细表插入、库存扣减必须同时成功或同时失败。这就需要用到 事务 (@Transactional)。
@Service
public class SaleOrderServiceImpl extends ServiceImpl<SaleOrderMapper, SaleOrder> implements SaleOrderService {
@Autowired
private ProductMapper productMapper;
@Autowired
private SaleOrderItemMapper saleOrderItemMapper;
@Autowired
SaleOrderMapper saleOrderMapper;
@Override
public IPage<SaleOrderVO> findPage(Integer page, Integer size, String orderNo) {
Page<SaleOrderVO> pageParam = new Page<>(page, size);
return saleOrderMapper.findPage(pageParam, orderNo);
}
@Override
@Transactional(rollbackFor = Exception.class) // 开启事务,任何异常都回滚
public void createOrder(OrderDTO orderDTO) {
// 1. 创建订单主表对象
SaleOrder order = new SaleOrder();
order.setOrderNo(UUID.randomUUID().toString().substring(0, 16)); // 生成随机订单号
order.setCustomerId(orderDTO.getCustomerId());
order.setUserId(orderDTo.getUserId());
order.setTotalAmount(BigDecimal.ZERO); // 先初始化为 0,防止数据库非空校验失败
order.setCreateTime(LocalDateTime.now());
order.setStatus(0);
// 插入主表 (MyBatis-Plus 会自动将生成的 ID 回填到 order 对象中)
baseMapper.insert(order);
BigDecimal totalAmount = BigDecimal.ZERO;
// 2. 遍历商品列表
for (OrderDTO.OrderItemDTO itemDTO : orderDTO.getItems()) {
// 2.1 查询商品并检查库存
Product product = productMapper.selectById(itemDTO.getProductId());
if (product.getStock() < itemDTO.getQuantity()) {
throw new RuntimeException("库存不足: " + product.getName()); // 抛出异常,触发事务回滚
}
// 2.2 扣减库存
product.setStock(product.getStock() - itemDTO.getQuantity());
productMapper.updateById(product);
// 2.3 保存订单明细
SaleOrderItem orderItem = new SaleOrderItem();
orderItem.setOrderId(order.getId()); // 使用主表回填的 ID
orderItem.setProductId(product.getId());
orderItem.setPrice(product.getPrice());
orderItem.setQuantity(itemDTO.getQuantity());
// 计算小计
orderItem.setAmount(product.getPrice().multiply(new BigDecimal(itemDTO.getQuantity())));
saleOrderItemMapper.insert(orderItem);
// 累加总金额
totalAmount = totalAmount.add(orderItem.getAmount());
}
// 3. 更新订单主表的总金额
order.setTotalAmount(totalAmount);
baseMapper.updateById(order);
}
@Override
public void updateStatus(Long id, Integer status) {
SaleOrder order = new SaleOrder();
order.setId(id);
order.setStatus(status);
baseMapper.updateById(order);
}
}
6.5 接口实现 (SaleOrderController)
@RestController
@RequestMapping("/api/orders")
@Tag(name = "订单管理")
public class SaleOrderController {
@Autowired
private SaleOrderService saleOrderService;
@GetMapping
@Operation(summary = "获取订单列表")
public Result<IPage<SaleOrderVO>> findPage(Integer page, Integer size,String orderNo){
return Result.success(saleOrderService.findPage(page, size, orderNo));
}
@PostMapping
@Operation(summary = "创建订单")
public Result<Object> createOrder(@RequestBody OrderDTO orderDTO,HttpSession session){
SysUser sysUser = (SysUser)session.getAttribute("user");
orderDTO.setUserId(sysUser.getId());
saleOrderService.createOrder(orderDTO);
return Result.success("订单创建成功", null);
}
@PutMapping("/{id}/status")
@Operation(summary = "更新订单状态")
public Result<Object> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
saleOrderService.updateStatus(id, status);
return Result.success(null);
}
}
七、系统日志 (AOP切面)
为了方便排查问题,我们希望记录每个接口的请求参数和响应耗时。使用 AOP (面向切面编程) 可以将这些通用逻辑与业务逻辑分离。
7.1 核心概念:AOP
- 切面 (Aspect): 封装通用逻辑的类(如日志记录)。
- 切点 (Pointcut): 定义在哪些地方执行切面逻辑(如
controller包下的所有方法)。 - 通知 (Advice): 具体的执行逻辑(如方法执行前后打印日志)。
7.2 日志切面实现 (LogAspect)
@Aspect
@Component
@Slf4j
public class LogAspect {
// 定义切点:com.atguigu.liteims.controller 包下的所有方法
@Pointcut("execution(* com.atguigu.liteims.controller..*.*(..))")
public void controllerPointcut() {}
// 环绕通知:在目标方法执行前后都执行
@Around("controllerPointcut()")
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
// 1. 获取请求信息
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
log.info("Request: {} {} - Args: {}",
request.getMethod(),
request.getRequestURI(),
Arrays.toString(joinPoint.getArgs()));
}
// 2. 执行目标方法
Object result = joinPoint.proceed();
// 3. 计算耗时
long timeTaken = System.currentTimeMillis() - startTime;
log.info("Response: {} ms", timeTaken);
return result;
}
}
至此,我们的进销存系统后端核心功能已全部实现!
评论