MINIO工具类记录

文件管理客户端

package kl.gayxzc.csr.cilent;

import io.minio.GetObjectArgs;
import io.minio.GetObjectResponse;
import io.minio.GetPresignedObjectUrlArgs;
import io.minio.ListObjectsArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import io.minio.Result;
import io.minio.StatObjectArgs;
import io.minio.http.Method;
import io.minio.messages.Item;
import kl.gayxzc.csr.constant.CsrErrorCode;
import kl.gayxzc.csr.constant.MinioConstant;
import kl.gayxzc.csr.exception.MinioException;
import kl.gayxzc.csr.utils.DateUtils;
import kl.gayxzc.csr.utils.UUIDUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.FastByteArrayOutputStream;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

/**
 * minio-client
 * 文件管理客户端
 *
 * @author ldh
 */
@Slf4j
@Component("fileClient")
public class FileClient {

    private MinioClient minioClient;

    private BucketClient bucketClient;

    @Value("${csr.minio.file-server-type}")
    private String minioServerType;


    @Autowired
    public void setMinioClient(MinioClient minioClient) {
        this.minioClient = minioClient;
    }

    @Autowired
    public void setBucketClient(BucketClient bucketClient) {
        this.bucketClient = bucketClient;
    }

    /**
     * 上传文件到默认存储桶
     *
     * @param files 文件列表
     * @return 上传后的路径
     * @throws Exception 上传失败异常
     */
    public List<String> put(MultipartFile... files) {
        return put(null, files);
    }

    /**
     * 上传文件到指定存储桶
     *
     * @param path  上传后的路径
     * @param files 文件列表
     * @return 上传后的路径
     * @throws Exception 上传失败异常
     */
    public List<String> put(String path, MultipartFile... files) {
        if (files == null || files.length == 0) {
            return new ArrayList<>();
        }
        List<String> list = new LinkedList<>();
        for (MultipartFile file : files) {
            list.add(put(path, file));
        }
        return list;
    }

    /**
     * 下载默认存储桶中的文件
     *
     * @param fileName 文件名称
     * @param response 响应体
     * @throws Exception 下载失败异常
     */
    public void download(String fileName, HttpServletResponse response) {
        GetObjectArgs objectArgs = GetObjectArgs.builder().bucket(bucketClient.getDefaultBucket())
                .object(fileName).build();
        try (GetObjectResponse getObjectRes = minioClient.getObject(objectArgs)) {
            byte[] buf = new byte[1024];
            int len;
            try (FastByteArrayOutputStream os = new FastByteArrayOutputStream()) {
                while ((len = getObjectRes.read(buf)) != -1) {
                    os.write(buf, 0, len);
                }
                os.flush();
                byte[] bytes = os.toByteArray();
                response.setCharacterEncoding("utf-8");
                final String[] split = fileName.split(MinioConstant.DEFAULT_FILENAME_SEPARATOR);
                String orgFilename = split.length > 1 ? split[split.length - 1] : fileName;
                log.info("file original name is {}", orgFilename);
                response.addHeader("Content-Disposition", "attachment;fileName=" + URLEncoder.encode(orgFilename, "utf-8"));
                try (ServletOutputStream stream = response.getOutputStream()) {
                    stream.write(bytes);
                    stream.flush();
                }
            }
        } catch (Exception e) {
            log.error("file {} download fail", fileName, e);
            throw new MinioException(CsrErrorCode.DOWNLOAD_ERROR);
        }
    }


    /**
     * 下载指定存储桶中的文件
     *
     * @param fileName 文件名称
     * @param name     指定桶名称
     * @param response 响应体
     * @throws Exception 下载失败异常
     */
    public void download(String fileName, String name, HttpServletResponse response) {
        GetObjectArgs objectArgs = GetObjectArgs.builder().bucket(name)
                .object(fileName).build();
        try (GetObjectResponse getObjectRes = minioClient.getObject(objectArgs)) {
            byte[] buf = new byte[1024];
            int len;
            try (FastByteArrayOutputStream os = new FastByteArrayOutputStream()) {
                while ((len = getObjectRes.read(buf)) != -1) {
                    os.write(buf, 0, len);
                }
                os.flush();
                byte[] bytes = os.toByteArray();
                response.setCharacterEncoding("utf-8");
                final String[] split = fileName.split(MinioConstant.DEFAULT_FILENAME_SEPARATOR);
                String orgFilename = split.length > 1 ? split[split.length - 1] : fileName;
                log.info("file original name is {}", orgFilename);
                response.addHeader("Content-Disposition", "attachment;fileName=" + URLEncoder.encode(orgFilename, "utf-8"));
                try (ServletOutputStream stream = response.getOutputStream()) {
                    stream.write(bytes);
                    // 与框架冲突,保留框架层关闭 >> ResponseResult.success();
//                    stream.flush();
                }
            }
        } catch (Exception e) {
            log.error("file {} download fail", fileName, e);
            throw new MinioException(CsrErrorCode.DOWNLOAD_ERROR);
        }
    }

