我用一个通用方法,删了项目中80%的校验代码(附工具类源码)

12,265 阅读17分钟

前言

Java 的你,是不是也经常被重复的校验代码搞得头大?

做了这些年的开发,你会发现在项目里,这些场景几乎天天碰:

  • 用户注册:手机号/邮箱是否唯一,账号状态是否可用
  • 添加单据:单据号是否已存在、客户ID是否有效、制单日期是否小于当前日期。
  • 编辑单据:当前的单据状态是否允许修改,删除单据时是否允许删除
  • 时间窗口:预约/发货时间不早于创建时间,截止时间不晚于开始时间

就这些场景,在项目的 Service 里面经常能看到一大堆这种校验代码,看着都不复杂,无非就是按某个字段查数据,判个空,或者判断状态,然后决定是否抛个异常:

// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
    User user = userDao.findById(userId);
    if (user == null) {
        throw new RuntimeException("用户ID无效");
    }
}

// 判断部门 ID 是否有效(复制、粘贴、改个名...又一个方法诞生了)
public void checkDeptExistence(String deptId) {
    Dept dept = deptDao.findById(deptId);
    if (dept == null) {
        throw new RuntimeException("部门ID无效");
    }   
}

刚开始实体少还能忍,等业务一上量,这种代码像长草一样蔓延。每加一个实体校验就得复制一遍,哪天想统一一下异常类型或者返回信息,还得把所有地方挨个改一轮,心累。

不过说实话,能把校验抽成上面这种独立方法,已经算是有代码洁癖了。 我见过的更多项目,是把这些逻辑直接嵌在一大堆if...else里,层层嵌套,那才叫真正的“屎山”。

所以,今天我想分享一个我的解决方案。 它不依赖任何复杂的设计模式,而是巧妙地利用了 Java 8 的函数式编程特性和 MyBatis-Plus。我们将大量使用方法引用(由 SFunction 接口承载)来保证类型安全,并借助 Predicate函数式接口传递灵活的校验逻辑,最终打造一个优雅的通用断言工具。


在动手前,我们先分析一下问题的本质

好的代码源于好的设计,而好的设计源于对问题的深刻分析。

在写下第一行通用代码之前,我们先回过头来,仔细审视一下那些重复的校验逻辑,看看能否从中抽象出统一的模式。


我们来看几个典型的校验场景及其背后的逻辑:

  • 场景一:【唯一性】 校验新增用户的 username 是否已存在。

    • SQLSELECT count(*) FROM t_user WHERE username = ?;
    • 逻辑: 若 count > 0,则抛异常。
  • 场景二:【存在性】 校验创建订单时,userId 是否有效。

    • SQLSELECT count(*) FROM t_user WHERE id = ?;
    • 逻辑: 若 count == 0,则抛异常。
  • 场景三:【批量存在性】 批量导入商品时,校验所有 categoryId 是否都有效。

    • SQLSELECT count(*) FROM t_category WHERE id IN (?, ?, ...);
    • 逻辑: 若 count != idList.size(),则抛异常。
  • 场景四:【单体属性】 校验订单状态是否可以编辑(例如,必须是"草稿"状态)。

    • SQLSELECT status FROM t_order WHERE id = ?;
    • 逻辑: 获取 status 值,若不等于 "DRAFT",则抛异常。
  • 场景五:【批量属性】 批量删除订单时,校验所有订单是否都可被删除(例如,状态必须是"已完成"或"已取消")。

    • SQLSELECT id, status FROM t_order WHERE id IN (?, ?, ...);
    • 逻辑: 获取所有订单的 status,遍历检查,任一不满足则抛异常。

总结

  1. 唯一性校验:判断值是否已存在
  2. 存在性校验:判断值是否真实存在
  3. 批量存在性校验:集合中所有值是否都存在
  4. 单体属性校验:某字段值是否符合业务要求
  5. 批量属性校验:集合中所有记录的某字段是否符合要求

提炼“公式”:发现重复的“套路”

通过之前的分析,现在我们来提炼一下,这些看似不同的校验,背后有没有一个统一的“结构”,在这些固定结构里面,哪些是可以替换的?

