使用WebUploader实现图片分片上传

1、添加js和CSS文件

<link rel="stylesheet" type="text/css" href="/resources/css/WebUploader/webuploader.css" />
<script type="text/javascript" src="/resources/js/jquery-1.7.min.js"></script>
<script type="text/javascript" src="/resources/js/WebUploader/webuploader.js"></script>
<script type="text/javascript" src="/resources/js/WebUploader/md5.js"></script>
<%
String path = request.getContextPath();
String basePath = request.getScheme() + "://"
        + request.getServerName() + ":" + request.getServerPort()
        + path + "/";
%>
<style type="text/css">
            .itemDel, .itemStop, .itemUpload{
                margin-left: 15px;
                color: blue;
                cursor: pointer;
            }
            #theList{
                width: 80%;
                min-height: 100px;
                border: 1px solid red;
            }
            #theList .itemStop{
                display: none;
            }
        </style>

2、html代码

<div id="uploader">
    <ul id="theList"></ul>
    <div id="picker">选择文件</div>
</div>
3、js代码
<script type="text/javascript">
    var userInfo = {userId:"kazaff", md5:""};   //用户会话信息
    var chunkSize = 5 *1024 * 1024;             //分块大小
    var uniqueFileName = null;                  //文件唯一标识符
    var md5Mark = null;

    function getServer(type){                   //测试用,根据不同类型的后端返回对应的请求地址
        return "<%=basePath%>fileUpload";
    }

    var backEndUrl = getServer("java");

        WebUploader.Uploader.register({
            "before-send-file": "beforeSendFile"
          , "before-send": "beforeSend"
          , "after-send-file": "afterSendFile"
        }, {
            beforeSendFile: function(file){
               //秒传验证
               var task = new $.Deferred();
               var start = new Date().getTime();
               (new WebUploader.Uploader()).md5File(file, 0, 10*1024*1024).progress(function(percentage){
                    console.log(percentage);
               }).then(function(val){
                    console.log("总耗时: "+((new Date().getTime()) - start)/1000);
                    md5Mark = val;
                    userInfo.md5 = val;
                    $.ajax({
                         type: "POST"
                       , url: backEndUrl
                       , data: {
                            status: "md5Check"
                          , md5: val
                        }
                       , cache: false
                       , timeout: 1000 //todo 超时的话,只能认为该文件不曾上传过
                       , dataType: "json"
               }).then(function(data, textStatus, jqXHR){
                       //console.log(data);

                       if(data.ifExist){   //若存在,这返回失败给WebUploader,表明该文件不需要上传
                           task.reject();

                           uploader.skipFile(file);
                           file.path = data.path;
                           UploadComlate(file);
                       }else{
                           task.resolve();
                           //拿到上传文件的唯一名称,用于断点续传
                           uniqueFileName = md5(''+userInfo.userId+file.name+file.type+file.lastModifiedDate+file.size);
                       }
                  }, function(jqXHR, textStatus, errorThrown){    //任何形式的验证失败,都触发重新上传
                       task.resolve();
                       //拿到上传文件的唯一名称,用于断点续传
                       uniqueFileName = md5(''+userInfo.userId+file.name+file.type+file.lastModifiedDate+file.size);
                 });
             });
                 return $.when(task);
             }
                , beforeSend: function(block){
                    //分片验证是否已传过,用于断点续传
                    var task = new $.Deferred();
                    $.ajax({
                        type: "POST"
                        , url: backEndUrl
                        , data: {
                            status: "chunkCheck"
                            , name: uniqueFileName
                            , chunkIndex: block.chunk
                            , size: block.end - block.start
                        }
                        , cache: false
                        , timeout: 1000 //todo 超时的话,只能认为该分片未上传过
                        , dataType: "json"
                    }).then(function(data, textStatus, jqXHR){
                        if(data.ifExist){   //若存在,返回失败给WebUploader,表明该分块不需要上传
                            task.reject();
                        }else{
                            task.resolve();
                        }
                    }, function(jqXHR, textStatus, errorThrown){    //任何形式的验证失败,都触发重新上传
                        task.resolve();
                    });

                    return $.when(task);
                }
                , afterSendFile: function(file){
                    var chunksTotal = 0;
                    if((chunksTotal = Math.ceil(file.size/chunkSize)) > 1){
                        //合并请求
                        var task = new $.Deferred();
                        $.ajax({
                            type: "POST"
                            , url: backEndUrl
                            , data: {
                                status: "chunksMerge"
                                , name: uniqueFileName
                                , chunks: chunksTotal
                                , ext: file.ext
                                , md5: md5Mark
                            }
                            , cache: false
                            , dataType: "json"
                        }).then(function(data, textStatus, jqXHR){

                            //todo 检查响应是否正常

                            task.resolve();
                            file.path = data.path;
                            UploadComlate(file);

                        }, function(jqXHR, textStatus, errorThrown){
                            task.reject();
                        });

                        return $.when(task);
                    }else{
                        UploadComlate(file);
                    }
                }
            });

	    var uploader = WebUploader.create({
	        auto:true                                                 //允许自动上传
              , swf: "/js/WebUploaderUploader.swf"               //引用swf文件
              , server: backEndUrl                                        //后台响应服务器路径
              , pick:{
                 id:"##picker"                                            //点击触发文件上传的按钮id
               , multiple:false                                           //是否可选择多个文件(默认为true)
              } 
              , resize: true                                              //不压缩文件                                        
              , paste: document.body                                      //指定监听paste事件的容器
              , disableGlobalDnd: true                                    //是否禁止拖拽
              , thumb: {                                                  //配置生成缩略图的选项
                 width: 200
               , height: 200
               , quality: 70
               , allowMagnify: true
               , crop: true
               //, type: "image/jpeg"
             }
             , compress: {                                                //配置上传前压缩的图片的选项
                quality: 100                                              //图片压缩后比例(只允许jpeg格式)
              , allowMagnify: false                                       //是否允许放大(设为false,保证不失真)
              , crop: false                                               //是否允许裁剪
              , preserveHeaders: true                                     //是否保留头部meta信息
              , noCompressIfLarger: true                                  //如果压缩后图片比原图要大,是否选择原图
              ,compressSize: 1024                                         //小于此值不进行压缩(单位:字节)
             }
             , accept: {
              title: 'Images',
              extensions: 'gif,jpg,jpeg,bmp,png',
              mimeTypes: 'image/jpg,image/jpeg,image/png,image/bmp'
             }
             , duplicate: true                                            //支持重复上传
             , prepareNextFile: true                                      //是否允许在文件传输时提前把下一个文件准备好
             , chunked: true                                              //是否要分片处理
             , chunkSize: chunkSize                                       //分片数量,默认为2
             , threads: threads                                           //上传并发数,默认为3
             , formData: function(){return $.extend(true, {}, userInfo);} //文件上传请求的参数表
             , fileNumLimit: 10                                           //验证文件总数量, 超出则不允许加入队列
             , fileSingleSizeLimit: 1000 * 1024 * 1024                    //验证文件总大小是否超出限制, 超出则不允许加入队列
	    });

	    uploader.on("fileQueued", function(file){
				
		$("#theList").append(
                        '<li id="'+file.id+'">' +
			'<img /><span>'+file.name+'</span><span class="itemUpload">上传</span><span class="itemStop">暂停</span><span class="itemDel">删除</span>' +
			'<div class="percentage"></div>' +
			'</li>');
				
			var $img = $("#" + file.id).find("img");
				
			uploader.makeThumb(file, function(error, src){
			    if(error){
				$img.replaceWith("<span>不能预览</span>");
			    }

			    $img.attr("src", src);
			});
				
		});
			
		$("#theList").on("click", ".itemUpload", function(){
		    uploader.upload();

                    //"上传"-->"暂停"
                    $(this).hide();
                    $(".itemStop").show();
		});

                $("#theList").on("click", ".itemStop", function(){
                    uploader.stop(true);

                    //"暂停"-->"上传"
                    $(this).hide();
                    $(".itemUpload").show();
                });

            //todo 如果要删除的文件正在上传(包括暂停),则需要发送给后端一个请求用来清除服务器端的缓存文件
		$("#theList").on("click", ".itemDel", function(){
			uploader.removeFile($(this).parent().attr("id"));	//从上传文件列表中删除

			$(this).parent().remove();	//从上传列表dom中删除
		});
			
		uploader.on("uploadProgress", function(file, percentage){
			$("#" + file.id + " .percentage").text(percentage * 100 + "%");
		});

            function UploadComlate(file){
                console.log(file);
                $("#" + file.id + " .percentage").text("上传完毕");
                $(".itemStop").hide();
                $(".itemUpload").hide();
                $(".itemDel").hide();
            }
		</script>