    /**
     * 预览图片
     *
     * @param fileName 文件名称
     * @return 预览地址
     */
    public String preview(String fileName) {
        // 查看文件地址
        final GetPresignedObjectUrlArgs build = GetPresignedObjectUrlArgs.builder()
                .bucket(bucketClient.getDefaultBucket())
                .object(fileName)
                .method(Method.GET)
                .build();
        try {
            return minioClient.getPresignedObjectUrl(build);
        } catch (Exception e) {
            log.error("file {} preview failed", fileName, e);
            throw new MinioException(CsrErrorCode.PREVIEW_ERROR);

        }
    }

    /**
     * 预览图片
     *
     * @param fileName   文件名称
     * @param bucketName 桶名称
     * @return 预览地址
     */
    public String preview(String fileName, String bucketName) {
        // 查看文件地址
        final GetPresignedObjectUrlArgs build = GetPresignedObjectUrlArgs.builder()
                .bucket((Strings.isBlank(bucketName)) ? bucketClient.getDefaultBucket() : bucketName)
                .object(fileName)
                .method(Method.GET)
                .build();
        try {
            return minioClient.getPresignedObjectUrl(build);
        } catch (Exception e) {
            log.error("file {} preview failed", fileName, e);
            throw new MinioException(CsrErrorCode.PREVIEW_ERROR);

        }
    }

    /**
     * 删除⽂件
     *
     * @param objectName ⽂件名称
     * @throws Exception 删除文件异常
     */
    public void removeObject(String objectName) {
        removeObject(bucketClient.getDefaultBucket(), objectName);
    }