我们把上面 SQL 里的可变部分用【】括起来:

  1. SELECT count(*) FROM 【t_user】 WHERE 【username】 = 【'zhangsan'】;
  2. SELECT count(*) FROM 【t_user】 WHERE 【username】 = 【'lisi'】 AND 【id】 != 【101】;
  3. SELECT count(*) FROM 【t_dept】 WHERE 【id】 = 【'D007'】;
  4. SELECT 【status】 FROM 【t_order】 WHERE 【id】 = 【1】;
  5. SELECT 【status】 FROM 【t_order】 WHERE 【id】 in 【1,2,3】;

发现了什么?万变不离其宗!一个校验动作,底层逻辑是固定的,只是下面几个元素是可变的:

  • 【操作的表】t_usert_deptt_order...
  • 【查询的字段】count(*)status...
  • 【过滤的字段】usernameid...
  • 【过滤的条件】=!=in...
  • 【过滤的字段值】= 'zhangsan'!= 101= 'D007'in (1,2,3)...

抽象出通用模式

好了,案例已经足够丰富。现在我们来归纳一下,这些校验可以被清晰地分为两大类,每一类又包含“单体”和“批量”两种模式:

模式A:存在性校验 (Existence Check)

  • 核心:判断满足条件的记录是否存在

  • 关注点:数据库中的 count 数量。

  • 子模式

    • 单体存在性 (场景一、二):WHERE column = ?
    • 批量存在性 (场景三):WHERE column IN (...)

模式B:属性校验 (Property Check)

  • 核心:获取记录的一个或多个属性,在代码中进行业务逻辑判断。

  • 关注点:从数据库取回的具体字段值

  • 子模式

    • 单体属性 (场景四):SELECT column2 FROM ... WHERE id = ?
    • 批量属性 (场景五):SELECT column1, column2 FROM ... WHERE id IN (...)

选择我们的“武器”:MyBatis-Plus + Lambda

要实现这个封装,我们需要解决如何灵活地传递 “变化的参数”:

  1. 如何传递“查询的表”?
    MyBatis-Plus 的 IService<T> 是不二之选。IService<T> 天然就绑定了 User 实体和其对应的表,我们可以直接将 IService<T> 实例作为参数。

  2. 如何传递“查询的字段/条件字段”?
    这部分是关键。我们希望调用者能以一种类型安全、无硬编码字符串的方式来指定查询。

    • 指定字段:手写 "username""id" 这样的字符串是万恶之源。Java 8 的方法引用(如 User::getUsername)是完美的替代品,它在编译期就能保证字段的正确性。MyBatis-Plus 提供的 SFunction 接口就是为此而生的。

    • 指定值:对于单体校验,可以直接传值;对于批量校验,可以传一个 Collection (如 ListSet)。

    • 组合更复杂的条件:对于更复杂的 AND/OR 查询,MyBatis-Plus 的 LambdaQueryWrapper 是标准解决方案。

  3. 如何传递“条件”? 由专门的方法实现或者传递 Predicate 进去

  4. 如何传递“条件字段值”? 直接作为参数传进去就行了


总结:

  • 控制表:用 IService<T> 绑定实体和表
  • 字段选择:用 SFunction<T, ?> 方法引用避免硬编码字符串
  • 条件构造:用 LambdaQueryWrapper 构建灵活查询
  • 自定义断言: 用 Java Predicate 实现复杂业务判断

思路已经非常清晰了:

我们要构建一个或一系列的静态方法,它们能够处理单值和集合两种输入,内部利用这些输入动态构建 LambdaQueryWrapper(使用 .eq().in()),执行查询,并完成最终的断言逻辑。

好了,理论分析已经为我们铺平了道路。接下来,让我们看看用代码如何实现这个构想。


工具类 Asserts 设计与实现

理论的推演最终要回归代码的实践。 现在,我们就来打造这个通用的断言工具类,我将其命名为 Asserts。 它将包含一系列静态方法,专门用于处理我们前面分析的各种校验场景。

存在性校验 (Existence Check)

这类校验的核心是判断记录的“有”或“无”,对应我们分析的 模式A。它非常适合用 count 查询来实现。

1. 断言存在:exists

这是最基础的校验,确保某条记录必须存在。

代码实现 (Asserts.java):

public final class Asserts {

    private Asserts() {} // 工具类,私有化构造函数