4、后台代码

(1)、bean层

package com.bean;
public class FileInfo {
    private String md5;
    private int chunkIndex;
    private String size;
    private String name;
    private String userId;
    private String id;
    private int chunks;
    private int chunk;
    private String lastModifiedDate;
    private String type;
    private String ext;
    public FileInfo(){}

    public FileInfo(String name, String size, int chunkIndex){
        this.name = name;
        this.size = size;
        this.chunkIndex = chunkIndex;
    }

    public FileInfo(String userId, String id){
        this.userId = userId;
        this.id = id;
    }

    public FileInfo(String md5){
        this.md5 = md5;
    }

    public FileInfo(int chunks, int chunk, String userId, String id, String name, String size, String lastModifiedDate, String type){
        this.userId = userId;
        this.id = id;
        this.name = name;
        this.size = size;
        this.chunks = chunks;
        this.chunk = chunk;
        this.lastModifiedDate = lastModifiedDate;
        this.type = type;
    }

    public FileInfo(String name, int chunks, String ext, String md5){
        this.name = name;
        this.chunks = chunks;
        this.ext = ext;
        this.md5 = md5;
    }

    public String getType() {
        return type;
    }

    public void setType(String type) {
        this.type = type;
    }

    public String getLastModifiedDate() {
        return lastModifiedDate;
    }