    /**
     * 删除⽂件
     *
     * @param bucketName bucket名称
     * @param objectName ⽂件名称
     * @throws Exception 删除文件异常
     */
    public void removeObject(String bucketName, String objectName) {
        try {
            minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(objectName).build());
        } catch (Exception e) {
            log.error("file {} remove failed", objectName, e);
            throw new MinioException(CsrErrorCode.REMOVE_ERROR);
        }
    }


    /**
     * 删除文件夹及文件
     *
     * @param bucketName bucket名称
     * @since tarzan LIU
     */
    public void deleteObject(String bucketName) {
        boolean flag = bucketClient.bucketExists(bucketName);
        if (flag) {
            try {
                // 递归列举某个bucket下的所有文件,然后循环删除
                log.info("递归列举{}下的所有文件循环删除", bucketName);
                Iterable<Result<Item>> iterable = minioClient.listObjects(ListObjectsArgs.builder()
                        .bucket(bucketName)
                        .recursive(true)
                        .build());
                for (Result<Item> itemResult : iterable) {
                    removeObject(bucketName, itemResult.get().objectName());
                }
            } catch (Exception e) {
                log.error("递归列举{}下的所有文件循环删除异常", bucketName, e);
            }
        }
    }

    /**
     * 上传单个文件
     *
     * @param file 文件
     * @return 文件路径
     * @throws Exception 上传失败异常
     */
    public String put(MultipartFile file) {
        return put(null, file);
    }

    /**
     * 上传单个文件至默认桶
     *
     * @param path 指定路径
     * @param file 文件
     * @return 文件最终路径
     * @throws Exception 文件上传失败异常
     */
    public String put(String path, MultipartFile file) {
        beforePutCheck(file);
        String originalFilename = file.getOriginalFilename();
        assert originalFilename != null;
        String objectName = buildObjectName(originalFilename, path);
        try {
            minioClient.putObject(buildPutObjectArgs(objectName, file));
            return objectName;
        } catch (Exception e) {
            log.error("file[{}] upload failed", originalFilename, e);
            throw new MinioException(CsrErrorCode.FILE_UPLOAD_FAILED_ERROR);
        }
    }

    /**
     * 上传单个文件至指定桶
     *
     * @param path       指定路径
     * @param file       文件
     * @param bucketName 桶名称
     * @return 文件最终路径
     * @throws Exception 文件上传失败异常
     */
    public String put(String path, MultipartFile file, String bucketName) {
        beforePutCheck(file);
        String originalFilename = file.getOriginalFilename();
        assert originalFilename != null;
//        String objectName = buildObjectName(originalFilename, path);
        try {
            minioClient.putObject(buildPutObjectArgs(path, file, bucketName));
            return path;
        } catch (Exception e) {
            log.error("file[{}] upload failed", originalFilename, e);
            throw new MinioException(CsrErrorCode.FILE_UPLOAD_FAILED_ERROR);
        }
    }

    /**
     * put 前校验
     *
     * @param file 文件
     * @throws Exception put异常
     */
    void beforePutCheck(MultipartFile file) {
        String originalFilename = file.getOriginalFilename();
        if (!StringUtils.hasText(originalFilename)) {
            log.error("file {} cannot have an empty name", originalFilename);
            throw new MinioException(CsrErrorCode.FILE_NAME_NO_NULL_ERROR);
        }
    }

    /**
     * 获取文件分隔符
     *
     * @return 文件分隔符
     */
    String getFileSeparator() {
        if (minioServerType.equals("WIN")) {
            return MinioConstant.FILE_SEPARATOR_WIN;
        } else if (minioServerType.equals("UNIX")) {
            return MinioConstant.FILE_SEPARATOR_UNIX;
        }
        return File.separator;
    }

    /**
     * 构建ObjectName
     *
     * @param originalFilename 文件原始名称
     * @param path             文件路径
     * @return ObjectName
     */
    String buildObjectName(String originalFilename, String path) {
        String filename = UUIDUtils.uuid() + MinioConstant.DEFAULT_FILENAME_SEPARATOR + originalFilename;

        String objectName = DateUtils.getNowDateStr(DateUtils.PATTERN_YYYYMMDD) + getFileSeparator() + filename;
        if (path != null) {
//           TODO 手写路径换 File.separator
            objectName = path + "/" + filename;
        }
        return objectName;
    }

    /**
     * 构建 PutObjectArgs
     *
     * @param objectName put对象名称
     * @param file       文件对象
     * @return PutObjectArgs
     * @throws Exception 输入输出流异常
     */
    PutObjectArgs buildPutObjectArgs(String objectName, MultipartFile file) throws Exception {
        return PutObjectArgs.builder()
                .bucket(bucketClient.getDefaultBucket())
                .object(objectName)
                .stream(file.getInputStream(), file.getSize(), -1)
                .contentType(file.getContentType())
                .build();
    }

    /**
     * 构建 PutObjectArgs
     *
     * @param objectName put对象名称
     * @param file       文件对象
     * @param bucketName 连接桶名称
     * @return PutObjectArgs
     * @throws Exception 输入输出流异常
     */
    PutObjectArgs buildPutObjectArgs(String objectName, MultipartFile file, String bucketName) throws Exception {
        return PutObjectArgs.builder()
                .bucket((Strings.isBlank(bucketName)) ? bucketClient.getDefaultBucket() : bucketName)
                .object(objectName)
                .stream(file.getInputStream(), file.getSize(), -1)
                .contentType(file.getContentType())
                .build();
    }

    /**
     * 判断默认存储桶文件是否存在
     *
     * @param objectName 文件名称, 如果要带文件夹请用 / 分割, 例如 /help/index.html
     * @return true存在, 反之
     */
    public Boolean checkFileIsExist(String objectName) {
        return this.checkFileIsExist(bucketClient.getDefaultBucket(), objectName);
    }

    /**
     * 判断默认存储桶文件夹是否存在
     *
     * @param folderName 文件夹名称
     * @return true存在, 反之
     */
    public Boolean checkFolderIsExist(String folderName) {
        return this.checkFolderIsExist(bucketClient.getDefaultBucket(), folderName);
    }

    /**
     * 判断文件是否存在
     *
     * @param bucketName 桶名称
     * @param objectName 文件名称, 如果要带文件夹请用 / 分割, 例如 /help/index.html
     * @return true存在, 反之
     */
    public Boolean checkFileIsExist(String bucketName, String objectName) {
        try {
            minioClient.statObject(
                    StatObjectArgs.builder().bucket(bucketName).object(objectName).build()
            );
        } catch (Exception e) {
            return false;
        }
        return true;
    }

    /**
     * 判断文件夹是否存在
     *
     * @param bucketName 桶名称
     * @param folderName 文件夹名称
     * @return true存在, 反之
     */
    public Boolean checkFolderIsExist(String bucketName, String folderName) {
        try {
            Iterable<Result<Item>> results = minioClient.listObjects(
                    ListObjectsArgs
                            .builder()
                            .bucket(bucketName)
                            .prefix(folderName)
                            .recursive(false)
                            .build());
            for (Result<Item> result : results) {
                Item item = result.get();
                if (item.isDir() && folderName.equals(item.objectName())) {
                    return true;
                }
            }
        } catch (Exception e) {
            return false;
        }
        return true;
    }

}

