学习总结录 学习总结录
首页
归档
分类
标签
  • Java基础
  • Java集合
  • MySQL
  • Redis
  • JVM
  • 多线程
  • 计算机网络
  • 操作系统
  • Spring
  • Kafka
  • Elasticsearch
  • Python
  • 面试专题
  • 案例实践
  • 工具使用
  • 项目搭建
  • 服务治理
  • ORM框架
  • 分布式组件
  • MiniSpring
  • 设计模式
  • 算法思想
  • 编码规范
友链
关于
GitHub (opens new window)
首页
归档
分类
标签
  • Java基础
  • Java集合
  • MySQL
  • Redis
  • JVM
  • 多线程
  • 计算机网络
  • 操作系统
  • Spring
  • Kafka
  • Elasticsearch
  • Python
  • 面试专题
  • 案例实践
  • 工具使用
  • 项目搭建
  • 服务治理
  • ORM框架
  • 分布式组件
  • MiniSpring
  • 设计模式
  • 算法思想
  • 编码规范
友链
关于
GitHub (opens new window)
  • 案例实践

  • 工具使用

    • MyBatisPlus
      • 简介
      • 快速入门
      • 配置日志
      • CRUD扩展
        • 插入操作
        • 主键生成策略
        • 不同主键策略
        • 更新操作
        • 自动填充
        • 数据库的方式
        • 代码的方式
      • 乐观锁处理
      • 查询操作
      • 分页查询
      • 删除操作
      • 性能分析插件
      • 条件构造器
      • 代码生成器
      • 参考
    • Linux
    • RabbitMQ
    • Elasticsearch
    • JWT
    • MongoDB
    • Redis
    • Git
    • Kafka
    • Docker
    • PromQL
    • AviatorScript
    • Java 17
    • Groovy
  • 项目搭建

  • 服务治理

  • ORM框架

  • MiniSpring

  • 案例归档
  • 工具使用
旭日
2023-03-27
目录

MyBatisPlus

# 简介

官网地址:MyBatisPlus (opens new window)

# 快速入门

这边可以参考官网的快速入门 (opens new window),按照教程创建好数据库和表之后,开始初始化工程,这边我们使用Springboot(以mysql做为默认的数据库)。

  • 添加依赖
 <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.3.1</version>
        </dependency>
  • 配置
spring:
  # 引入MySQL配置
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/mybatis_plus_learn?useUnicode=true&characterEncoding=utf8&useSSL=false&useLegacyDatetimeCode=false&serverTimezone=GMT%2b8&tinyInt1isBit=true
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root
server:
  port: 8081
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  • 书写代码

以前的情况,我们需要去配置mapper.xml,然后使得这个xml文件去映射到我对应实体类的mapper,这个过程中容易因为粗心,或者配置文件写错导致开发效率麻烦。

创建实体类

@Data
public class User
{
    private Long id;
    private String name;
    private Integer age;
    private String email;
}

创建mapper

// 继承基本的类BaseMapper<T>
public interface UserMapper extends BaseMapper<User>
{
    // 这里已经封装好了所有简单的CRUD操作
    // 如果有特殊的业务,就可以在这里写
}

测试

@SpringBootTest
public class SampleTest
{
    @Autowired
    private UserMapper userMapper;

    @Test
    public void  testSelect() {
        System.out.println(("----- selectAll method test ------"));
        List<User> userList = userMapper.selectList(null);
        userList.forEach(System.out::println);
    }
}

自此我们一个简单的查询操作就完成了,在这个过程中我们没有写一行sql语句,也没有配置任何的mapper文件,非常高效!

# 配置日志

在application.yml中配置如下,可以实现在控制台中打印日志。

mybatis-plus:
  configuration:
    # 配置日志
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

# CRUD扩展

# 插入操作

  • 首先我们进行一个简单的插入操作。
@Test
    public void testInsert () {
        // 这里我们没有设置ID
        User testUser = new User();
        testUser.setName("测试用户");
        testUser.setAge(3);
        testUser.setEmail("151515151@qq.com");
        int insert = userMapper.insert(testUser);
        System.out.println(insert);
    }
  • 这里看到控制台打印了:
Parameters: 1439073877085921281(Long), 测试用户(String), 3(Integer), 151515151@qq.com(String)

我们可以看到这个ID非常的长,下面我们对这个ID进行分析。

# 主键生成策略

数据库插入的id的默认值为: 全局的唯一ID

雪花算法:分布式唯一ID生成算法 (opens new window),几乎可以保证全球唯一

# 不同主键策略

这边我们先看一下IdType的源码