    public void setLastModifiedDate(String lastModifiedDate) {
        this.lastModifiedDate = lastModifiedDate;
    }

    public int getChunks() {
        return chunks;
    }

    public void setChunks(int chunks) {
        this.chunks = chunks;
    }

    public int getChunk() {
        return chunk;
    }

    public void setChunk(int chunk) {
        this.chunk = chunk;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getMd5() {
        return md5;
    }

    public void setMd5(String md5) {
        this.md5 = md5;
    }

    public int getChunkIndex() {
        return chunkIndex;
    }

    public void setChunkIndex(int chunkIndex) {
        this.chunkIndex = chunkIndex;
    }

    public String getSize() {
        return size;
    }

    public void setSize(String size) {
        this.size = size;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getExt() {
        return ext;
    }

    public void setExt(String ext) {
        this.ext = ext;
    }
}

(2)、Controller层

package com.controller;
import com.bean.FileInfo;
import com.service.webUploader;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletResponse;
import java.io.*;

@Controller
@RequestMapping("/")
public class FileUploadController {

	private final static Logger log = LoggerFactory.getLogger(FileUploadController.class);

	@Autowired
	private webUploader wu;

	@RequestMapping(method = RequestMethod.GET)
	public String printWelcome(ModelMap model) {
		model.addAttribute("message", "Hello world!");
		return "hello";
	}

	//大文件上传
	@RequestMapping(value = "fileUpload", method = RequestMethod.POST)
	@ResponseBody
	public String fileUpload(String status, FileInfo info, @RequestParam(value = "file", required = false) MultipartFile file){
            String uploadFolder="D:\tupian\";
        	if(status == null){	//文件上传
			if(file != null && !file.isEmpty()){	//验证请求不会包含数据上传,所以避免NullPoint这里要检查一下file变量是否为null
				try {
					File target = wu.getReadySpace(info, this.uploadFolder);	//为上传的文件准备好对应的位置
					if(target == null){
						return "{\"status\": 0, \"message\": \"" + wu.getErrorMsg() + "\"}";
					}

					file.transferTo(target);	//保存上传文件

					//将MD5签名和合并后的文件path存入持久层,注意这里这个需求导致需要修改webuploader.js源码3170行
					//因为原始webuploader.js不支持为formData设置函数类型参数,这将导致不能在控件初始化后修改该参数
					if(info.getChunks() <= 0){
						if(!wu.saveMd52FileMap(info.getMd5(), target.getName())){
							log.error("文件[" + info.getMd5() + "=>" + target.getName() + "]保存关系到持久成失败,但并不影响文件上传,只会导致日后该文件可能被重复上传而已");
						}
					}

					return "{\"status\": 1, \"path\": \"" + target.getName() + "\"}";

				}catch(IOException ex){
					log.error("数据上传失败", ex);
					return "{\"status\": 0, \"message\": \"数据上传失败\"}";
				}
			}
		}else{
			if(status.equals("md5Check")){	//秒传验证

				String path = wu.md5Check(info.getMd5());

				if(path == null){
					return "{\"ifExist\": 0}";
				}else{
					return "{\"ifExist\": 1, \"path\": \"" + path + "\"}";
				}

			}else if(status.equals("chunkCheck")){	//分块验证

				//检查目标分片是否存在且完整
				if(wu.chunkCheck(this.uploadFolder + "/" + info.getName() + "/" + info.getChunkIndex(), Long.valueOf(info.getSize()))){
					return "{\"ifExist\": 1}";
				}else{
					return "{\"ifExist\": 0}";
				}

			}else if(status.equals("chunksMerge")){	//分块合并

				String path = wu.chunksMerge(info.getName(), info.getExt(), info.getChunks(), info.getMd5(), this.uploadFolder);
				if(path == null){
					return "{\"status\": 0, \"message\": \"" + wu.getErrorMsg() + "\"}";
				}

				return "{\"status\": 1, \"path\": \"" + path + "\", \"message\": \"中文测试\"}";
			}
		}

		log.error("请求参数不完整");
		return "{\"status\": 0, \"message\": \"请求参数不完整\"}";
	}
}
(3)、Service层
package com.service;
import com.bean.FileInfo;
import com.util.fileLock;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;

import java.io.*;
import java.nio.channels.FileChannel;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.concurrent.locks.Lock;

/**
 * Created by kazaff on 2014/12/2.
 */

@Service
@Scope("prototype")
public class webUploader {

    private final static Logger log = LoggerFactory.getLogger(webUploader.class);

    /**
     * 错误详情
     */
    private String msg;

    /**
     * 秒传验证
     * 根据文件的MD5签名判断该文件是否已经存在
     *
     * @param key   文件的md5签名
     * @return  若存在则返回该文件的路径,不存在则返回null
     */
    public String md5Check(String key){

        //todo 模拟去数据库查找
        Map<String, String> data = new HashMap<String, String>();
        data.put("b0201e4d41b2eeefc7d3d355a44c6f5a", "kazaff2.jpg");

        if(data.containsKey(key)){
           return data.get(key);
        }else{
            return null;
        }
    }

    /**
     * 分片验证
     * 验证对应分片文件是否存在,大小是否吻合
     * @param file  分片文件的路径
     * @param size  分片文件的大小
     * @return
     */
    public boolean chunkCheck(String file, Long size){
        //检查目标分片是否存在且完整
        File target = new File(file);
        if(target.isFile() && size == target.length()){
            return true;
        }else{
            return false;
        }
    }

    /**
     * 分片合并操作
     * 要点:
     *  > 合并: NIO
     *  > 并发锁: 避免多线程同时触发合并操作
     *  > 清理: 合并清理不再需要的分片文件、文件夹、tmp文件
     * @param folder    分片文件所在的文件夹名称
     * @param ext       合并后的文件后缀名
     * @param chunks    分片总数
     * @param md5       文件签名
     * @param path      合并后的文件所存储的位置
     * @return
     */
    public String chunksMerge(String folder, String ext, int chunks, String md5, String path){

        //合并后的目标文件
        String target;

        //检查是否满足合并条件:分片数量是否足够
        if(chunks == this.getChunksNum(path + "/" + folder)){

            //同步指定合并的对象
            Lock lock = fileLock.getLock(folder);
            lock.lock();
            try{
                //检查是否满足合并条件:分片数量是否足够
                //File[] files = this.getChunks(path + "/" +folder);
                List<File> files = new ArrayList<File>(Arrays.asList(this.getChunks(path + "/" +folder)));
                if(chunks == files.size()){

                    //按照名称排序文件,这里分片都是按照数字命名的
                    Collections.sort(files, new Comparator<File>() {
                        @Override
                        public int compare(File o1, File o2) {
                            if(Integer.valueOf(o1.getName()) < Integer.valueOf(o2.getName())){
                                return -1;
                            }
                            return 1;
                        }
                    });

                    //创建合并后的文件
                    File outputFile = new File(path + "/" + this.randomFileName(ext));
                    if(outputFile.exists()){
                        log.error("文件[" + folder + "]随机命名冲突");
                        this.setErrorMsg("文件随机命名冲突");
                        return null;
                    }
                    outputFile.createNewFile();
                    FileChannel outChannel = new FileOutputStream(outputFile).getChannel();

                    //合并
                    FileChannel inChannel;
                    for(File file : files){
                        inChannel = new FileInputStream(file).getChannel();
                        inChannel.transferTo(0, inChannel.size(), outChannel);
                        inChannel.close();

                        //删除分片
                        if(!file.delete()){
                            log.error("分片[" + folder + "=>" + file.getName() + "]删除失败");
                        }
                    }
                    outChannel.close();
                    files = null;

                    //将MD5签名和合并后的文件path存入持久层
                    if(this.saveMd52FileMap(md5, outputFile.getName())){
                        log.error("文件[" + md5 + "=>" + outputFile.getName() + "]保存关系到持久成失败,但并不影响文件上传,只会导致日后该文件可能被重复上传而已");
                    }

                    //清理:文件夹,tmp文件
                    this.cleanSpace(folder, path);

                    return  outputFile.getName();
                }
            }catch(Exception ex){
                log.error("数据分片合并失败", ex);
                this.setErrorMsg("数据分片合并失败");
                return null;

            }finally {
                //解锁
                lock.unlock();
                //清理锁对象
                fileLock.removeLock(folder);
            }
        }

        //去持久层查找对应md5签名,直接返回对应path
        target = this.md5Check(md5);
        if(target == null){
            log.error("文件[签名:" + md5 + "]数据不完整,可能该文件正在合并中");
            this.setErrorMsg("数据不完整,可能该文件正在合并中");
            return null;
        }

        return target;
    }

    /**
     * 将MD5签名和目标文件path的映射关系存入持久层
     * @param key   md5签名
     * @param file  文件路径
     * @return
     */
    public boolean saveMd52FileMap(String key, String file){
        //todo
        return true;
    }

    /**
     * 为上传的文件创建对应的保存位置
     * 若上传的是分片,则会创建对应的文件夹结构和tmp文件
     * @param info  上传文件的相关信息
     * @param path  文件保存根路径
     * @return
     */
    public File getReadySpace(FileInfo info, String path){

        //创建上传文件所需的文件夹
        if(!this.createFileFolder(path, false)){
            return null;
        }

        String newFileName;	//上传文件的新名称

        //如果是分片上传,则需要为分片创建文件夹
        if (info.getChunks() > 0) {
            newFileName = String.valueOf(info.getChunk());

            String fileFolder = this.md5(info.getUserId() + info.getName() + info.getType() + info.getLastModifiedDate() + info.getSize());
            if(fileFolder == null){
                return null;
            }

            path += "/" + fileFolder;    //文件上传路径更新为指定文件信息签名后的临时文件夹,用于后期合并

            if(!this.createFileFolder(path, true)){
                return null;
            }

        } else {
            //生成随机文件名
            newFileName = this.randomFileName(info.getName());
        }

        return new File(path, newFileName);
    }

    /**
     * 清理分片上传的相关数据
     * 文件夹,tmp文件
     * @param folder    文件夹名称
     * @param path      上传文件根路径
     * @return
     */
    private boolean cleanSpace(String folder, String path){
        //删除分片文件夹
        File garbage = new File(path + "/" + folder);
        if(!garbage.delete()){
            return false;
        }

        //删除tmp文件
        garbage = new File(path + "/" + folder + ".tmp");
        if(!garbage.delete()){
            return false;
        }

        return true;
    }

    /**
     * 获取指定文件的所有分片
     * @param folder    文件夹路径
     * @return
     */
    private File[] getChunks(String folder){
        File targetFolder = new File(folder);
        return targetFolder.listFiles(new FileFilter() {
            @Override
            public boolean accept(File file) {
                if (file.isDirectory()) {
                    return false;
                }
                return true;
            }
        });
    }

    /**
     * 获取指定文件的分片数量
     * @param folder    文件夹路径
     * @return
     */
    private int getChunksNum(String folder){

       File[] filesList = this.getChunks(folder);
        return filesList.length;
    }

    /**
     * 创建存放上传的文件的文件夹
     * @param file  文件夹路径
     * @return
     */
    private boolean createFileFolder(String file, boolean hasTmp){

        //创建存放分片文件的临时文件夹
        File tmpFile = new File(file);
        if(!tmpFile.exists()){
            try {
                tmpFile.mkdir();
            }catch(SecurityException ex){
                log.error("无法创建文件夹", ex);
                this.setErrorMsg("无法创建文件夹");
                return false;
            }
        }

        if(hasTmp){
            //创建一个对应的文件,用来记录上传分片文件的修改时间,用于清理长期未完成的垃圾分片
            tmpFile = new File(file + ".tmp");
            if(tmpFile.exists()){
                tmpFile.setLastModified(System.currentTimeMillis());
            }else{
                try{
                    tmpFile.createNewFile();
                }catch(IOException ex){
                    log.error("无法创建tmp文件", ex);
                    this.setErrorMsg("无法创建tmp文件");
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * 为上传的文件生成随机名称
     * @param originalName  文件的原始名称,主要用来获取文件的后缀名
     * @return
     */
    private String randomFileName(String originalName){
        String ext[] = originalName.split("\\.");
        return UUID.randomUUID().toString() + "." + ext[ext.length-1];
    }

    /**
     * MD5签名
     * @param content   要签名的内容
     * @return
     */
    private String md5(String content){
        StringBuffer sb = new StringBuffer();
        try{
            MessageDigest md5 = MessageDigest.getInstance("MD5");
            md5.update(content.getBytes("UTF-8"));
            byte[] tmpFolder = md5.digest();

            for(int i = 0; i < tmpFolder.length; i++){
                sb.append(Integer.toString((tmpFolder[i] & 0xff) + 0x100, 16).substring(1));
            }

            return sb.toString();
        }catch(NoSuchAlgorithmException ex){
            log.error("无法生成文件的MD5签名", ex);
            this.setErrorMsg("无法生成文件的MD5签名");
            return null;
        }catch(UnsupportedEncodingException ex){
            log.error("无法生成文件的MD5签名", ex);
            this.setErrorMsg("无法生成文件的MD5签名");
            return null;
        }
    }

    /**
     * 记录异常错误信息
     * @param msg   错误详细
     */
    private void setErrorMsg(String msg){
        this.msg = msg;
    }

    /**
     * 获取错误详细
     * @return
     */
    public String getErrorMsg(){
        return this.msg;
    }
}
(4)、util层
package com.util;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Component
public class fileLock {

    private static Map<String, Lock> LOCKS = new HashMap<String, Lock>();

    public static synchronized Lock getLock(String key){
        if(LOCKS.containsKey(key)){
            return LOCKS.get(key);
        }else{
            Lock one = new ReentrantLock();
            LOCKS.put(key, one);
            return one;
        }
    }

    public static synchronized void removeLock(String key){
        LOCKS.remove(key);
    }
}




猜你喜欢

转载自blog.csdn.net/weixin_42187261/article/details/80435501
今日推荐