    /**
     * 断言记录存在,若不存在则抛出异常
     * (对应场景三:校验 userId 是否有效)
     *
     * @param service IService 实例
     * @param value   要校验的值
     * @param column  要查询的列(方法引用)
     * @param errorMsg 错误信息
     * @param <T>     实体类型
     * @param <V>     值的类型
     */
    public static <T, V> void exists(IService<T> service, V value, SFunction<T, ?> column, String errorMsg) {
        long count = service.lambdaQuery().eq(column, value).count();
        if (count == 0) {
            throw new RuntimeException(errorMsg); // 建议使用自定义业务异常
        }
    }
}

如何使用:

告别旧的 checkUserExistence 方法,现在在 Service 中只需要一行代码:

// 在 OrderService 中校验用户 ID 是否有效
public void createOrder(OrderDTO orderDTO) {
    // 校验用户是否存在
    Asserts.exists(userService, orderDTO.getUserId(), User::getId, "下单用户不存在");
    
    // ... 其他业务逻辑
}

2. 断言唯一(或不存在):unique

exists 相反,它确保记录不存在,常用于“唯一性”校验。

代码实现 (Asserts.java):

// 在 Asserts 类中添加新方法
/**
 * 断言记录不存在(唯一性),若存在则抛出异常
 * (对应场景一:校验 username 是否已存在)
 *
 * @param service IService 实例
 * @param value   要校验的值
 * @param column  要查询的列(方法引用)
 * @param errorMsg 错误信息
 */
public static <T, V> void unique(IService<T> service, V value, SFunction<T, ?> column, String errorMsg) {
    long count = service.lambdaQuery().eq(column, value).count();
    if (count > 0) {
        throw new RuntimeException(errorMsg);
    }
}

如何使用:

// 在 UserService 中校验用户名是否唯一
public void register(UserDTO userDTO) {
    Asserts.unique(this, userDTO.getUsername(), User::getUsername, "用户名已存在");
    
    // ... 其他业务逻辑
}

编辑场景下的唯一校验

/**
 * 唯一性校验(排除某列等于指定值的记录)
 * 使用场景:更新时检查某字段唯一,但要排除当前记录
 *
 * @param service       IService 实例
 * @param value         要校验唯一性的字段值
 * @param column        要查询的字段列(方法引用)
 * @param excludeColumn 排除条件的列(方法引用)
 * @param excludeValue  排除条件的值
 * @param errorMsg      错误信息
 */
public static <T, V, EV> void unique(IService<T> service,
                                     V value,
                                     SFunction<T, V> column,
                                     SFunction<T, EV> excludeColumn,
                                     EV excludeValue,
                                     String errorMsg) {
    LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(column, value)
           .ne(excludeColumn, excludeValue);

    long count = service.count(wrapper);
    if (count > 0) {
        throw new RuntimeException(errorMsg);
    }
}


使用示例:

public void updateUser(UserDTO userDTO) {
    // 排除当前用户 id 相同的记录
    Asserts.unique(this, userDTO.getUsername(), User::getUsername, User::getId, userDTO.getId(), "用户名已存在");
    
}

思考:场景二那种编辑时带 id != ? 的复杂条件怎么处理?我们可以重载一个 unique 方法,额外接收一个 Consumer<LambdaQueryWrapper<T>> 参数,让调用者可以自由地拼接更复杂的查询条件。这部分作为扩展思考,我们先聚焦核心功能。

3. 断言批量存在:allExists

处理批量场景,确保集合中所有的值在数据库中都有对应记录。

代码实现 (Asserts.java):

// 在 Asserts 类中添加新方法
/**
 * 断言集合中所有值对应的记录都存在
 *
 * @param service IService 实例
 * @param values  要校验的值的集合
 * @param column  要查询的列(方法引用)
 * @param errorMsg 错误信息
 */
public static <T, V> void allExists(IService<T> service, Collection<V> values, SFunction<T, ?> column, String errorMsg) {
    if (values == null || values.isEmpty()) {
        return; // 空集合不校验
    }
    long count = service.lambdaQuery().in(column, values).count();
    if (count != values.size()) {
        // 注意:这里为了简化,没有去重。如果传入的 values 可能有重复,
        // 可以先用 Set 去重再比较大小。
        throw new RuntimeException(errorMsg);
    }
}

如何使用:

// 批量操作前,校验所有传入的部门 ID 是否都有效
public void assignUsersToDepts(List<String> userIds, List<String> deptIds) {
    Asserts.allExists(deptService, deptIds, Dept::getId, "包含无效的部门ID");
    
    // ... 其他业务逻辑
}

属性校验 (Property Check)

这类校验需要先查出数据,再对数据的具体字段值进行判断,对应我们分析的 模式B

1. 精准断言:assertPropertyEquals 和 assertPropertyNotEquals

这类校验需要先查出数据,再对具体字段值进行判断。为了性能,我们优先选择只查询必要的列。

代码实现 (Asserts.java):

import java.util.Objects;
// ... 其他 import

/**
 * [精准] 断言指定记录的单个属性值等于期望值
 * (对应场景四:校验订单状态)
 * SQL优化:SELECT property_column FROM table WHERE id_column = ?
 *
 * @param service IService 实例
 * @param idValue 用于定位记录的ID值
 * @param idColumn 定位记录的列(如 Order::getId)
 * @param propertyColumn 要校验的属性列(如 Order::getStatus)
 * @param expectedValue 期望的属性值
 * @param errorMsg 校验失败时的错误信息
 */
public static <T, V, P> void assertPropertyEquals(IService<T> service, V idValue, SFunction<T, ?> idColumn, SFunction<T, P> propertyColumn, P expectedValue, String errorMsg) {
    // 只查询需要校验的属性列,而不是整个实体
    T entity = service.lambdaQuery()
            .select(propertyColumn) // 核心优化点
            .eq(idColumn, idValue)
            .one();

    if (entity == null) {
        throw new RuntimeException("记录不存在");
    }

    P actualValue = propertyColumn.apply(entity);
    if (!Objects.equals(expectedValue, actualValue)) {
        throw new RuntimeException(errorMsg);
    }
}

/**
 * [精准] 断言指定记录的单个属性值不等于期望值
 */
public static <T, V, P> void assertPropertyNotEquals(IService<T> service, V idValue, SFunction<T, ?> idColumn, SFunction<T, P> propertyColumn, P unexpectedValue, String errorMsg) {
    T entity = service.lambdaQuery()
            .select(propertyColumn)
            .eq(idColumn, idValue)
            .one();

    if (entity == null) {
        throw new RuntimeException("记录不存在");
    }

    P actualValue = propertyColumn.apply(entity);
    if (Objects.equals(unexpectedValue, actualValue)) {
        throw new RuntimeException(errorMsg);
    }
}

如何使用:

// 编辑订单前,校验订单状态必须为 "DRAFT"
public void editOrder(OrderDTO orderDTO) {
    Asserts.assertPropertyEquals(
        this,                     // 当前 OrderService
        orderDTO.getId(),         // 订单ID
        Order::getId,             // 按 ID 查询
        Order::getStatus,         // 要校验的属性是 status
        OrderStatusEnum.DRAFT,    // 期望 status 的值是 "DRAFT"
        "只有草稿状态的订单才能编辑"
    );
    
    // ... 其他业务逻辑
}

// 支付订单前,校验订单状态不能是 "CANCELLED"
public void payOrder(String orderId) {
    Asserts.assertPropertyNotEquals(
        this,
        orderId,
        Order::getId,
        Order::getStatus,
        OrderStatusEnum.CANCELLED, // 不期望 status 的值是 "CANCELLED"
        "已取消的订单无法支付"
    );
    
    // ... 其他业务逻辑
}

通过 select(propertyColumn),我们生成的 SQL 从 SELECT * 变成了 SELECT status ...,性能提升立竿见影,代码意图也更加清晰。

2. 灵活断言:assertEntity

当然,有时我们的校验逻辑会更复杂,比如“订单金额必须大于0,并且收货人信息不能为空”。这种场景需要获取多个字段,用简单的“等于/不等于”无法满足。

为此,我们保留一个更灵活的、可以自定义断言逻辑的方法,并将其重命名为 assertEntity,以区别于前面的精准属性断言。并且我们现在允许调用者指定需要查询的列,从而将 SELECT * 优化为 SELECT column1, column2...,避免查询不必要的列。

我们使用 Java 的可变参数(varargs)特性,让一个方法同时支持两种调用方式:

  • 如果不传入 selectColumns,它就执行查询所有列。
  • 如果传入了 selectColumns,它就只查询指定的列。

