商品管理

DTO包解释

先整体说一下这个类的作用

这是一个 DTO(Data Transfer Object,数据传输对象),专门用来接收前端传来的“保存商品”的完整数据

类比一下:

  • 就像你在电商后台“发布新商品”时,填的那张大表单(包含商品基本信息、颜色/尺码属性、库存价格等)
  • 这个类就是把那张表单的所有数据,打包成一个Java对象,方便后端接收和处理。

1. 包声明(第一行)

1
package com.easymall.entity.dto;
  • 作用:告诉Java这个类放在哪个“文件夹”里(逻辑上的文件夹)。
  • 含义拆解
    • com.easymall:公司/项目名(这里叫“易商城”)
    • entity:实体相关的大目录
    • dto:专门放“数据传输对象”的子目录(和数据库表对应的PO分开)
  • 小白理解:就像你的文件整理在 D:\易商城项目\实体类\传输数据包\ 下面。

2. 导入包(import 部分)

1
2
3
import com.easymall.entity.po.ProductInfo;
import com.easymall.entity.po.ProductPropertyValue;
import com.easymall.entity.po.ProductSku;
  • 作用:把别的“工具/类”借过来用。
  • 含义拆解
    • 这三个都是 po 包下的类(PO = Persistent Object,持久对象,也就是和数据库表一一对应的entity类):
      • ProductInfo:商品基本信息类(对应数据库的“商品主表”,存商品名、描述、图片等)
      • ProductPropertyValue:商品属性值类(对应“商品属性表”,存颜色、尺码等具体属性)
      • ProductSku:商品库存类(对应“商品SKU表”,存“红色+L码”对应的价格、库存等)
  • 小白理解:就像你要填表单,先把“基本信息栏”“属性栏”“库存栏”这三个小模块的模板拿过来。
1
2
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
  • 作用:导入参数校验的注解(用来检查前端传的数据合不合法)。
  • 含义拆解
    • @Valid:用来做级联校验(比如检查商品基本信息里的“商品名”有没有填)
    • @Size:用来检查集合/字符串的长度(比如规定属性列表至少得有1个)
  • 小白理解:就像表单里的“必填项提示”“至少选一个规格”的规则。

4. 字段部分(核心数据)

字段1:商品基本信息

1
2
@Valid
private ProductInfo productInfo;
  • 逐词解释
    • @Valid级联校验注解。意思是:不仅要检查 productInfo 这个对象本身有没有传,还要检查它里面的字段(比如 ProductInfo 里的“商品名”有没有空)。
    • private:私有的,只有这个类自己能直接访问(别的类要通过Getter/Setter访问)。
    • ProductInfo:类型是“商品基本信息类”(就是刚才import的PO)。
    • productInfo:字段名(小驼峰命名),存具体的商品基本信息数据。
  • 小白理解:这是表单里的“商品基本信息栏”,而且这一栏里的每一项(比如商品名)都必须填对。

字段2:商品属性列表

1
2
3
@Valid
@Size(min = 1)
private List<ProductPropertyValue> productPropertyList;
  • 逐词解释
    • @Valid:同样级联校验,检查每个属性里的字段(比如“属性值”有没有空)。
    • @Size(min = 1)长度校验。意思是:这个列表(List)里至少得有1个元素(也就是至少得填1个商品属性,比如颜色)。
    • List<ProductPropertyValue>:类型是“ProductPropertyValue的列表”,可以存多个商品属性(比如颜色、尺码、材质等)。
    • productPropertyList:字段名,存具体的属性列表数据。
  • 小白理解:这是表单里的“商品属性表格”,而且你至少得填一行属性(不能空着)。

字段3:商品SKU列表

1
2
3
@Valid
@Size(min = 1)
private List<ProductSku> skuList;
  • 逐词解释
    • 和上面几乎一样,@Valid 级联校验,@Size(min = 1) 至少1个SKU。
    • List<ProductSku>:类型是“ProductSku的列表”,存多个SKU组合(比如“红色+L码”“蓝色+M码”等,每个组合对应一个价格和库存)。
    • skuList:字段名,存具体的SKU列表数据。
  • 小白理解:这是表单里的“库存价格表格”,至少得有一个规格组合(不然商品没法卖)。