/**
 * 生成ID类型枚举类
 *
 * @author hubin
 * @since 2015-11-10
 */
@Getter
public enum IdType {
    /**
     * 数据库ID自增
     * <p>该类型请确保数据库设置了 ID自增 否则无效</p>
     */
    AUTO(0),
    /**
     * 该类型为未设置主键类型(注解里等于跟随全局,全局里约等于 INPUT)
     */
    NONE(1),
    /**
     * 用户输入ID
     * <p>该类型可以通过自己注册自动填充插件进行填充</p>
     */
    INPUT(2),

    /* 以下3种类型、只有当插入对象ID 为空,才自动填充。 */
    /**
     * 分配ID (主键类型为number或string),
     * 默认实现类 {@link com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator}(雪花算法)
     *
     * @since 3.3.0
     */
    ASSIGN_ID(3),
    /**
     * 分配UUID (主键类型为 string)
     * 默认实现类 {@link com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator}(UUID.replace("-",""))
     */
    ASSIGN_UUID(4);

    private final int key;

    IdType(int key) {
        this.key = key;
    }
}
值 描述
AUTO 数据库ID自增
NONE 无状态,该类型为未设置主键类型(注解里等于跟随全局,全局里约等于 INPUT)
INPUT insert前自行set主键值
ASSIGN_ID 分配ID(主键类型为Number(Long和Integer)或String)(since 3.3.0),使用接口IdentifierGenerator的方法nextId(默认实现类为DefaultIdentifierGenerator雪花算法)
ASSIGN_UUID 分配UUID,主键类型为String(since 3.3.0),使用接口IdentifierGenerator的方法nextUUID(默认default方法)
ID_WORKER 分布式全局唯一ID 长整型类型(please use ASSIGN_ID)
UUID 32位UUID字符串(please use ASSIGN_UUID)
ID_WORKER_STR 分布式全局唯一ID 字符串类型(please use ASSIGN_ID)

可以发现,当我们插入对象的id为空的时候,这时候ID会被自动填充,而此时我们的User表是数字类型,所以会采用雪花算法,由此会为我们生成一个非常长的ID。

下面我们来试一下主键自增的策略:

  • 实体类字段上配置:@TableId(type = IdType.AUTO)
  • 在数据库里记得把主键字段选择自动递增(不然代码和数据库不统一,会报错)

再次运行我们的测试方法,可以得到以下结果:

1439073877085921282 测试用户 3 151515151@qq.com

再运行一次,得到以下结果:

1439073877085921283 测试用户 3 151515151@qq.com

自从我们就实现主键的自增策略!

# 更新操作

接下来,我们再来试一试更新操作,通过更新操作我们来认识以下MybatisPlus是如何实现动态SQL拼接的。

  • baseMapper中封装了通用的更新方法

    /**
     * 根据 ID 修改
     *
     * @param entity 实体对象
     */
    int updateById(@Param(Constants.ENTITY) T entity);

注意这里虽然名称是根据根据ID修改,但是传入的参数是一个实体类

    @Test
    public void testUpdate() {
        User updateUser = new User();
        updateUser.setId(1439073877085921284L);
        updateUser.setName("测试用户修改了");
        updateUser.setAge(10);
        userMapper.updateById(updateUser);
    }

	// 控制台打印的sql语句
	// UPDATE user SET name=?, age=? WHERE id=?
  • 现在我们再去添加一个修改的字段,把邮箱也修改一下,看看控制台打印了什么。
    @Test
    public void testUpdate() {
        User updateUser = new User();
        updateUser.setId(1439073877085921284L);
        updateUser.setName("测试用户修改了");
        updateUser.setAge(10);
        updateUser.setEmail("846212939@qq.com");
        userMapper.updateById(updateUser);
    }

	// 控制台打印的sql语句
	// UPDATE user SET name=?, age=?, email=? WHERE id=?

通过这两个小例子发现所有的SQL语句都是自动帮我们动态配置了的。

# 自动填充

一般来说一个表需要创建时间、修改时间!这些操作都是自动化完成的,我们不希望在我操作的时候,我要手工的去录入这些时间,而是自动化完成

# 数据库的方式

工作中是不允许的,因为数据库已经设计好了,一般来说是不会去动数据库的

这边只是单纯演示一下。

  • 首先在User表添加两个字段create_time,update_time:
    ALTER TABLE `mybatis_plus_learn`.`user` 
    MODIFY COLUMN `create_time` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' AFTER `password`,
    MODIFY COLUMN `update_time` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间' AFTER `create_time`;
  • 在User实体类中添加两个属性:
    private Date createTime;
    private Date updateTime;
  • 再次运行插入方法:

1439073877085921285 测试用户 3 151515151@qq.com 2021-09-19 10:14:37 2021-09-19 10:14:37

  • 对该数据进行修改:

1439073877085921285 测试用户修改了 15 846212939@qq.com 2021-09-19 10:14:37 2021-09-19 10:29:06

这样可以看到数据在录入的时候,创建时间和修改时间都会自动填充,但是当数据在更新的时候,只有修改时间会更新。

# 代码的方式

MybatisPlus-自动填充 (opens new window)

  • 首先把刚才对数据库的操作还原:
    ALTER TABLE `mybatis_plus_learn`.`user` 
    MODIFY COLUMN `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间' AFTER `password`,
    MODIFY COLUMN `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间' AFTER `create_time`;
  • 接着在User实体类的属性上添加对应的注解:
    @TableField(fill = FieldFill.INSERT)
    private Date createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Date updateTime;
  • 这边查看一下源码:

/**
 * 字段填充策略枚举类
 *
 * <p>
 * 判断注入的 insert 和 update 的 sql 脚本是否在对应情况下忽略掉字段的 if 标签生成
 * <if test="...">......</if>
 * 判断优先级比 {@link FieldStrategy} 高
 * </p>
 *
 * @author hubin
 * @since 2017-06-27
 */
public enum FieldFill {
    /**
     * 默认不处理
     */
    DEFAULT,
    /**
     * 插入时填充字段
     */
    INSERT,
    /**
     * 更新时填充字段
     */
    UPDATE,
    /**
     * 插入和更新时填充字段
     */
    INSERT_UPDATE
}
值 描述
DEFAULT 默认不处理
INSERT 插入时填充字段
UPDATE 更新时填充字段
INSERT_UPDATE 插入和更新时填充字段
  • 自定义实现类 MyMetaObjectHandler
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler
{
    /**
     * 插入元对象字段填充(用于插入时对公共字段的填充)
     *
     * @param metaObject 元对象
     */
    @Override
    public void insertFill(MetaObject metaObject)
    {
        log.info("start insert fill ....");
        this.setFieldValByName("createTime", new Date(), metaObject);
        this.setFieldValByName("updateTime", new Date(), metaObject);

    }

    /**
     * 更新元对象字段填充(用于更新时对公共字段的填充)
     *
     * @param metaObject 元对象
     */
    @Override
    public void updateFill(MetaObject metaObject)
    {
        log.info("start update fill ....");
        this.setFieldValByName("updateTime", new Date(), metaObject);
    }
}
  • 进行插入操作:

1439073877085921286 测试用户999 3 151515151@qq.com 2021-09-19 11:30:38 2021-09-19 11:30:38

  • 进行修改操作:

1439073877085921286 测试用户修改了 15 846212939@qq.com 2021-09-19 11:30:38 2021-09-19 11:31:33

这样我们在不修改数据库情况下,就实现了创建时间、更新时间两个字段的自动填充操作。

# 乐观锁处理

MybatisPlus-乐观锁 (opens new window)

乐观锁:十分乐观,总是认为不会出现问题,无论干什么事情,都不会上锁

悲观锁:十分悲观,总是认为会出现问题,无论干什么事情,都会上锁

乐观锁实现方式:

  • 取出记录时,获取当前version
  • 更新时,带上这个version
  • 执行更新时, set version = newVersion where version = oldVersion
  • 如果version不对,就更新失败
-- Thread A
update user set name = "wxx", version = version + 1
where id = 2 and version = 1

-- Thread B
update user set name = "wxx", version = version + 1
where id = 2 and version = 1

两个进程进行争夺,当线程B如果先完成了,那么线程A就会完成不了。

下面我们通过代码来实现一个简单的乐观锁案例:

  • 对数据库进行修改:
ALTER TABLE `mybatis_plus_learn`.`user` 
ADD COLUMN `version` int(20) DEFAULT '1' NULL COMMENT '乐观锁' AFTER `update_time`;
  • 对实体类进行修改:
@Version
private Integer version;
  • 配置插件
@Configuration
@MapperScan("com.xu.mybatis_plus_learn.mapper")
public class MyConfig
{
    // 最新版
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        return interceptor;
    }
}
  • 测试案例
@Test
public void testOptimisticLocker() {
    // 单线程的情况
    User updateUser = userMapper.selectById(1439073877085921286L);
    updateUser.setName("乐观锁用户2");
    userMapper.updateById(updateUser);
}
// 控制台SQL语句
// UPDATE user SET name=?, age=?, email=?, create_time=?, update_time=?, version=? WHERE id=? AND version=?

