MinIo 的操作与使用和避坑

官方 API 文档:Documentation
官方中文文档:MinIO中文文档

一、介绍

  MinIO 是开源的对象存储服务器,相当于免费版的 OSS。

  MinIO 是一款高性能、分布式的开源对象存储系统,它是一款软件产品。MinIO 公司旨在解决非结构化数据增长的需求,开发了流行于业界的开源云存储软件 MinIO。

  虽然 MinIO 是 100% 开源的,但它既是一家公司又是一个开源项目。它采用 GNU AGPL v3 开源证书,拥有 GNU AGPL 代码的版权,同时还是 MinIO 项目的主要贡献者,可独立对 MinIO 进行维护。

  MinIO 基于 Apache License 2.0 开源协议的对象存储服务。它兼容 Amazon S3 云存储接口。适合存储非结构化数据,如图片,音频,视频,日志等。

二、安装

官网下载链接:官网下载界面
在这里插入图片描述

# 下载安装包
[root@xiaoqiang ~]# wget https://dl.min.io/server/minio/release/linux-amd64/archive/minio_20230930070229.0.0_amd64.deb -O minio.deb
[root@xiaoqiang  ~]# sudo dpkg -i minio.deb

# 设置
[root@xiaoqiang  ~]# sudo touch  /etc/default/minio
[root@xiaoqiang  ~]# cat /etc/default/minio 
MINIO_ROOT_USER="xiaoiang"
MINIO_ROOT_PASSWORD="heheda123"
MINIO_VOLUMES="/dataspace/xiaoqiang-data-backup"

# 创建用户和组,分配目录权限
[root@xiaoqiang  ~]# sudo groupadd -r minio-user
[root@xiaoqiang  ~]# sudo useradd -M -r -g minio-user minio-user
[root@xiaoqiang  ~]# sudo chown minio-user:minio-user -R /dataspace/xiaoqiang-data-backup

# 启动服务
[root@xiaoqiang  ~]# sudo systemctl daemon-reload
[root@xiaoqiang  ~]# sudo systemctl enable minio.service
[root@xiaoqiang  ~]# sudo systemctl start minio.service

注意: minio 与 Prometheus、clickhouse 端口冲突,如果启动服务失败,注意关掉相关服务。 netstat -antulp | grep 9000,kill 掉该进程,minio 服务可以重启成功。

在这里插入图片描述

参考:MinIO使用及整合起步依赖

三、Client 连通与避坑

  Java 代码:

        MinioClient minioClient =
                MinioClient.builder()
                        .endpoint("http://192.168.110.110:9000")
                        .credentials("xiaoqiang", "heheda123")
                        .build();
        System.out.println(minioClient);
        // 检查桶是否存在
        boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket("test").build());
        if (!found) {
            // 创建桶
            minioClient.makeBucket(MakeBucketArgs.builder().bucket("test").build());
        }

        //列出所有桶名
        List<Bucket> buckets = minioClient.listBuckets();
        for (Bucket i : buckets){
            System.out.println(i.name());
        }

  Maven 引入:

        <!--此依赖为minio的服务依赖-->
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>8.5.7</version>
        </dependency>
踩坑1:jar 包引入冲突

  报错:Unsupported OkHttp library found. Must use okhttp >= 4.11.0

在这里插入图片描述

        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>8.5.2</version>
        </dependency>
        <!--或者-->
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>8.4.5</version>
        </dependency>
        <!--或者-->
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>8.3.7</version>
        </dependency>

  报错:Unsupported OkHttp library found. Must use okhttp >= 4.8.1