代码实现 (Asserts.java):

// 在 Asserts 类中,用这个新方法替换掉旧的 assertEntity
/**
 * [灵活] 断言查询到的单个实体满足复杂的业务条件。
 * 支持指定查询部分列以优化性能。
 *
 * @param service IService 实例
 * @param value   用于定位实体的值 (通常是ID)
 * @param column  定位实体的列
 * @param predicate 对实体进行校验的断言逻辑
 * @param errorMsg  校验失败时的错误信息
 * @param selectColumns 可选参数,指定需要查询的列,若不传则查询所有列 (SELECT *)
 */
public static <T, V> void assertEntity(IService<T> service, V value, SFunction<T, ?> column, Predicate<T> predicate, String errorMsg, SFunction<T, ?>... selectColumns) {
    LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();

    // 核心优化:如果调用者指定了要查询的列,则应用它们
    if (selectColumns != null && selectColumns.length > 0) {
        wrapper.select(selectColumns);
    }
    
    wrapper.eq(column, value);
    T entity = service.getOne(wrapper);

    if (entity == null) {
        throw new RuntimeException("记录不存在");
    }
    if (!predicate.test(entity)) {
        throw new RuntimeException(errorMsg);
    }
}

如何使用:

现在,assertEntity 的使用变得既强大又高效。

用法一:指定列进行性能优化

我们的发货校验只需要 statusshippingAddress 两个字段。现在可以这么写:

// 订单发货前,进行复杂校验 (只查询必要的2个字段)
public void shipOrder(String orderId) {
    Asserts.assertEntity(
        this,
        orderId,
        Order::getId,
        order -> "PAID".equals(order.getStatus()) && order.getShippingAddress() != null,
        "订单状态非“已支付”或收货地址为空,无法发货",
        // --- 这就是优化的关键 ---
        Order::getStatus, Order::getShippingAddress() 
    );
    
    // ... 发货逻辑
}

生成的SQL将是 SELECT status, shipping_address FROM t_order WHERE id = ?,而不是 SELECT * ...,完美!

用法二:不指定列,兼容旧用法(查询所有字段)

如果校验逻辑确实很复杂,或者你懒得去指定具体列,那么像以前一样调用即可。

// 假设有个非常复杂的校验,需要用到实体的大部分字段
public void complexCheck(String orderId) {
    Asserts.assertEntity(
        this,
        orderId,
        Order::getId,
        order -> {
            // ... 一段依赖很多字段的复杂校验逻辑 ...
            return true;
        },
        "订单不满足复杂的校验条件"
        // 不传入第6个参数,则自动查询所有字段
    );
    
    // ...
}

这样一来,我们的工具箱就非常完备了:

  • 日常简单场景:使用 assertPropertyEquals / assertPropertyNotEquals,获得最佳性能和可读性。
  • 复杂业务场景:使用 assertEntity,牺牲一点查询性能,换取无与伦比的灵活性。

3. 断言批量实体的属性:allEntities

与单体验证同理,批量场景更需要精细化的方法来平衡性能和灵活性。

精准断言:assertAllPropertiesEqual 确保一批记录的某个属性全部都等于期望值。

// Asserts.java
public static <T, V, P> void assertAllPropertiesEqual(IService<T> service, Collection<V> idValues, SFunction<T, V> idColumn, SFunction<T, P> propertyColumn, P expectedValue, String errorMsg) {
    if (idValues == null || idValues.isEmpty()) return;
    Set<V> distinctIds = new HashSet<>(idValues);
    
    // 只查询 id 和要校验的属性列
    List<T> entities = service.lambdaQuery()
            .select(idColumn, propertyColumn)
            .in(idColumn, distinctIds)
            .list();

    if (entities.size() != distinctIds.size()) throw new RuntimeException("部分记录不存在");

    for (T entity : entities) {
        if (!Objects.equals(expectedValue, propertyColumn.apply(entity))) {
            V failedId = idColumn.apply(entity);
            throw new RuntimeException(String.format(errorMsg, failedId)); // 精准报错
        }
    }
}

使用:

// 批量审核前,确保所有单据都是 "待审核" 状态
Asserts.assertAllPropertiesEqual(
    this, orderIds, Order::getId,
    Order::getStatus, OrderStatusEnum.PENDING_REVIEW,
    "订单[%s]不是待审核状态,操作失败"
);