这里我们可以看到SQL语句已经不一样了,多了一个and version = ?

再看看数据库的内容:

乐观锁用户2 15 846212939@qq.com 2021-09-19 11:30:38 2021-09-19 15:03:38 2

下面演示一下多线程的情况:

@Test
public void testOptimisticLocker2() {
    // 多线程的情况

    // 线程1
    User updateUser1 = userMapper.selectById(1439073877085921285L);
    updateUser1.setName("乐观锁用户-多线程1");

    // 线程2
    User updateUser2 = userMapper.selectById(1439073877085921285L);
    updateUser2.setName("乐观锁用户-多线程2");

    // 这里模仿线程2抢先了
    userMapper.updateById(updateUser2);

    userMapper.updateById(updateUser1);
}

观察一下控制台的打印:

==> Parameters: 乐观锁用户-多线程2(String), 15(Integer), 846212939@qq.com(String), 2021-09-19 10:18:17.0(Timestamp), 2021-09-19 15:12:35.214(Timestamp), 2(Integer), 1439073877085921285(Long), 1(Integer) <== Updates: 1

==> Parameters: 乐观锁用户-多线程1(String), 15(Integer), 846212939@qq.com(String), 2021-09-19 10:18:17.0(Timestamp), 2021-09-19 15:12:35.237(Timestamp), 2(Integer), 1439073877085921285(Long), 1(Integer) <== Updates: 0

可以看到,我们的线程1执行了,但是并没有更新,因为此时version已经因为线程2执行之后更改为2了,而线程1的SQL语句UPDATE user SET name=?, age=?, email=?, create_time=?, update_time=?, version=? WHERE id=? AND version=?的version为1,因此无法进行修改。

# 查询操作

  • 根据ID查询
@Test
public void testSelectById() {
    User user = userMapper.selectById(1);
    System.out.println(user);
}

查询结果:

User(id=1, name=Jone, age=18, email=test1@baomidou.com, password=123456, createTime=null, updateTime=null, version=1)

  • 批量查询
@Test
public void selectBatchIds() {
    List<User> users = userMapper.selectBatchIds(Arrays.asList(1, 2, 3));
    users.forEach(System.out::println);
}

查询结果:

User(id=1, name=Jone, age=18, email=test1@baomidou.com, password=123456, createTime=null, updateTime=null, version=1) User(id=2, name=Jack, age=20, email=test2@baomidou.com, password=123456, createTime=null, updateTime=null, version=1) User(id=3, name=Tom, age=28, email=test3@baomidou.com, password=123456, createTime=null, updateTime=null, version=1)

  • 条件查询
@Test
public void selectByMap() {
    // 简单的条件查询,复杂的查询后面会讲述
    HashMap<String, Object> map = new HashMap<>();
    map.put("email", "151515151@qq.com");
    List<User> users = userMapper.selectByMap(map);
    users.forEach(System.out::println);
}

查询结果:

User(id=1439073877085921281, name=测试用户, age=3, email=151515151@qq.com, password=null, createTime=null, updateTime=null, version=1) User(id=1439073877085921282, name=测试用户, age=3, email=151515151@qq.com, password=null, createTime=null, updateTime=null, version=1) User(id=1439073877085921283, name=测试用户, age=3, email=151515151@qq.com, password=null, createTime=null, updateTime=null, version=1)

# 分页查询

MybatisPlus-分页插件 (opens new window)

  • 注册分页插件
@Configuration
@MapperScan("com.xu.mybatis_plus_learn.mapper")
public class MyConfig
{
    // 最新版
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}
  • 使用分页插件进行查询
@Test
public void selectByPage() {
    Page<User> page = new Page<>(1, 5);
    userMapper.selectPage(page, null);
    page.getRecords().forEach(System.out::println);
}
==>  Preparing: SELECT id,name,age,email,password,create_time,update_time,version FROM user LIMIT ?
==> Parameters: 5(Long)
<==    Columns: id, name, age, email, password, create_time, update_time, version
<==        Row: 1, Jone, 18, test1@baomidou.com, 123456, null, null, 1
<==        Row: 2, Jack, 20, test2@baomidou.com, 123456, null, null, 1
<==        Row: 3, Tom, 28, test3@baomidou.com, 123456, null, null, 1
<==        Row: 4, 66666, 7, 6666, 123456, null, null, 1
<==        Row: 5, Billie, 24, test5@baomidou.com, 123456, null, null, 1
<==      Total: 5

