第 48 课:业务模块开发
本课目标:掌握通用CRUD服务封装、文件上传、分页查询、DTO/VO转换等常见业务开发技巧。
一、概念讲解
1.1 通用CRUD封装
传统方式:每个实体都写一套CRUD代码
User:create, findById, findAll, update, delete
Product:create, findById, findAll, update, delete
Order:create, findById, findAll, update, delete
→ 大量重复代码
通用封装:
BaseService<T>:create, findById, findAll, update, delete
User extends BaseService<User>
Product extends BaseService<Product>
→ 代码复用,减少重复
1.2 文件上传流程
前端上传 ──> 后端接收 ──> 文件校验 ──> 生成文件名 ──> 保存文件 ──> 返回URL
校验内容:
1. 文件大小限制
2. 文件类型限制
3. 文件名安全检查
4. 防止文件名冲突(UUID重命名)
1.3 分页查询原理
前端传递:page=1, size=10, keyword=手机
后端处理:
1. 构建Pageable对象
2. 执行分页查询(SELECT COUNT + SELECT 数据)
3. 封装返回结果
返回数据:
{
"records": [...], // 当前页数据
"total": 100, // 总记录数
"page": 1, // 当前页
"size": 10, // 每页大小
"totalPages": 10 // 总页数
}
二、语法格式
2.1 泛型Service基本结构
public interface BaseService<T> {
T save(T entity);
T findById(Long id);
List<T> findAll();
PageResult<T> findPage(int page, int size);
T update(Long id, T entity);
void delete(Long id);
}
2.2 文件上传API
@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) {
// 1. 校验文件
// 2. 保存文件
// 3. 返回URL
}
三、代码案例
3.1 通用Service接口
package com.example.demo.common.service;
import com.example.demo.common.result.PageResult;
import java.util.List;
public interface BaseService<T> {
T save(T entity);
T findById(Long id);
List<T> findAll();
PageResult<T> findPage(int page, int size);
T update(Long id, T entity);
void delete(Long id);
}
3.2 通用Service实现
package com.example.demo.common.service;
import com.example.demo.common.result.PageResult;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public abstract class BaseServiceImpl<T, R extends JpaRepository<T, Long>>
implements BaseService<T> {
protected final R repository;
protected BaseServiceImpl(R repository) {
this.repository = repository;
}
@Override
public T save(T entity) {
return repository.save(entity);
}
@Override
public T findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new RuntimeException("数据不存在"));
}
@Override
public List<T> findAll() {
return repository.findAll();
}
@Override
public PageResult<T> findPage(int page, int size) {
Pageable pageable = PageRequest.of(page - 1, size,
Sort.by(Sort.Direction.DESC, "id"));
Page<T> pageResult = repository.findAll(pageable);
return new PageResult<>(
pageResult.getContent(),
pageResult.getTotalElements(),
page,
size
);
}
@Override
public T update(Long id, T entity) {
if (!repository.existsById(id)) {
throw new RuntimeException("数据不存在");
}
return repository.save(entity);
}
@Override
public void delete(Long id) {
if (!repository.existsById(id)) {
throw new RuntimeException("数据不存在");
}
repository.deleteById(id);
}
}
3.3 商品模块实现
实体类
package com.example.demo.module.product.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "sys_product")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(columnDefinition = "INT DEFAULT 0")
private int stock;
private Long categoryId;
@Column(columnDefinition = "INT DEFAULT 1")
private int status;
private String description;
private String imageUrl;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@PrePersist
public void prePersist() {
this.createTime = LocalDateTime.now();
this.updateTime = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
this.updateTime = LocalDateTime.now();
}
public Product() {}
public Product(String name, BigDecimal price, int stock) {
this.name = name;
this.price = price;
this.stock = stock;
this.status = 1;
}
// getter/setter
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public int getStock() { return stock; }
public void setStock(int stock) { this.stock = stock; }
public Long getCategoryId() { return categoryId; }
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public LocalDateTime getCreateTime() { return createTime; }
public LocalDateTime getUpdateTime() { return updateTime; }
}
Repository
package com.example.demo.module.product.mapper;
import com.example.demo.module.product.entity.Product;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// 按名称搜索(分页)
Page<Product> findByNameContaining(String name, Pageable pageable);
// 按分类查询
List<Product> findByCategoryId(Long categoryId);
// 按价格范围查询
List<Product> findByPriceBetween(java.math.BigDecimal min, java.math.BigDecimal max);
// 按状态查询
Page<Product> findByStatus(int status, Pageable pageable);
// 自定义查询:搜索商品(名称或描述包含关键字)
@Query("SELECT p FROM Product p WHERE " +
"(:keyword IS NULL OR p.name LIKE %:keyword%) AND " +
"(:categoryId IS NULL OR p.categoryId = :categoryId)")
Page<Product> searchProducts(
@Param("keyword") String keyword,
@Param("categoryId") Long categoryId,
Pageable pageable);
// 库存扣减
@Query("UPDATE Product p SET p.stock = p.stock - :quantity WHERE p.id = :id AND p.stock >= :quantity")
int deductStock(@Param("id") Long id, @Param("quantity") int quantity);
}
DTO
package com.example.demo.module.product.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class ProductCreateDTO {
@NotBlank(message = "商品名称不能为空")
@Size(max = 100, message = "商品名称不超过100个字符")
private String name;
@NotNull(message = "价格不能为空")
@DecimalMin(value = "0.01", message = "价格必须大于0")
private BigDecimal price;
@Min(value = 0, message = "库存不能为负数")
private int stock;
private Long categoryId;
@Size(max = 500, message = "描述不超过500个字符")
private String description;
private String imageUrl;
}
Service
package com.example.demo.module.product.service;
import com.example.demo.common.result.PageResult;
import com.example.demo.common.service.BaseServiceImpl;
import com.example.demo.module.product.dto.ProductCreateDTO;
import com.example.demo.module.product.entity.Product;
import com.example.demo.module.product.mapper.ProductRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class ProductService
extends BaseServiceImpl<Product, ProductRepository> {
public ProductService(ProductRepository repository) {
super(repository);
}
// 创建商品
public Product create(ProductCreateDTO dto) {
Product product = new Product();
product.setName(dto.getName());
product.setPrice(dto.getPrice());
product.setStock(dto.getStock());
product.setCategoryId(dto.getCategoryId());
product.setDescription(dto.getDescription());
product.setImageUrl(dto.getImageUrl());
product.setStatus(1);
return repository.save(product);
}
// 搜索商品(分页)
@Transactional(readOnly = true)
public PageResult<Product> search(String keyword, Long categoryId,
int page, int size) {
Pageable pageable = PageRequest.of(page - 1, size,
Sort.by(Sort.Direction.DESC, "createTime"));
Page<Product> result = repository.searchProducts(
keyword, categoryId, pageable);
return new PageResult<>(
result.getContent(),
result.getTotalElements(),
page,
size
);
}
// 扣减库存
public void deductStock(Long productId, int quantity) {
int affected = repository.deductStock(productId, quantity);
if (affected == 0) {
throw new RuntimeException("库存不足");
}
}
}
Controller
package com.example.demo.module.product.controller;
import com.example.demo.common.result.PageResult;
import com.example.demo.common.result.Result;
import com.example.demo.module.product.dto.ProductCreateDTO;
import com.example.demo.module.product.entity.Product;
import com.example.demo.module.product.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
public Result<Product> create(@Valid @RequestBody ProductCreateDTO dto) {
return Result.success("创建成功", productService.create(dto));
}
@GetMapping("/{id}")
public Result<Product> getById(@PathVariable Long id) {
return Result.success(productService.findById(id));
}
@GetMapping
public Result<PageResult<Product>> list(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long categoryId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return Result.success(productService.search(keyword, categoryId, page, size));
}
@PutMapping("/{id}")
public Result<Product> update(@PathVariable Long id,
@Valid @RequestBody Product product) {
return Result.success("更新成功", productService.update(id, product));
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
productService.delete(id);
return Result.success("删除成功", null);
}
@PostMapping("/{id}/deduct")
public Result<Void> deductStock(@PathVariable Long id,
@RequestParam int quantity) {
productService.deductStock(id, quantity);
return Result.success("库存扣减成功", null);
}
}
3.4 文件上传Controller
package com.example.demo.common.controller;
import com.example.demo.common.result.Result;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/file")
public class FileController {
@Value("${file.upload-dir:./uploads}")
private String uploadDir;
@Value("${file.allowed-types:image/jpeg,image/png,image/gif,application/pdf}")
private String allowedTypes;
@Value("${file.max-size:10485760}") // 10MB
private long maxSize;
@PostMapping("/upload")
public Result<Map<String, String>> upload(
@RequestParam("file") MultipartFile file) {
// 1. 校验文件是否为空
if (file.isEmpty()) {
return Result.error(400, "请选择要上传的文件");
}
// 2. 校验文件大小
if (file.getSize() > maxSize) {
return Result.error(400, "文件大小不能超过10MB");
}
// 3. 校验文件类型
String contentType = file.getContentType();
if (contentType == null || !allowedTypes.contains(contentType)) {
return Result.error(400, "不支持的文件类型");
}
// 4. 生成唯一文件名
String originalFilename = file.getOriginalFilename();
String extension = "";
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
}
String newFilename = UUID.randomUUID().toString() + extension;
// 5. 保存文件
try {
File dir = new File(uploadDir);
if (!dir.exists()) {
dir.mkdirs();
}
File dest = new File(dir, newFilename);
file.transferTo(dest);
// 6. 返回文件访问路径
Map<String, String> result = new HashMap<>();
result.put("filename", newFilename);
result.put("originalFilename", originalFilename);
result.put("url", "/uploads/" + newFilename);
result.put("size", String.valueOf(file.getSize()));
return Result.success("上传成功", result);
} catch (IOException e) {
return Result.error(500, "文件上传失败:" + e.getMessage());
}
}
@PostMapping("/upload/multiple")
public Result<Map<String, Object>> uploadMultiple(
@RequestParam("files") MultipartFile[] files) {
int successCount = 0;
int failCount = 0;
for (MultipartFile file : files) {
if (!file.isEmpty()) {
successCount++;
} else {
failCount++;
}
}
Map<String, Object> result = new HashMap<>();
result.put("total", files.length);
result.put("success", successCount);
result.put("failed", failCount);
return Result.success("上传完成", result);
}
}
3.5 分页查询封装
package com.example.demo.common.query;
import lombok.Data;
@Data
public class PageQuery {
private int page = 1;
private int size = 10;
private String sortField = "id";
private String sortOrder = "desc";
// 构建Pageable
public org.springframework.data.domain.Pageable toPageable() {
org.springframework.data.domain.Sort sort =
"asc".equalsIgnoreCase(sortOrder)
? org.springframework.data.domain.Sort.by(sortField).ascending()
: org.springframework.data.domain.Sort.by(sortField).descending();
return org.springframework.data.domain.PageRequest.of(page - 1, size, sort);
}
}
3.6 分页查询示例
package com.example.demo.module.order.controller;
import com.example.demo.common.query.PageQuery;
import com.example.demo.common.result.PageResult;
import com.example.demo.common.result.Result;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// 使用通用分页查询
@GetMapping
public Result<PageResult<Map<String, Object>>> list(PageQuery query) {
Pageable pageable = query.toPageable();
// 模拟查询
// Page<Order> orders = orderRepository.findAll(pageable);
return Result.success(new PageResult<>(
new java.util.ArrayList<>(),
0,
query.getPage(),
query.getSize()
));
}
}
3.7 统一异常处理
package com.example.demo.common.exception;
import com.example.demo.common.result.Result;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleRuntimeException(RuntimeException ex) {
return Result.error(400, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Map<String, String>> handleValidationException(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errors.put(error.getField(), error.getDefaultMessage());
}
return Result.error(400, "参数校验失败");
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleMaxUploadSize(MaxUploadSizeExceededException ex) {
return Result.error(400, "文件大小超过限制");
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleException(Exception ex) {
return Result.error(500, "服务器内部错误");
}
}
四、常见错误
4.1 泛型继承后方法不生效
原因:子类没有正确传递Repository。
解决:确保子类构造函数调用super(repository)。
4.2 文件上传中文名乱码
原因:浏览器编码问题。
解决:使用UUID重命名文件,或配置server.tomcat.uri-encoding=UTF-8。
4.3 分页参数越界
现象:请求第0页或负数页。
解决:在toPageable()方法中处理边界情况。
4.4 @Transactional在私有方法上不生效
原因:Spring AOP只能拦截public方法。
解决:确保事务方法是public的。
五、课后练习
- 基于通用Service,实现一个文章(Article)模块的完整CRUD
- 实现文件上传接口,支持图片压缩和缩略图生成
- 封装一个通用的搜索接口,支持多条件组合查询
- 编写分页查询的单元测试,验证各种边界情况
六、本课小结
| 知识点 | 说明 |
|---|---|
| 通用Service | 泛型封装CRUD,减少重复代码 |
| 文件上传 | MultipartFile接收文件,校验+重命名+保存 |
| 分页查询 | Pageable封装分页参数,PageResult封装返回结果 |
| DTO/VO | DTO接收前端数据,VO返回给前端,Entity映射数据库 |
| 多条件搜索 | @Query自定义查询,支持关键字+分类+状态组合 |
| 库存扣减 | @Query UPDATE操作,乐观锁控制并发 |
本课程持续更新中,欢迎关注!
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_26950675/article/details/166601547