存储桶管理客户端

package kl.gayxzc.csr.cilent;

import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.RemoveBucketArgs;
import io.minio.SetBucketPolicyArgs;
import io.minio.messages.Bucket;
import kl.gayxzc.csr.constant.CsrErrorCode;
import kl.gayxzc.csr.constant.MinioConstant;
import kl.gayxzc.csr.exception.MinioException;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.List;

/**
 * minio-client
 * 存储桶管理客户端
 *
 * @author ldh
 */
@Slf4j
@Component("bucketClient")
public class BucketClient {

    private MinioClient minioClient;

    private String defaultBucket;

    @Value("${csr.minio.bucket-name}")
    private String minioName;


    @Autowired
    public void setMinioClient(MinioClient minioClient) {
        this.minioClient = minioClient;
    }

    /**
     * 校验桶是否存在
     *
     * @param bucketName 桶名称
     * @return
     */
    public Boolean bucketExists(String bucketName) {
        try {
            return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        } catch (Exception e) {
            log.error("bucket {} cannot found", bucketName, e);
            throw new MinioException(CsrErrorCode.CHECK_ERROR);
        }
    }

    /**
     * 获取默认存储桶
     *
     * @return 默认存储桶
     * @throws Exception 获取失败
     */
    public String getDefaultBucket() {
        if (!StringUtils.hasText(defaultBucket)) {
            defaultBucket = minioName;
            if (!StringUtils.hasText(defaultBucket)) {
                log.error("default bucket cannot be empty");
                throw new MinioException(CsrErrorCode.MINIO_DEFAULT_ERROR);
            }
        }
        return defaultBucket;
    }

    /**
     * 设置默认存储桶
     *
     * @param name 存储桶名称
     * @throws Exception 设置失败异常
     */
    public void setDefaultBucket(String name) {
        if (bucketExists(name)) {
            this.defaultBucket = name;
            return;
        }
        this.defaultBucket = minioName;
        createBucket(this.defaultBucket);
        if (!StringUtils.hasText(defaultBucket)) {
            throw new MinioException(CsrErrorCode.MINIO_DEFAULT_ERROR);
        }
    }


    /**
     * 创建存储桶
     *
     * @param bucketName 存储桶名称
     * @throws Exception 存储桶异常
     */
    public void createBucket(String bucketName) {
        if (!bucketExists(bucketName)) {
            try {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            } catch (Exception e) {
                log.error("存储桶异常", e);
                throw new MinioException(CsrErrorCode.BUCKET_ERROR);
            }
        }
        log.warn("bucket {} has created", bucketName);
    }

    /**
     * 创建存储桶并赋予策略
     *
     * @param bucketName 存储桶名称
     * @param policy     权限
     * @throws Exception 存储桶异常
     */
    public void createBucket(String bucketName, String policy) {
        if (!bucketExists(bucketName)) {
            try {
//                创建桶
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
                if (Strings.isBlank(policy)) {
                    policy = MinioConstant.READ_WRITE;
                }
                if (Strings.isNotBlank(policy)) {
                    String strategy = this.consolidationStrategy(bucketName, policy);
                    minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().bucket(bucketName).config(strategy).build());
                }
            } catch (Exception e) {
                log.error("存储桶异常", e);
                throw new MinioException(CsrErrorCode.BUCKET_ERROR);
            }
        }
        log.warn("bucket {} has created", bucketName);
    }

    /**
     * 拼接Minio对应桶策略
     *
     * @param policy 权限code
     * @return json 策略结构体
     */
    private String consolidationStrategy(String bucketName, String policy) {
        String jsonStrategy = Strings.EMPTY;
        if (MinioConstant.READ_ONLY.equals(policy)) {
            jsonStrategy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},\"Action\":[\"s3:GetBucketLocation\",\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},\"Action\":[\"s3:GetObject\"],\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}]}";
        } else if (MinioConstant.WRITE_ONLY.equals(policy)) {
            jsonStrategy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},\"Action\":[\"s3:GetBucketLocation\",\"s3:ListBucketMultipartUploads\"],\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},\"Action\":[\"s3:AbortMultipartUpload\",\"s3:DeleteObject\",\"s3:ListMultipartUploadParts\",\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}]}";
        } else if (MinioConstant.READ_WRITE.equals(policy)) {
            jsonStrategy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},\"Action\":[\"s3:GetBucketLocation\",\"s3:ListBucket\",\"s3:ListBucketMultipartUploads\"],\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},\"Action\":[\"s3:DeleteObject\",\"s3:GetObject\",\"s3:ListMultipartUploadParts\",\"s3:PutObject\",\"s3:AbortMultipartUpload\"],\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}]}";
        } else {
            try {
                this.removeBucket(bucketName);
                log.info("桶策略枚举值不合法,桶{}已删除", bucketName);
                throw new MinioException(CsrErrorCode.POLICY_TYPE_ENUM_NOT_NULL);
            } catch (Exception e) {
                log.error("桶策略枚举值不合法", e);
            }
        }
        return jsonStrategy;
    }

    /**
     * 获取全部bucket
     *
     * @return 全部bucket信息
     * @throws Exception 获取全部bucket信息失败异常
     */
    public List<Bucket> getAllBuckets() {
        try {
            return minioClient.listBuckets();
        } catch (Exception e) {
            log.error("获取全部bucket信息失败异常", e);
            throw new MinioException(CsrErrorCode.FAILED_ALL_INFORMATION_ERROR);
        }
    }

    /**
     * 根据bucketName删除信息
     *
     * @param bucketName bucket名称
     * @throws Exception 删除bucket信息失败异常
     */
    public void removeBucket(String bucketName) {
        try {
            minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
        } catch (Exception e) {
            log.error("删除bucket信息失败异常", e);
            throw new MinioException(CsrErrorCode.REMOVE_BUCKET_ERROR);
        }

    }
}