# 删除操作

  • 普通删除
    @Test
    public void deleteById() {
        userMapper.deleteById(1439073877085921286L);
    }

    @Test
    public void deleteMap() {
        HashMap<String, Object> map = new HashMap<>();
        map.put("name", "测试用户");
        userMapper.deleteByMap(map);
    }
  • 逻辑删除

在工作中,我们的数据并不是真正的删除,在数据库中没有被移除,而是通过一个变量让他来失效。

MybatisPlus-逻辑删除 (opens new window)

比如我们文章的管理,一般删除一个文章不是真正的删除了,而是丢到了垃圾桶。

下面我们来书写一个逻辑删除的小案例:

  • 修改数据库:
ALTER TABLE `mybatis_plus_learn`.`user` 
ADD COLUMN `deleted` int(1) NULL DEFAULT 0 COMMENT '逻辑删除' AFTER `version`;
  • 修改实体类字段:
@TableLogic
private Integer deleted;
  • 配置文件:
mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: deleted  # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
  • 删除测试:
==>  Preparing: UPDATE user SET deleted=1 WHERE id=? AND deleted=0
==> Parameters: 1439073877085921286(Long)
<==    Updates: 1

可以看到逻辑删除本质上是一个更新操作,把逻辑删除这个字段进行了修改而已。

# 性能分析插件

在我们日常开发工作当中,避免不了查看当前程序所执行的SQL语句,有时候还需要根据SQL的执行时间来优化sql语句。

MybatisPlus-性能分析插件 (opens new window)

旧版的PerformanceInterceptor已经被弃用

  • p6spy 依赖引入
<dependency>
  <groupId>p6spy</groupId>
  <artifactId>p6spy</artifactId>
  <version>最新版本</version>
</dependency>
  • application.yml 配置

url和driver-class-name要修改,同时记得设置为了生产或者测试环境

spring:
  # 引入MySQL配置
  datasource:
    url: jdbc:p6spy:mysql://127.0.0.1:3306/mybatis_plus_learn?useUnicode=true&characterEncoding=utf8&useSSL=false&useLegacyDatetimeCode=false&serverTimezone=GMT%2b8&tinyInt1isBit=true
    driver-class-name: com.p6spy.engine.spy.P6SpyDriver
    username: root
    password: root
  profiles:
    active: dev
// 控制台打印
 Consume Time:11 ms 2021-09-20 00:57:53
 Execute SQL:SELECT id,name,age,email,password,create_time,update_time,version,deleted FROM user WHERE deleted=0

# 条件构造器

在日常的查询中,我们可能会遇到一些特殊条件,比如年龄在多少区间的、某某字段不为空的情况,这时候就需要我们用到条件构造器了。

下面我们通过书写几个案例来熟悉一下:

  • 案例1-多条件
@Test
    private void test1() {
        // 查询name不为空的用户,并且邮箱不为空的用户,并且年龄大于12
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.isNotNull("name").isNotNull("email").eq("age", 12);
        List<User> users = userMapper.selectList(wrapper);
        users.forEach(System.out::println);
    }
 Consume Time:16 ms 2021-09-20 09:43:03
 Execute SQL:SELECT id,name,age,email,password,create_time,update_time,version,deleted FROM user WHERE deleted=0 AND (name IS NOT NULL AND email IS NOT NULL AND age = 12)
  • 案例2-区间查询
@Test
public void test2() {
// 查询一个区间的人数
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.between("age", 20, 30);
Integer integer = userMapper.selectCount(wrapper);
System.out.println(integer);
}
 Consume Time:17 ms 2021-09-20 09:52:35
 Execute SQL:SELECT COUNT( * ) FROM user WHERE deleted=0 AND (age BETWEEN 20 AND 30)
  • 案例3-模糊查询
@Test
public void test3() {
    // 模糊查询
    QueryWrapper<User> wrapper = new QueryWrapper<>();
    wrapper.likeRight("email", "t");
    List<User> users = userMapper.selectList(wrapper);
    users.forEach(System.out::println);
}
 Consume Time:14 ms 2021-09-20 09:56:24
 Execute SQL:SELECT id,name,age,email,password,create_time,update_time,version,deleted FROM user WHERE deleted=0 AND (email LIKE 't%')

# 代码生成器

MybatisPLus-代码生成器 (opens new window)

MybatisPlus-快速开发插件 (opens new window)

# 参考

MyBatisPlus (opens new window)

上次更新: 2024/06/29, 15:13:44
验证码
Linux

← 验证码 Linux→

最近更新
01
基础概念
10-31
02
Pytorch
10-30
03
Numpy
10-30
更多文章>
Theme by Vdoing | Copyright © 2021-2024 旭日 | 蜀ICP备2021000788号-1
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式