灵活断言:assertAllEntities

批量场景的终极解决方案,支持自定义查询列和自定义校验逻辑。

// Asserts.java
public static <T, V> void assertAllEntities(IService<T> service, Collection<V> idValues, SFunction<T, V> idColumn, Predicate<T> predicate, String errorMsgFormat, SFunction<T, ?>... selectColumns) {
    if (idValues == null || idValues.isEmpty()) return;
    Set<V> distinctIds = new HashSet<>(idValues);

    LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();```
    wrapper.in(idColumn, distinctIds);

    // 动态添加 select 列,必须包含 idColumn 以便后续获取 failedId
    if (selectColumns != null && selectColumns.length > 0) {
        List<SFunction<T, ?>> columns = new ArrayList<>(Arrays.asList(selectColumns));
        columns.add(idColumn); // 确保id列被查询
        wrapper.select(columns.toArray(new SFunction[columns.size()]));
    }
    
    List<T> entities = service.list(wrapper);
    if (entities.size() != distinctIds.size()) throw new RuntimeException("部分记录不存在");

    for (T entity : entities) {
        if (!predicate.test(entity)) {
            V failedId = idColumn.apply(entity);
            throw new RuntimeException(String.format(errorMsgFormat, failedId));
        }
    }
}

使用:

// 批量删除前,确保订单状态为已完成或已取消
Asserts.assertAllEntities(
    this, orderIds, Order::getId,
    order -> OrderStatusEnum.COMPLETED.equals(order.getStatus()) || OrderStatusEnum.CANCELLED.equals(order.getStatus()),
    "订单[%s]状态不正确,无法删除",
    Order::getStatus // 只查 status 就够了
);

为什么我选择将“校验”与“取值”分开?

有朋友可能会问,为什么这个方法是 void 类型?校验通过后我还得再查一次数据库才能拿到实体,这不是多此一举吗?

这是一个非常好的问题,它涉及到职责单一原则过早优化的权衡。

首先,我追求的是职责单一。 exists 这个方法的名字就告诉我们,它的唯一职责就是“确保值存在/有效”,像一个门卫,只负责拦下不该进的人,不负责接待客人。将“校验”和“取值”分开,让每个方法只做一件事,代码的意图会非常清晰。

其次,我们应该警惕“想当然的性能问题”。 在绝大多数业务场景中,一个简单的、带索引的 findById 查询对数据库的压力微乎其微。为了节省这“可能”存在的性能,而去破坏代码的简洁性和职责单一性,往往得不偿失。

我始终认为,代码首先要追求的是好读、好改、好维护。 大多数项目的代码连“好读”都做不到,却在过度担心那些未经测试的性能瓶颈。在一个清晰、可维护的结构之上,如果未来真的出现了性能问题,定位和优化也会变得非常简单。


注意

⚠️ 重要:这个工具的适用场景

必须强调,这个通用方法是一把“手术刀”,而不是“开山斧”。它最适合的场景是:在一个业务操作中,需要校验多个、来源不同(分属不同表)的某个/某些属性的有效性。

比如在创建一个订单时,你需要同时校验 userIdcustomerIdproductId 是否都真实存在。这些ID分属不同实体,本来就需要独立的查询。用我们的方法,可以把这些独立的校验写得非常统一和优雅。


总结

通过上面几步,我们构建了一个小而强大的 Asserts 工具类。它不仅消灭了大量重复的模板代码,还带来了诸多好处:

  • 可读性Asserts.exists(...) 比一堆 if-else 更能清晰地表达“业务意图”。
  • 可维护性:统一了校验逻辑和异常抛出方式,未来需要修改时,只需改动 Asserts 类即可。
  • 类型安全:借助方法引用,彻底告别了手写字段名的低级错误。
  • 高扩展性:基于函数式接口,可以轻松应对未来更复杂的校验需求。

现在,你可以把这个Asserts类放到你项目的 common 包里,开始享受写清爽业务代码的乐趣了!




如果这篇文章对你有帮助,点个赞就是对我最大的鼓励!如果你觉得它未来会派上用场,别忘了点个收藏,方便随时查阅哦!欢迎在评论区留下你的看法。