在这里插入图片描述

        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>8.2.1</version>
        </dependency>
        <!--或者-->
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>8.1.0</version>
        </dependency>
        <!--或者-->
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>8.0.3</version>
        </dependency>

  报错:Exception in thread "main" java.lang.NoSuchMethodError: kotlin.collections.ArraysKt.copyInto([B[BIII)[B

在这里插入图片描述
  尝试解决1:

在这里插入图片描述
  结论:不好使。

  尝试解决2:新建一个纯 Maven 项目。好使

在这里插入图片描述
在这里插入图片描述
  接下来的解决思路:将项目中的依赖依次注释最终只留 Minio 的依赖,看到底是哪个引入的依赖和 Minio 的依赖有冲突。

1. SpringBoot 项目

  最终发现是 spring-boot-starter-parent 的依赖导致的。

在这里插入图片描述
在这里插入图片描述

  居然是 SpringBoot 的顶层依赖导致的,也是绝了。尝试了下低版本居然可以。

在这里插入图片描述

  更高的版本居然也可以。

在这里插入图片描述
  经测试,框起来的版本可以用。
在这里插入图片描述

2. 自己的 Maven 项目(非 SpringBoot 项目)

  经排查,居然是 phoenix 的引入导致的,该 jar 包是从 Cloudera Manager 集群中下载后通过 mvn install 命令手动引入依赖,网上 Maven 仓库并没有这个 jar 包,现在该 jar 包中的 okio 和 minio 中的 okio 冲突导致的。报错:Exception in thread "main" java.lang.NoSuchFieldError: Companion

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  而且通过 exclusion 排除该 jar 包的方式并不好使,可能得重新编译该 jar 包。

思路1:尝试从 Maven 仓库中替换该 jar 包

在这里插入图片描述
在这里插入图片描述
  但总是导入一个依赖就缺另一个依赖。

思路2:改造有问题的 jar 包

  打开 phoenix-5.0.0-cdh6.2.0-client.jar 文件删除 okio 再重新手动安装。测试后发现该方法管用。
在这里插入图片描述

踩坑2:磁盘不足导致上传文件失败

  上传文件的时候报错:Error: Storage backend has reached its minimum free drive threshold. Please delete a few objects to proceed

在这里插入图片描述
  原因:磁盘空间不足导致的。参考:【MinIO异常】Storage reached its minimum free drive threshold 的解决方案

在这里插入图片描述

四、封装一些简单的方法

来自:minio的基本使用——java

package com.jdh.minio.config;
 
import io.minio.*;
import io.minio.errors.*;
import io.minio.http.Method;
import io.minio.messages.Bucket;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
 
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
 
/**
 * @ClassName: MinioFile
 * @Author: jdh
 * @CreateTime: 2022-04-15
 * @Description:
 */
@Configuration
@Slf4j
public class MinioFileUtil {
 
    @Resource
    private MinioProperties minioProperties;
 
    private MinioClient minioClient;
 
    /**
     * 这个是6.0.左右的版本
     * @return MinioClient
     */
//    @Bean
//    public MinioClient getMinioClient(){
//
//        String url = "http:" + minioProperties.getIp() + ":" + minioProperties.getPort();
//
//        try {
//            return new MinioClient(url, minioProperties.getAccessKey(), minioProperties.getSecretKey());
//        } catch (InvalidEndpointException | InvalidPortException e) {
//            e.printStackTrace();
//            log.info("-----创建Minio客户端失败-----");
//            return null;
//        }
//    }
 
    /**
     * 下面这个和上面的意思差不多,但是这个是新版本
     * 获取一个连接minio服务端的客户端
     *
     * @return MinioClient
     */
    @Bean
    public MinioClient getClient() {
 
        String url = "http:" + minioProperties.getIp() + ":" + minioProperties.getPort();
        MinioClient minioClient = MinioClient.builder()
                .endpoint(url)    //两种都可以,这种全路径的其实就是下面分开配置一样的
//                        .endpoint(minioProperties.getIp(),minioProperties.getPort(),minioProperties.getSecure())
                .credentials(minioProperties.getAccessKey(), minioProperties.getSecretKey())
                .build();
        this.minioClient = minioClient;
        return minioClient;
    }
 
    /**
     * 创建桶
     *
     * @param bucketName 桶名称
     */
    public void createBucket(String bucketName) throws Exception {
        if (!StringUtils.hasLength(bucketName)) {
            throw new RuntimeException("创建桶的时候,桶名不能为空!");
        }
 
        // Create bucket with default region.
        minioClient.makeBucket(MakeBucketArgs.builder()
                .bucket(bucketName)
                .build());
    }
 
    /**
     * 创建桶,固定minio容器
     *
     * @param bucketName 桶名称
     */
    public void createBucketByRegion(String bucketName, String region) throws Exception {
        if (!StringUtils.hasLength(bucketName)) {
            throw new RuntimeException("创建桶的时候,桶名不能为空!");
        }
        MinioClient minioClient = this.getClient();
 
        // Create bucket with specific region.
        minioClient.makeBucket(MakeBucketArgs.builder()
                .bucket(bucketName)
                .region(region) //
                .build());
 
//        // Create object-lock enabled bucket with specific region.
//        minioClient.makeBucket(
//                MakeBucketArgs.builder()
//                        .bucket("my-bucketname")
//                        .region("us-west-1")
//                        .objectLock(true)
//                        .build());
    }
 
    /**
     * 修改桶名
     * (minio不支持直接修改桶名,但是可以通过复制到一个新的桶里面,然后删除老的桶)
     *
     * @param oldBucketName 桶名称
     * @param newBucketName 桶名称
     */
    public void renameBucket(String oldBucketName, String newBucketName) throws Exception {
        if (!StringUtils.hasLength(oldBucketName) || !StringUtils.hasLength(newBucketName)) {
            throw new RuntimeException("修改桶名的时候,桶名不能为空!");
        }
 
    }
 
    /**
     * 删除桶
     *
     * @param bucketName 桶名称
     */
    public void deleteBucket(String bucketName) throws Exception {
        if (!StringUtils.hasLength(bucketName)) {
            throw new RuntimeException("删除桶的时候,桶名不能为空!");
        }
 
        minioClient.removeBucket(
                RemoveBucketArgs.builder()
                        .bucket(bucketName)
                        .build());
    }
 
    /**
     * 检查桶是否存在
     *
     * @param bucketName 桶名称
     * @return boolean true-存在 false-不存在
     */
    public boolean checkBucketExist(String bucketName) throws Exception {
        if (!StringUtils.hasLength(bucketName)) {
            throw new RuntimeException("检测桶的时候,桶名不能为空!");
        }
 
        return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
    }
 
    /**
     * 列出所有的桶
     *
     * @return 所有桶名的集合
     */
    public List<Bucket> getAllBucketInfo() throws Exception {
 
        //列出所有桶
        List<Bucket> buckets = minioClient.listBuckets();
        return buckets;
    }
 
    /**
     * 列出某个桶中的所有文件名
     * 文件夹名为空时,则直接查询桶下面的数据,否则就查询当前桶下对于文件夹里面的数据
     *
     * @param bucketName 桶名称
     * @param folderName 文件夹名
     * @param isDeep     是否递归查询
     */
    public Iterable<Result<Item>> getBucketAllFile(String bucketName, String folderName, Boolean isDeep) throws Exception {
        if (!StringUtils.hasLength(bucketName)) {
            throw new RuntimeException("获取桶中文件列表的时候,桶名不能为空!");
        }
        if (!StringUtils.hasLength(folderName)) {
            folderName = "";
        }
        System.out.println(folderName);
        Iterable<Result<Item>> listObjects = minioClient.listObjects(
                ListObjectsArgs
                        .builder()
                        .bucket(bucketName)
                        .prefix(folderName + "/")
                        .recursive(isDeep)
                        .build());
 
//        for (Result<Item> result : listObjects) {
//            Item item = result.get();
//            System.out.println(item.objectName());
//        }
 
        return listObjects;
    }
 
    /**
     * 删除文件夹
     *
     * @param bucketName 桶名
     * @param objectName 文件夹名
     * @param isDeep     是否递归删除
     * @return
     */
    public Boolean deleteBucketFile(String bucketName, String objectName) {
        if (!StringUtils.hasLength(bucketName) || !StringUtils.hasLength(objectName)) {
            throw new RuntimeException("删除文件的时候,桶名或文件名不能为空!");
        }
        try {
            minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(objectName).build());
            return true;
        } catch (Exception e) {
            log.info("删除文件失败");
            return false;
        }
    }
 
    /**
     * 删除文件夹
     *
     * @param bucketName 桶名
     * @param objectName 文件夹名
     * @param isDeep     是否递归删除
     * @return
     */
    public Boolean deleteBucketFolder(String bucketName, String objectName, Boolean isDeep) {
        if (!StringUtils.hasLength(bucketName) || !StringUtils.hasLength(objectName)) {
            throw new RuntimeException("删除文件夹的时候,桶名或文件名不能为空!");
        }
        try {
            ListObjectsArgs args = ListObjectsArgs.builder().bucket(bucketName).prefix(objectName + "/").recursive(isDeep).build();
            Iterable<Result<Item>> listObjects = minioClient.listObjects(args);
            listObjects.forEach(objectResult -> {
                try {
                    Item item = objectResult.get();
                    minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(item.objectName()).build());
                } catch (Exception e) {
                    log.info("删除文件夹中的文件异常", e);
                }
            });
            return true;
        } catch (Exception e) {
            log.info("删除文件夹失败");
            return false;
        }
    }
 
    /**
     * 获取文件下载地址
     *
     * @param bucketName 桶名
     * @param objectName 文件名
     * @param expires    过期时间,默认秒
     * @return
     * @throws Exception
     */
    public String getFileDownloadUrl(String bucketName, String objectName, Integer expires) throws Exception {
 
        GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder()
                .method(Method.GET)//下载地址的请求方式
                .bucket(bucketName)
                .object(objectName)
                .expiry(expires, TimeUnit.SECONDS)//下载地址过期时间
                .build();
        String objectUrl = minioClient.getPresignedObjectUrl(args);
        return objectUrl;
    }
 
    /**
     * 获取文件上传地址(暂时还未实现)
     *
     * @param bucketName 桶名
     * @param objectName 文件名
     * @param expires    过期时间,默认秒
     * @return
     * @throws Exception
     */
    public String getFileUploadUrl(String bucketName, String objectName, Integer expires) throws Exception {
 
        // 过期时间
        ZonedDateTime zonedDateTime = ZonedDateTime.now().plusSeconds(60);
        PostPolicy postPolicy = new PostPolicy(bucketName, zonedDateTime);
 
        // 获取对象的默认权限策略
        StatObjectResponse statObjectResponse = minioClient.statObject(StatObjectArgs.builder().bucket(bucketName).object(objectName).build());
        String objectPolicy = statObjectResponse.headers().get("x-amz-object-policy");
 
        String presignedObjectUrl = minioClient.getPresignedObjectUrl(
                GetPresignedObjectUrlArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .method(Method.POST)
                        .expiry(expires) // 预签名的 URL 有效期为 1 小时
                        .build());
 
        MyMinioClient client = new MyMinioClient(minioClient);
 
        return presignedObjectUrl;
    }
 
    /**
     * 创建文件夹
     *
     * @param bucketName 桶名
     * @param folderName 文件夹名称
     * @return
     * @throws Exception
     */
    public ObjectWriteResponse createBucketFolder(String bucketName, String folderName) throws Exception {
 
        if (!checkBucketExist(bucketName)) {
            throw new RuntimeException("必须在桶存在的情况下才能创建文件夹");
        }
        if (!StringUtils.hasLength(folderName)) {
            throw new RuntimeException("创建的文件夹名不能为空");
        }
        PutObjectArgs putObjectArgs = PutObjectArgs.builder()
                .bucket(bucketName)
                .object(folderName + "/")
                .stream(new ByteArrayInputStream(new byte[0]), 0, 0)
                .build();
        ObjectWriteResponse objectWriteResponse = minioClient.putObject(putObjectArgs);
 
 
        return objectWriteResponse;
    }
 
    /**
     * 检测某个桶内是否存在某个文件
     *
     * @param objectName 文件名称
     * @param bucketName 桶名称
     */
    public boolean getBucketFileExist(String objectName, String bucketName) throws Exception {
        if (!StringUtils.hasLength(objectName) || !StringUtils.hasLength(bucketName)) {
            throw new RuntimeException("检测文件的时候,文件名和桶名不能为空!");
        }
 
        try {
            // 判断文件是否存在
            boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()) &&
                    minioClient.statObject(StatObjectArgs.builder().bucket(bucketName).object(objectName).build()) != null;
            return exists;
        } catch (ErrorResponseException e) {
            log.info("文件不存在 ! Object does not exist");
            return false;
        } catch (Exception e) {
            throw new Exception(e);
        }
    }
 
    /**
     * 判断桶中是否存在文件夹
     *
     * @param bucketName 同名称
     * @param objectName 文件夹名称
     * @param isDeep     是否递归查询(暂不支持)
     * @return
     */
    public Boolean checkBucketFolderExist(String bucketName, String objectName, Boolean isDeep) {
 
        Iterable<Result<Item>> results = minioClient.listObjects(
                ListObjectsArgs.builder().bucket(bucketName).prefix(objectName).recursive(isDeep).build());
 
        return results.iterator().hasNext(); // 文件夹下存在文件
    }
 
    /**
     * 根据MultipartFile file上传文件
     * minio 采用文件流上传,可以换成下面的文件上传
     *
     * @param file       上传的文件
     * @param bucketName 上传至服务器的桶名称
     */
    public boolean uploadFile(MultipartFile file, String bucketName) throws Exception {
 
        if (file == null || file.getSize() == 0 || file.isEmpty()) {
            throw new RuntimeException("上传文件为空,请重新上传");
        }
 
        if (!StringUtils.hasLength(bucketName)) {
            log.info("传入桶名为空,将设置默认桶名:" + minioProperties.getBucketName());
            bucketName = minioProperties.getBucketName();
            if (!this.checkBucketExist(minioProperties.getBucketName())) {
                this.createBucket(minioProperties.getBucketName());
            }
        }
 
        if (!this.checkBucketExist(bucketName)) {
            throw new RuntimeException("当前操作的桶不存在!");
        }
 
        // 获取上传的文件名
        String filename = file.getOriginalFilename();
        assert filename != null;
        //可以选择生成一个minio中存储的文件名称
        String minioFilename = UUID.randomUUID().toString() + "_" + filename;
        String url = "http:" + minioProperties.getIp() + ":" + minioProperties.getPort();
 
        InputStream inputStream = file.getInputStream();
        long size = file.getSize();
        String contentType = file.getContentType();
 
        // Upload known sized input stream.
        minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName) //上传到指定桶里面
                        .object(minioFilename)//文件在minio中存储的名字
                        //p1:上传的文件流;p2:上传文件总大小;p3:上传的分片大小
                        .stream(inputStream, size, -1) //上传分片文件流大小,如果分文件上传可以采用这种形式
                        .contentType(contentType) //文件的类型
                        .build());
 
        return this.getBucketFileExist(minioFilename, bucketName);
    }
 
    /**
     * 上传本地文件,根据路径上传
     * minio 采用文件内容上传,可以换成上面的流上传
     *
     * @param filePath 上传本地文件路径
     * @Param bucketName 上传至服务器的桶名称
     */
    public boolean uploadPath(String filePath, String bucketName) throws Exception {
 
        File file = new File(filePath);
        if (!file.isFile()) {
            throw new RuntimeException("上传文件为空,请重新上传");
        }
 
        if (!StringUtils.hasLength(bucketName)) {
            log.info("传入桶名为空,将设置默认桶名:" + minioProperties.getBucketName());
            bucketName = minioProperties.getBucketName();
            if (!this.checkBucketExist(minioProperties.getBucketName())) {
                this.createBucket(minioProperties.getBucketName());
            }
        }
 
        if (!this.checkBucketExist(bucketName)) {
            throw new RuntimeException("当前操作的桶不存在!");
        }
 
        String minioFilename = UUID.randomUUID().toString() + "_" + file.getName();//获取文件名称
        String fileType = minioFilename.substring(minioFilename.lastIndexOf(".") + 1);
 
        minioClient.uploadObject(
                UploadObjectArgs.builder()
                        .bucket(bucketName)
                        .object(minioFilename)//文件存储在minio中的名字
                        .filename(filePath)//上传本地文件存储的路径
                        .contentType(fileType)//文件类型
                        .build());
 
        return this.getBucketFileExist(minioFilename, bucketName);
    }
 
    /**
     * 文件下载,通过http返回,即在浏览器下载
     *
     * @param response   http请求的响应对象
     * @param bucketName 下载指定服务器的桶名称
     * @param objectName 下载的文件名称
     */
    public void downloadFile(HttpServletResponse response, String bucketName, String objectName) throws Exception {
        if (response == null || !StringUtils.hasLength(bucketName) || !StringUtils.hasLength(objectName)) {
            throw new RuntimeException("下载文件参数不全!");
        }
 
        if (!this.checkBucketExist(bucketName)) {
            throw new RuntimeException("当前操作的桶不存在!");
        }
 
        //获取一个下载的文件输入流操作
        GetObjectResponse objectResponse = minioClient.getObject(
                GetObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .build());
 
        OutputStream outputStream = response.getOutputStream();
        int len = 0;
        byte[] buf = new byte[1024 * 8];
        while ((len = objectResponse.read(buf)) != -1) {
            outputStream.write(buf, 0, len);
        }
        if (outputStream != null) {
            outputStream.close();
            outputStream.flush();
        }
        objectResponse.close();
    }
 
    /**
     * 文件下载到指定路径
     *
     * @param downloadPath 下载到本地路径
     * @param bucketName   下载指定服务器的桶名称
     * @param objectName   下载的文件名称
     */
    public void downloadPath(String downloadPath, String bucketName, String objectName) throws Exception {
        if (downloadPath.isEmpty() || !StringUtils.hasLength(bucketName) || !StringUtils.hasLength(objectName)) {
            throw new RuntimeException("下载文件参数不全!");
        }
 
        if (!new File(downloadPath).isDirectory()) {
            throw new RuntimeException("本地下载路径必须是一个文件夹或者文件路径!");
        }
 
        if (!this.checkBucketExist(bucketName)) {
            throw new RuntimeException("当前操作的桶不存在!");
        }
 
        downloadPath += objectName;
 
        minioClient.downloadObject(
                DownloadObjectArgs.builder()
                        .bucket(bucketName) //指定是在哪一个桶下载
                        .object(objectName)//是minio中文件存储的名字;本地上传的文件是user.xlsx到minio中存储的是user-minio,那么这里就是user-minio
                        .filename(downloadPath)//需要下载到本地的路径,一定是带上保存的文件名;如 d:\\minio\\user.xlsx
                        .build());
    }
 
}
  • 12
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小强签名设计

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值