常量定义

package kl.gayxzc.csr.constant;

/**
 * MINIO_常量定义
 */
public class MinioConstant {
    /**
     * 文件名称分隔符
     */
    public static final String DEFAULT_FILENAME_SEPARATOR = "@";

    /**
     * win
     */
    public static final String FILE_SEPARATOR_WIN = "\\";

    /**
     * unix
     */
    public static final String FILE_SEPARATOR_UNIX = "/";

    /**
     * bucket权限-只读 READ_ONLY
     */
    public static final String READ_ONLY = "READ_ONLY";
    /**
     * bucket权限-只写 WRITE_ONLY
     */
    public static final String WRITE_ONLY = "WRITE_ONLY";
    /**
     * bucket权限-读写 READ_WRITE
     */
    public static final String READ_WRITE = "READ_WRITE";

}

工具类定义

package kl.gayxzc.csr.utils;

import java.util.UUID;

/**
 * UUID工具类
 *
 * @author ldh
 */
public class UUIDUtils {
    /**
     * 随机获取uuid对象
     *
     * @return uuid对象
     */
    public static UUID randomUuid() {
        return UUID.randomUUID();
    }

    /**
     * 获取原生uuid字符串
     *
     * @return 原生uuid字符串
     */
    public static String orgUuid() {
        return randomUuid().toString();
    }

    /**
     * 获取 uuid
     *
     * @return uuid字符串
     */
    public static String uuid() {
        return orgUuid().replace("-", "");
    }
}

package kl.gayxzc.csr.utils;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

/**
 * 日期工具类
 *
 * @author ldh
 */
public class DateUtils {

    public static final String PATTERN_YYYYMMDD = "yyyyMMdd";

    public static final DateTimeFormatter DTF_PATTERN_YYYYMMDD = DateTimeFormatter.ofPattern(PATTERN_YYYYMMDD);

    public static final String PATTERN_YYYY_MM_DD = "yyyy-MM-dd";

    public static final DateTimeFormatter DTF_PATTERN_YYYY_MM_DD = DateTimeFormatter.ofPattern(PATTERN_YYYY_MM_DD);

    /**
     * /**
     * 获取当前日期
     *
     * @return 当前日期 <java.time.LocalDate>
     */
    public static LocalDate now() {
        return LocalDate.now();
    }

    /**
     * 获取当前日期字符串
     *
     * @return 时间
     */
    public static String getNowDateStr() {
        return now().format(DTF_PATTERN_YYYY_MM_DD);
    }

    /**
     * 获取当前日期字符串转Long
     *
     * @return 时间
     */
    public static Long getNowDateNumber() {
        return Long.valueOf(getNowDateStr());
    }

    /**
     * 获取当前日期字符串
     *
     * @param pattern 格式化
     * @return 时间
     */
    public static String getNowDateStr(String pattern) {
        return now().format(DateTimeFormatter.ofPattern(pattern));
    }
}

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值