总结:这个类完整的工作流程

  1. 前端:在后台填好“发布商品”的表单(基本信息+属性+SKU),点击“保存”。
  2. 后端:接收到前端传来的JSON数据,自动转换成这个 ProductSaveDTO 对象。
  3. 校验:Java自动检查:
    • 基本信息里的必填项填了吗?(靠 @Valid
    • 属性列表至少有1个吗?(靠 @Size(min = 1)
    • SKU列表至少有1个吗?(靠 @Size(min = 1)
  4. 处理:校验通过后,后端再把这个DTO里的数据,拆成对应的PO,存到数据库里。

注解详细解释

1. @NotEmpty(核心校验注解)

出现位置productNameproductDesccovercategoryIdpCategoryId 字段

1
2
@NotEmpty
private String productName;
  • 来源包jakarta.validation.constraints.NotEmpty
  • 核心作用强制校验“不能为空”
    • 不仅不能是 null,而且字符串长度不能为0(即不能是 "" 空字符串),集合/数组的话不能没有元素。
  • 在这个类里的场景
    • 比如 productName(商品名):发布商品时,商品名必须填,而且不能只打个空格就提交,这个注解就是卡这个的。
    • 同理,cover(封面图)、categoryId(分类)都是发布商品的必填项。

2. @NotEmpty(groups = {UpdateGroup.class})(分组校验)

出现位置productId 字段

1
2
@NotEmpty(groups = {UpdateGroup.class})
private String productId;
  • 核心作用“分情况校验”——只有在特定场景下才校验。
  • 参数拆解
    • groups = {UpdateGroup.class}:指定校验组
      • UpdateGroup 是一个自定义的空接口(专门用来标记“更新操作”的场景)。
  • 在这个类里的场景(非常经典,小白必懂)
    • 场景1:新增商品(不需要 productId):
      • 新增时,商品ID是后端自动生成的,前端不用传,所以此时不校验 productId
    • 场景2:更新商品(必须要 productId):
      • 更新时,必须告诉后端“更新的是哪个商品”,所以此时强制校验 productId 必须有值。
    • 这个注解就是实现:新增时不管,更新时必须填

3. @JsonFormat(后端→前端的时间格式化)

出现位置createTime 字段

1
2
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
  • 来源包com.fasterxml.jackson.annotation.JsonFormat
  • 核心作用把Java的 Date 对象,转换成前端想要的“字符串格式”(用于后端返回数据给前端时)。
  • 参数拆解
    • pattern = "yyyy-MM-dd HH:mm:ss":指定时间格式。
      • yyyy:4位年(如2026)
      • MM:2位月(如04)
      • dd:2位日(如02)
      • HH:24小时制小时(如14)
      • mm:分
      • ss:秒
      • 最终效果:2026-04-02 14:30:00
    • timezone = "GMT+8":指定时区。
      • 中国在东八区,必须加这个,不然返回的时间会少8小时(时差问题)。
  • 场景
    • 后端存的是 Date 对象(一串计算机能懂的数字),前端要显示给用户看“2026-04-02 14:30:00”,这个注解就是做这个转换的。

4. @DateTimeFormat(前端→后端的时间格式化)

出现位置createTime 字段

1
2
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
  • 来源包org.springframework.format.annotation.DateTimeFormat
  • 核心作用把前端传来的“时间字符串”,转换成Java的 Date 对象(用于前端提交数据给后端时)。
  • 参数拆解
    • pattern = "yyyy-MM-dd HH:mm:ss":告诉后端,前端传过来的时间字符串长这个样子,请按这个格式解析。
  • 场景
    • 前端传了个字符串 "2026-04-02 14:30:00",后端要把它存到数据库(需要 Date 类型),这个注解就是做这个解析的。
  • @JsonFormat 的区别(小白速记)
    • @JsonFormat后端 → 前端(返回给用户看)
    • @DateTimeFormat前端 → 后端(接收用户提交的)

5. @Override(重写标记)

出现位置toString() 方法上

1
2
@Override
public String toString() { ... }
  • 来源包java.lang.Override(Java核心包,不用import)
  • 核心作用标记这个方法是“重写”了父类/接口的方法
  • 在这个类里的场景
    • 所有Java类都默认继承 Object 类,Object 类里有个原生的 toString() 方法(打印出来是“类名@哈希值”,很难懂)。
    • 这里重写了它,改成打印商品的具体信息(如“商品ID:xxx,商品名称:xxx”),方便调试时看数据。
  • 附加作用:加了这个注解,如果你不小心把方法名写错了(比如写成 tostring 小写),编译器会报错提醒你,防止写错。

总结:这个类里注解的“分工”

注解 分工 场景
@NotEmpty 普通必填校验 商品名、描述、封面等新增时就必须填的字段
@NotEmpty(groups=...) 分组必填校验 商品ID只有更新时才必须填
@JsonFormat 时间转字符串(后端→前端) 把创建时间格式化后显示给用户看
@DateTimeFormat 字符串转时间(前端→后端) 接收前端传来的时间字符串
@Override 标记重写 方便调试打印商品信息

图片上传

好的!这是一个标准的 Spring Boot 文件上传与图片读取控制器。我们将代码分成 类定义、依赖注入、上传接口、读取接口、工具方法 5 个模块,逐行/逐块用通俗的话讲清楚。

一、 类定义与注解(从上往下看)

1
2
3
4
5
6
7

// 核心类注解
@Validated // 1. 开启参数校验:让下面的 @NotNull、@NotEmpty 注解生效
@Slf4j // 2. Lombok 注解:自动生成一个 log 对象,用来打日志
@RestController // 3. 核心:声明这是一个 Web 控制器,且所有方法返回 JSON 数据(或直接写流)
@RequestMapping("/file") // 4. 类级别的路径映射:这个类里所有接口的 URL 都以 /file 开头
public class FileController extends ABaseController { // 5. 继承基类:基类里通常封装了通用的返回方法(如 getSuccessResponseVO)

二、 依赖注入(类内部的成员变量)

1
2
3
4
5
@Resource
private AppConfig appConfig; // 1. 注入配置类:读取 application.yml 里的配置(比如文件上传的根目录)

@Resource
private FileUtils fileUtils; // 2. 注入文件工具类:封装了具体的上传逻辑(比如重命名、生成缩略图、保存到磁盘)

三、 接口1:上传图片 /file/uploadImage

1
2
3
4
5
6
7
8
9
10
11
12
@RequestMapping("/uploadImage") // 映射 URL:POST 访问 /file/uploadImage 就会进这个方法
public ResponseVO uploadCover(
@NotNull MultipartFile file, // 参数1:上传的文件。@NotNull 规定这个参数不能传空
Boolean createThumbnail // 参数2:是否生成缩略图(可选,Boolean 可以为 null
) throws IOException {

// 核心逻辑:把文件传给工具类处理,工具类会返回文件的相对路径(比如 /2025/04/02/xxx.jpg)
String filePath = fileUtils.uploadImage(file, createThumbnail);

// 返回结果:调用父类 ABaseController 的方法,包装成标准的 JSON 格式返回给前端
return getSuccessResponseVO(filePath);
}

四、 接口2:读取/显示图片 /file/getResource

这是给浏览器访问图片用的(比如 <img src="/file/getResource?sourceName=xxx.jpg">)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RequestMapping("/getResource")
public void getResource(
HttpServletResponse response, // 参数1:HTTP 响应对象,用来把图片数据写回给浏览器
@NotEmpty String sourceName // 参数2:文件名。@NotEmpty 规定文件名不能为空字符串
) {
// 1. 安全检查!防止恶意路径攻击(比如 sourceName 传 ../../windows/system32 来偷文件)
if (!StringTools.pathIsOk(sourceName)) {
throw new BusinessException(ResponseCodeEnum.CODE_600); // 路径非法,抛业务异常
}

// 2. 获取文件后缀名(比如 "jpg", "png")
String suffix = StringTools.getFileSuffix(sourceName);

// 3. 设置响应头 Content-Type:告诉浏览器“这是一张图片”,浏览器才会显示而不是下载
response.setContentType("image/" + suffix.replace(".", "")); // 变成 "image/jpeg" 或 "image/png"

// 4. 设置缓存头 Cache-Control:让浏览器把这张图缓存 1 个月(2592000秒),下次访问就不用请求服务器了,提升速度
response.setHeader("Cache-Control", "max-age=2592000");

// 5. 调用下面的工具方法,真正去读取硬盘上的文件并写回给浏览器
readFile(response, sourceName);
}

五、 核心工具方法:readFile(真正读取文件的逻辑)

这是一个 protected 方法,供本类或子类调用,使用了经典的 Java IO 流操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
protected void readFile(HttpServletResponse response, String filePath) {
// 1. 再次检查路径安全(双重保险)
if (!StringTools.pathIsOk(filePath)) {
return;
}

// 2. 拼接文件在服务器硬盘上的完整绝对路径
// 例如:D:/project/files/ + /upload/ + 2025/04/02/xxx.jpg
File file = new File(appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE + filePath);

// 3. 如果文件不存在,直接结束(不报错,也不返回任何东西,或者返回404,这里选择静默处理)
if (!file.exists()) {
return;
}

// 4. 核心 IO 流操作(使用 try-with-resources 语法,自动关闭流,防止内存泄漏)
try (
OutputStream out = response.getOutputStream(); // 获取通往浏览器的输出流(水管)
FileInputStream in = new FileInputStream(file) // 获取读取本地文件的输入流(水管)
) {
byte[] byteData = new byte[1024]; // 准备一个 1KB 的缓冲区(水桶)
int len = 0;

// 循环读取:每次从文件读 1KB 到缓冲区,只要没读完 (len != -1),就继续写
while ((len = in.read(byteData)) != -1) {
out.write(byteData, 0, len); // 把缓冲区里的有效数据写给浏览器
}

out.flush(); // 刷新流,确保缓冲区里最后一点数据也挤出去

} catch (Exception e) {
// 如果读取出错(比如文件损坏、网络断开),记录错误日志,不要直接抛给前端
log.error("读取文件异常", e);
}
}

总结:这个类的完整工作流

  1. 上传:前端 POST 图片到 /file/uploadImage -> FileUtils 保存到磁盘 -> 返回文件路径。
  2. 访问:前端 <img> 标签访问 /file/getResource?sourceName=路径 -> 校验安全 -> 设置响应头 -> 从磁盘读取文件流 -> 写回给浏览器显示。

Mapper 接口

好的!这是一个 MyBatis 持久层(数据库操作)的 Mapper 接口

一、 先搞懂:这个文件是干嘛的?

在 Java 企业级开发中,MyBatis 是用来操作数据库的框架

  • 这个 ProductPropertyValueMapper 接口,相当于一个「数据库操作说明书」,它只定义“我们要对数据库做什么”(比如:更新、删除、查询)。
  • 具体的 SQL 语句(比如 UPDATE ...DELETE ...),通常写在一个和它配套的 XML 文件里(比如 ProductPropertyValueMapper.xml)。

二、 逐行/逐块代码详解

1. 包声明与导入

1
import org.apache.ibatis.annotations.Param;
  • import org.apache.ibatis.annotations.Param;:导入 MyBatis 的 @Param 注解。这是一个非常重要的注解,作用是给方法的参数“起名字”,方便在 XML 里通过名字引用参数。

2. 接口定义

1
2
3
4
/**
* 数据库操作接口
*/
public interface ProductPropertyValueMapper<T, P> extends BaseMapper<T, P> {
  • public interface ...:声明这是一个接口(不是普通的类),MyBatis 的 Mapper 必须是接口。
  • <T, P>泛型
    • T:代表「实体类类型」(比如 ProductPropertyValue 类,对应数据库里的表)。
    • P:代表「主键/参数类型」(比如主键是 String 类型还是 Integer 类型)。
    • 用泛型的好处是:这个接口可以复用,不用写死具体的类。
  • extends BaseMapper<T, P>继承通用基础接口
    • 这是代码里的“偷懒”技巧。BaseMapper 里肯定已经写好了最通用的方法(比如:insert() 插入、selectById() 根据ID查)。
    • 继承它之后,这个接口就自动拥有了那些通用方法,不用再重复写一遍。我们只需要在这里写业务特有的复杂方法

3. 方法1:根据双ID更新

1
2
3
4
/**
* 根据ProductIdAndPropertyValueId更新
*/
Integer updateByProductIdAndPropertyValueId(@Param("bean") T t, @Param("productId") String productId, @Param("propertyValueId") String propertyValueId);
  • 方法名含义updateByProductIdAndPropertyValueId → 根据「商品ID」和「属性值ID」来更新数据。
  • 返回值 Integer:返回一个整数,表示「这次操作影响了数据库里的几行数据」(比如成功更新了 1 行,就返回 1)。
  • 参数详解
    • @Param("bean") T t
      • t 是一个实体类对象,里面装着「要更新成什么新数据」。
      • @Param("bean") 给它起个名叫 bean,在 XML 里写 SQL 时,就可以用 #{bean.属性名} 来取对象里的值了。
    • @Param("productId") String productId
      • 商品 ID。作为更新的「查询条件」。
    • @Param("propertyValueId") String propertyValueId
      • 属性值 ID。也是「查询条件」。
  • 业务逻辑大白话:“去数据库里,找到 product_id 是 XXX 且 property_value_id 是 YYY 的那一行,把它的数据更新成 bean 对象里的样子。”

4. 方法2:根据双ID删除

1
2
3
4
/**
* 根据ProductIdAndPropertyValueId删除
*/
Integer deleteByProductIdAndPropertyValueId(@Param("productId") String productId, @Param("propertyValueId") String propertyValueId);
  • 方法名含义:根据「商品ID」和「属性值ID」删除数据。
  • 返回值 Integer:返回删除了几行。
  • 参数:两个 ID 作为删除的条件。
  • 业务逻辑:“把数据库里同时匹配这两个 ID 的那一行删掉。”

5. 方法3:批量更新

1
void updateBatch(@Param("productId") String productId, @Param("dataList") List<T> dataList);
  • 方法名 updateBatch:批量更新。
  • 参数dataList 里装着好几个要更新的对象。
  • 业务逻辑:“根据商品 ID,把 dataList 里的这些对象对应的数据库记录,一次性全部更新。”

三、 总结:这个接口的特点

  1. 不写 SQL:它只定义“做什么”,具体的 SQL 语句(UPDATE ... WHERE ...)写在配套的 XML 文件里。
  2. @Param 很关键:只要方法有多个参数,或者参数是对象/集合,一定要用 @Param 起名字,否则 XML 里找不到参数。
  3. 双 ID 操作:这个表的设计很可能是一个关联表(商品和属性值的关联),所以需要用两个 ID 才能唯一确定一条记录。

需要我给你简单看一眼配套的 XML 文件大概长什么样吗?这样你就能理解接口和 SQL 是怎么配合的了。

MyBatis XML 映射文件

一、 文件头部与根标签(固定配置)

这部分是 MyBatis XML 的身份证,告诉程序“我是谁、我要绑定哪个接口”。

1
2
3
4
5
6
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!-- 核心:绑定 Mapper 接口,必须和接口的全限定类名完全一致 -->
<mapper namespace="com.easymall.mappers.ProductPropertyValueMapper">
  1. <?xml ... ?>:XML 文件的声明,固定写法,说明版本和编码。
  2. <!DOCTYPE ...>:文档类型定义(DTD),固定写法,用来校验 XML 语法是否符合 MyBatis 规范,IDE 也会根据这个给你做代码提示。
  3. <mapper namespace="...">
    • 根标签:所有 SQL 都必须写在这个标签里面。
    • namespace命名空间,必须和对应的 Mapper 接口的“全限定类名”(包名+类名)完全一致,这是 XML 和 Java 接口绑定的唯一凭证。

二、 核心映射配置(基础建设)

这部分是数据库表和 Java 实体类之间的翻译官,以及常用 SQL 片段的“仓库”。

1. 结果映射 <resultMap>(最重要的翻译官)

作用:告诉 MyBatis,数据库表里的列名,怎么对应到 Java 实体类的属性名(比如下划线 product_id 怎么转成驼峰 productId)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!--实体映射:id是这个映射的唯一标识,type是对应的Java实体类全路径-->
<resultMap id="base_result_map" type="com.easymall.entity.po.ProductPropertyValue">
<!--商品ID:column是数据库字段名,property是Java属性名-->
<result column="product_id" property="productId"/>
<!--属性ID-->
<result column="property_id" property="propertyId"/>
<!--属性名称-->
<result column="property_name" property="propertyName"/>
<!--属性排序-->
<result column="property_sort" property="propertySort"/>
<!--0:无需传封面 1:需传封面-->
<result column="cover_type" property="coverType"/>
<!---->
<result column="property_value_id" property="propertyValueId"/>
<!--属性封面-->
<result column="property_cover" property="propertyCover"/>
<!--属性值-->
<result column="property_value" property="propertyValue"/>
<!--备注-->
<result column="property_remark" property="propertyRemark"/>
<!--属性值排序-->
<result column="sort" property="sort"/>
</resultMap>
  • <resultMap id="..." type="...">
    • id="base_result_map":给这个映射规则起个唯一的名字,后面查询标签用 resultMap 属性引用它。
    • type="...":指定要映射成哪个 Java 实体类(PO:Persistent Object,持久化对象,对应数据库表)。
  • <result column="..." property="..."/>
    • 核心翻译规则:一行对应一个字段。
    • column:数据库表里的列名(通常是下划线命名,如 product_id)。
    • property:Java 实体类里的属性名(通常是驼峰命名,如 productId)。
    • 作用:MyBatis 查询出数据后,会自动把 product_id 列的值,塞进对象的 productId 属性里。

2. SQL 片段复用 <sql>(代码仓库,避免重复写)

作用:把常用的字段列表、查询条件抽出来,起个名字,后面用 <include> 引用,改一处就能全文件生效

(1) 通用查询列

1
2
3
4
5
<!-- 通用查询结果列:把所有要查的字段列出来,避免每次都写 SELECT * -->
<sql id="base_column_list">
p.product_id,p.property_id,p.property_name,p.property_sort,p.cover_type,
p.property_value_id,p.property_cover,p.property_value,p.property_remark,p.sort
</sql>
  • id="base_column_list":给这段 SQL 起个名字。
  • 内容:列出了表中所有需要查询的字段,并且给表加了别名 p(后面查询语句会给表起别名 p)。
  • 为什么不用 SELECT *
    • * 性能稍差(数据库要先解析有哪些列)。
    • 有时候表加了新字段但不想查出来,用明确的字段列表更安全。

(2) 基础精确查询条件(IF 判断入门)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!-- 通用查询条件列:基础的精确匹配(=) -->
<sql id="base_condition_filed">
<!-- if test:判断参数是否不为空,不为空才拼接这行 SQL -->
<if test="query.productId != null and query.productId!=''">
and p.product_id = #{query.productId}
</if>
<if test="query.propertyId != null and query.propertyId!=''">
and p.property_id = #{query.propertyId}
</if>
<if test="query.propertyName != null and query.propertyName!=''">
and p.property_name = #{query.propertyName}
</if>
<if test="query.propertySort != null">
and p.property_sort = #{query.propertySort}
</if>
<if test="query.coverType != null">
and p.cover_type = #{query.coverType}
</if>
<if test="query.propertyValueId != null and query.propertyValueId!=''">
and p.property_value_id = #{query.propertyValueId}
</if>
<if test="query.propertyCover != null and query.propertyCover!=''">
and p.property_cover = #{query.propertyCover}
</if>
<if test="query.propertyValue != null and query.propertyValue!=''">
and p.property_value = #{query.propertyValue}
</if>
<if test="query.propertyRemark != null and query.propertyRemark!=''">
and p.property_remark = #{query.propertyRemark}
</if>
<if test="query.sort != null">
and p.sort = #{query.sort}
</if>
</sql>
  • <if test="...">动态 SQL 的核心
    • 逻辑:如果 test 里的表达式成立(为 true),就把 <if> 标签包裹的 SQL 拼接到最终语句里;如果不成立,就忽略这行。
    • query.productId:这里的 query 是 Mapper 接口方法里的参数对象(通常叫 Query 或 VO,封装了查询条件),productId 是它的属性。
    • 判断规则
      • 字符串类型(String):要判断 != null!=''(不为空且不为空字符串)。
      • 数字类型(Integer/Double):只需要判断 != null(因为数字没有空字符串的概念)。
    • and:注意这里条件前都加了 and,后面配合 <where> 标签使用,<where> 会自动处理掉多余的 and

(3) 完整查询条件(包含模糊查询和 IN 查询)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!-- 完整查询条件:包含基础条件、模糊查询(LIKE)、范围查询(IN) -->
<sql id="query_condition">
<!-- where 标签:智能处理,如果里面有条件就加 WHERE,且会自动去掉开头多余的 and/or -->
<where>
<!-- 引用上面写好的基础精确查询条件 -->
<include refid="base_condition_filed"/>

<!-- 以下是模糊查询:Fuzzy 后缀表示模糊匹配 -->
<if test="query.productIdFuzzy!= null and query.productIdFuzzy!=''">
and p.product_id like concat('%', #{query.productIdFuzzy}, '%')
</if>
<if test="query.propertyIdFuzzy!= null and query.propertyIdFuzzy!=''">
and p.property_id like concat('%', #{query.propertyIdFuzzy}, '%')
</if>
<if test="query.propertyNameFuzzy!= null and query.propertyNameFuzzy!=''">
and p.property_name like concat('%', #{query.propertyNameFuzzy}, '%')
</if>
<if test="query.propertyValueIdFuzzy!= null and query.propertyValueIdFuzzy!=''">
and p.property_value_id like concat('%', #{query.propertyValueIdFuzzy}, '%')
</if>
<if test="query.propertyCoverFuzzy!= null and query.propertyCoverFuzzy!=''">
and p.property_cover like concat('%', #{query.propertyCoverFuzzy}, '%')
</if>
<if test="query.propertyValueFuzzy!= null and query.propertyValueFuzzy!=''">
and p.property_value like concat('%', #{query.propertyValueFuzzy}, '%')
</if>
<if test="query.propertyRemarkFuzzy!= null and query.propertyRemarkFuzzy!=''">
and p.property_remark like concat('%', #{query.propertyRemarkFuzzy}, '%')
</if>

<!-- IN 查询:比如查询多个商品ID的数据 -->
<if test="query.productIdList!=null and query.productIdList.size()>0">
and p.product_id in
<!-- foreach:循环遍历集合,生成 (1,2,3) 这种格式 -->
<foreach item="item" collection="query.productIdList" index="index" open="(" close=")" separator=",">
#{item}
</foreach>
</if>
</where>
</sql>
  • <where> 标签
    • 超级智能
      1. 如果 <where> 里面没有任何条件(所有 <if> 都不成立),它就不会加 WHERE 关键字,避免 SQL 语法错误。
      2. 如果里面的条件是以 andor 开头的,它会自动把开头多余的 and/or 去掉。
  • <include refid="..."/>:引用上面定义好的 SQL 片段,把 base_condition_filed 的内容复制粘贴到这里。
  • 模糊查询 LIKE
    • concat('%', #{query.productIdFuzzy}, '%')
      • concat 是 MySQL 的字符串拼接函数。
      • % 是通配符,代表任意字符。
      • 意思是:只要字段里包含传入的参数,就算匹配(比如搜“张”,能查到“张三”、“张三丰”)。
  • <foreach> 循环(IN 查询)
    • 作用:遍历一个集合(List/Set),生成 SQL 里 IN (1,2,3) 的部分。
    • 属性大白话:
      • collection="query.productIdList":要遍历的集合参数名。
      • item="item":给集合里的每一个元素起个临时名字。
      • open="(":循环开始前拼接一个左括号。
      • separator=",":元素之间用逗号分隔。
      • close=")":循环结束后拼接一个右括号。

三、 查询操作(SELECT)

1. 查询集合(带分页、排序)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- 查询集合:返回多条数据,用 resultMap 映射 -->
<select id="selectList" resultMap="base_result_map">
SELECT
<!-- 引用通用列 -->
<include refid="base_column_list"/>
FROM product_property_value p
<!-- 引用完整查询条件 -->
<include refid="query_condition"/>
<!-- 动态排序:orderBy 是排序字段,注意这里用 ${} 而不是 #{} -->
<if test="query.orderBy!=null">
order by ${query.orderBy}
</if>
<!-- 动态分页:simplePage 里封装了 start(起始行)和 end(每页条数) -->
<if test="query.simplePage!=null">
limit #{query.simplePage.start},#{query.simplePage.end}
</if>
</select>
  • <select id="..." resultMap="...">
    • id:必须和 Mapper 接口里的方法名一致。
    • resultMap:引用上面定义的 base_result_map,把查询结果转换成实体类对象列表。
  • FROM product_property_value p:给表起个别名叫 p,前面的字段列表 p.product_id 就是这么来的。
  • order by ${query.orderBy}
    • 重要区别:这里用了 ${} 而不是 #{}
    • 原因orderBy 传的是字段名(比如 "product_id desc"),不是参数值。${} 是直接把字符串拼接到 SQL 里,而 #{} 是预编译(会加引号,变成 'product_id desc',那就错了)。
    • 注意${} 有 SQL 注入风险,使用时要在后端校验 orderBy 的值,不能让前端随便传。
  • limit ... , ...:MySQL 的分页语法。
    • 第一个参数:起始行(从 0 开始)。
    • 第二个参数:查询多少条。

2. 查询总数(COUNT)

1
2
3
4
5
<!-- 查询数量:返回一个整数,用 resultType 指定返回类型 -->
<select id="selectCount" resultType="java.lang.Integer">
SELECT count(1) FROM product_property_value p
<include refid="query_condition"/>
</select>
  • resultType="java.lang.Integer"
    • 因为返回的是一个简单的数字(总条数),不需要映射成对象,直接指定返回类型为 Integer 即可。
  • count(1):统计行数,和 count(*) 效果一样,性能也差不多,count(1) 是程序员的习惯写法。

四、 插入操作(INSERT)

1. 单条插入(动态字段,只插有值的列)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!-- 插入 (匹配有值的字段):parameterType 指定传入的参数对象类型 -->
<insert id="insert" parameterType="com.easymall.entity.po.ProductPropertyValue">
INSERT INTO product_property_value
<!-- trim 标签:处理括号和逗号 -->
<!-- prefix:在内容前加 ( -->
<!-- suffix:在内容后加 ) -->
<!-- suffixOverrides:去掉内容最后多余的逗号 -->
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="bean.productId != null">
product_id,
</if>
<if test="bean.propertyId != null">
property_id,
</if>
<!-- ... 中间重复的 if 省略,逻辑同上 ... -->
<if test="bean.sort != null">
sort,
</if>
</trim>
<!-- 上面是列名,下面是对应的值,结构完全一样 -->
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="bean.productId!=null">
#{bean.productId},
</if>
<if test="bean.propertyId!=null">
#{bean.propertyId},
</if>
<!-- ... 省略 ... -->
<if test="bean.sort!=null">
#{bean.sort},
</if>
</trim>
</insert>
  • <trim> 标签
    • 这是插入语句的神器,专门用来处理动态 SQL 的括号和逗号。
    • 两个 <trim> 分别处理“列名部分”和“值部分”,结构必须保持一致。
    • suffixOverrides=",":最关键的属性。如果最后一个字段后面多了一个逗号,它会自动帮你去掉,避免 SQL 语法错误。

2. 单条插入或更新(MySQL 特有:有就更新,没有就插入)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- 插入或者更新:利用 MySQL 的 on DUPLICATE key update 特性 -->
<insert id="insertOrUpdate" parameterType="com.easymall.entity.po.ProductPropertyValue">
INSERT INTO product_property_value
<!-- ... 前面的 trim 列名和 values 部分和上面 insert 完全一样,省略 ... -->
<trim prefix="(" suffix=")" suffixOverrides=",">
<!-- ... 同 insert ... -->
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<!-- ... 同 insert ... -->
</trim>

<!-- 核心:如果主键或唯一索引冲突,就执行下面的更新操作 -->
on DUPLICATE key update
<trim prefix="" suffix="" suffixOverrides=",">
<if test="bean.productId!=null">
product_id = VALUES(product_id),
</if>
<if test="bean.propertyId!=null">
property_id = VALUES(property_id),
</if>
<!-- ... 省略 ... -->
</trim>
</insert>
  • on DUPLICATE key update
    • MySQL 特有功能
    • 逻辑:如果插入的数据违反了主键约束唯一索引约束(也就是表里已经有这条数据了),就不执行插入,转而执行后面的 UPDATE 语句。
    • VALUES(product_id):表示“使用刚才 INSERT 语句里打算插入的那个 product_id 的值来更新”。

3. 批量插入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!-- 添加 (批量插入)-->
<insert id="insertBatch" parameterType="com.easymall.entity.po.ProductPropertyValue">
INSERT INTO product_property_value(
product_id,
property_id,
property_name,
property_sort,
cover_type,
property_value_id,
property_cover,
property_value,
property_remark,
sort
)values
<!-- foreach 循环:每一条数据生成一个 (?,?,?) -->
<foreach collection="list" item="item" separator=",">
(
#{item.productId},
#{item.propertyId},
#{item.propertyName},
#{item.propertySort},
#{item.coverType},
#{item.propertyValueId},
#{item.propertyCover},
#{item.propertyValue},
#{item.propertyRemark},
#{item.sort}
)
</foreach>
</insert>
  • 批量逻辑
    • 列名部分是固定的(因为是批量,通常要求所有数据的字段都一致)。
    • <foreach> 循环 list 集合,每一个对象生成一组 (...),组与组之间用逗号 , 分隔。
    • 最终生成 SQL:INSERT INTO table (col) VALUES (1), (2), (3)

4. 批量插入或更新

逻辑同“单条插入或更新” + “批量插入”的结合体,利用 foreach 生成多组 values,最后跟上 on DUPLICATE key update


五、 更新操作(UPDATE)

1. 多条件动态更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!--多条件修改-->
<update id="updateByParam" parameterType="com.easymall.entity.query.ProductPropertyValueQuery">
UPDATE product_property_value p
<!-- set 标签:智能处理,自动去掉最后多余的逗号 -->
<set>
<if test="bean.productId != null">
product_id = #{bean.productId},
</if>
<if test="bean.propertyId != null">
property_id = #{bean.propertyId},
</if>
<!-- ... 省略 ... -->
</set>
<!-- 引用查询条件:决定要更新哪几行 -->
<include refid="query_condition"/>
</update>
  • <set> 标签
    • 专门用于 UPDATE 语句,作用和 <trim> 类似,会自动去掉 SET 子句中最后多余的逗号。

2. 根据双 ID 更新(你之前接口里的方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 根据ProductIdAndPropertyValueId修改-->
<update id="updateByProductIdAndPropertyValueId" parameterType="com.easymall.entity.po.ProductPropertyValue">
UPDATE product_property_value
<set>
<!-- ... 动态 set 字段,同前所述 ... -->
<if test="bean.propertyId != null">
property_id = #{bean.propertyId},
</if>
<!-- ... 省略 ... -->
</set>
<!-- 固定的 WHERE 条件:用两个 ID 唯一确定一行 -->
where product_id=#{productId} and property_value_id=#{propertyValueId}
</update>

3. 批量更新

1
2
3
4
5
6
7
8
9
10
<update id="updateBatch">
<!-- foreach 循环:每条更新语句用分号 ; 隔开 -->
<foreach item="item" collection="dataList" separator=";">
update product_property_value set
property_cover = #{item.propertyCover},
property_value=#{item.propertyValue},
property_remark=#{item.propertyRemark}
where product_id=#{item.productId} and property_value_id=#{item.propertyValueId}
</foreach>
</update>
  • 注意:这种用 ; 分隔多条 SQL 的批量更新方式,需要在数据库连接配置(JDBC URL)中开启 allowMultiQueries=true,否则会报错。

六、 删除操作(DELETE)

1. 多条件删除

1
2
3
4
5
<!--多条件删除-->
<delete id="deleteByParam">
delete p from product_property_value p
<include refid="query_condition"/>
</delete>

2. 根据双 ID 删除 + 批量删除

逻辑同查询和更新,利用 WHERE 条件或 <foreach> 循环生成 IN 子句。

七、 总结(小白必记核心点)

  1. #{} vs ${}
    • #{}:预编译,防注入,传参数值(99% 场景用它)。
    • ${}:字符串拼接,有风险,传字段名表名(如 order by)。
  2. 动态标签三剑客
    • <if>:判断条件,成立才拼 SQL。
    • <where> / <set> / <trim>:处理 SQL 拼接后的语法问题(多余的 and、逗号)。
    • <foreach>:循环集合,做批量操作或 IN 查询。
  3. 映射方式
    • 返回对象列表用 resultMap(复杂映射)。
    • 返回简单类型(Integer、String)用 resultType