java 文件分片上传

a 夏天 提交于 2021-01-25 17:31:50

采用jpa方式

model类

ChunkInfo

package org.zz.platform.filemanagement.model;

import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

// 文件块model
@Data
public class ChunkInfo {

    private Integer chunkNumber;
    /**
     * 每块大小
     */
    private Long chunkSize;
    /**
     * 当前分块大小
     */
    private Long currentChunkSize;
    /**
     * 总大小
     */
    private Long totalSize;
    /**
     * 文件标识
     */
    private String identifier;
    /**
     * 文件名
     */
    private String filename;

    /**
     * 相对路径
     */
    private String relativePath;
    /**
     * 总块数
     */
    private Integer totalChunks;

    /**
     * 块内容 transient 表示upfile不是该对象序列化的一部分
     */
    private transient MultipartFile upfile;

}

FileInfo

package org.zz.platform.filemanagement.model;

import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.GenericGenerator;

import javax.persistence.*;
import java.util.Date;
// 文件类
@Table(name = "t_file_info")
@Entity
@Getter
@Setter
public class FileInfo {

    /**
     * 附件编号
     */
    @Id
    @GeneratedValue(generator = "idGenerator")
    @GenericGenerator(name = "idGenerator", strategy = "uuid")
    @Column(name = "id", unique = true, nullable = false)
    private String id;

    /**
     * 附件名称
     */
    @Column(name = "filename")
    private String filename;

    /**
     * 附件MD5标识
     */
    @Column(name = "identifier")
    private String identifier;

    /**
     * 附件总大小
     */
    @Column(name = "total_size")
    private Long totalSize;

    @Transient
    private String totalSizeName;

    /**
     * 附件类型
     */
    @Column(name = "type")
    private String type;

    /**
     * 附件存储地址
     */
    @Column(name = "location")
    private String location;

    /**
     * 上传人
     */
    @Column(name = "upload_by")
    private String uploadBy;

    /**
     * 上传时间
     */
    @Column(name = "upload_time")
    private Date uploadTime = new Date();
}

省略jpaservice层

控制层

package org.zz.platform.filemanagement.controller;

import com.querydsl.core.BooleanBuilder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.zz.platform.common.utils.CommonUtils;
import org.zz.platform.common.utils.ResponseResult;
import org.zz.platform.filemanagement.model.ChunkInfo;
import org.zz.platform.filemanagement.model.FileInfo;
import org.zz.platform.filemanagement.model.QFileInfo;
import org.zz.platform.filemanagement.model.UploadResult;
import org.zz.platform.filemanagement.service.FileInfoService;
import org.zz.platform.filemanagement.utils.FileInfoUtils;
import org.zz.platform.filemanagement.vo.FileInfoVo;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;

/**
 * @Classname UpLoaderController
 * @Description:
 * @Author: WangHaodong
 * @Date 2021/1/18 11:05
 */
@RestController
@RequestMapping("/rest/uploader")
@Slf4j
public class UpLoaderController {

    @Value("${file.upload.path}")
    private String uploadFolder;

    private final FileInfoService fileInfoService;
    private final RedisTemplate<String, Object> redisTemplate;

    public UpLoaderController(FileInfoService fileInfoService,
                              RedisTemplate<String, Object> redisTemplate) {
        this.fileInfoService = fileInfoService;
        this.redisTemplate = redisTemplate;
    }

    /**
     * 上传文件块
     *
     * @param chunk 文件块
     * @Author: WangHaodong
     * @Date 2021/1/19 14:27
     * @return: org.springframework.http.ResponseEntity<?>
     **/
    @PostMapping("/chunk")
    public ResponseEntity<?> uploadChunk(ChunkInfo chunk) {
        String key = "chunk_info:" + chunk.getIdentifier();
        MultipartFile file = chunk.getUpfile();
        log.info("file originName: {}, chunkNumber: {}", file.getOriginalFilename(), chunk.getChunkNumber());
        try {
            byte[] bytes = file.getBytes();
            Path path = Paths.get(FileInfoUtils.generatePath(uploadFolder + "/file/", chunk));
            //文件写入指定路径
            Files.write(path, bytes);
            if (redisTemplate.hasKey(key)) {
                redisTemplate.opsForValue().append(key, "," + chunk.getChunkNumber());
            } else {
                redisTemplate.opsForValue().set(key, chunk.getChunkNumber().toString());
            }
        } catch (IOException e) {
            e.printStackTrace();
            return ResponseResult.fail("上传失败");
        }
        return ResponseResult.success("成功");
    }

    @GetMapping("/chunk")
    public UploadResult checkChunk(ChunkInfo chunk, HttpServletResponse response) {
        UploadResult ur = new UploadResult();
        //默认返回其他状态码,前端不进去checkChunkUploadedByResponse函数,正常走标准上传
        response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
        String file = uploadFolder + "/file/" + chunk.getIdentifier() + "/" + chunk.getFilename();
        FileInfo byIdentifier = fileInfoService.getRepository().findByIdentifier(chunk.getIdentifier());
        String key = "chunk_info:" + chunk.getIdentifier();
        //先判断整个文件是否已经上传过了,如果是,则告诉前端跳过上传,实现秒传
        if (FileInfoUtils.fileExists(file) || byIdentifier != null) {
            ur.setSkipUpload(true);
            response.setStatus(HttpServletResponse.SC_OK);
            ur.setMessage("上传完成");
            return ur;
        }
        //如果完整文件不存在 去数据库判断当前哪些文件块已经上传过了 结果告诉前端 跳过这些文件块的上传 实现断点续传
        // 判段redis中已上传的文件片 没有文件片走标准上传
        if (redisTemplate.opsForValue().get(key) == null) {
            return ur;
        }
        // 有文件片文件片取出接续上传
        String temp = redisTemplate.opsForValue().get(key).toString();
        String[] split = temp.split(",");
        ArrayList<Integer> list = new ArrayList<>();
        for (String s : split) {
            list.add(Integer.parseInt(s));
        }
        if (list.size() > 0) {
            ur.setSkipUpload(false);
            ur.setUploadedChunks(list);
            response.setStatus(HttpServletResponse.SC_OK);
            ur.setMessage("继续上传");
            return ur;
        }
        return ur;
    }

    @PostMapping("/mergeFile")
    public ResponseEntity<?> mergeFile(@RequestBody FileInfoVo fileInfoVo) {
        String key = "chunk_info:" + fileInfoVo.getUniqueIdentifier();
        //前端组件参数转换为model对象
        FileInfo fileInfo = new FileInfo();
        fileInfo.setFilename(fileInfoVo.getName());
        fileInfo.setIdentifier(fileInfoVo.getUniqueIdentifier());
        fileInfo.setId(fileInfoVo.getId());
        fileInfo.setTotalSize(fileInfoVo.getSize());
        fileInfo.setType(fileInfoVo.getName().substring(fileInfoVo.getName().lastIndexOf(".")));
        //进行文件的合并操作
        String filename = fileInfo.getFilename();
        String file = uploadFolder + "/file/" + fileInfo.getIdentifier() + "/" + filename;
        String folder = uploadFolder + "/file/" + fileInfo.getIdentifier();
        String fileSuccess = FileInfoUtils.merge(file, folder, filename);
        fileInfo.setLocation(file);
        //文件合并成功后,保存记录至数据库
        if ("200".equals(fileSuccess)) {
            fileInfoService.save(fileInfo);
            // 删除文件片缓存
            redisTemplate.delete(key);
        }
        return ResponseResult.success("成功");
    }

    @GetMapping("/selectFileList")
    public ResponseEntity<?> selectFileList(@RequestParam(required = false) String fileName,
                                            @PageableDefault(sort = {"uploadTime"}, direction = Sort.Direction.ASC) Pageable pageable,
                                            HttpServletRequest request) {
        QFileInfo qFileInfo = QFileInfo.fileInfo;
        BooleanBuilder booleanBuilder = new BooleanBuilder();
        if (!StringUtils.isEmpty(fileName)) {
            booleanBuilder.and(qFileInfo.filename.like("%" + fileName + "%"));
        }
        String strPage = request.getParameter("page");
        String strSize = request.getParameter("size");
        if (org.apache.commons.lang.StringUtils.isEmpty(strPage) && org.apache.commons.lang.StringUtils.isEmpty(strSize)) {
            List<FileInfo> list = fileInfoService.findAll(booleanBuilder, pageable.getSort());
            return ResponseResult.success(list);
        }
        Page<FileInfo> pageList = fileInfoService.findAll(booleanBuilder, pageable);
        for (FileInfo fileInfo : pageList) {
            fileInfo.setTotalSizeName(CommonUtils.totalSize(fileInfo.getTotalSize()));
        }
        return ResponseResult.success(pageList);
    }

    @GetMapping("/download/{id}")
    public ResponseEntity<?> download(@PathVariable String id) {
        FileInfo fileInfo = fileInfoService.find(id);
        return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).body(new FileSystemResource(fileInfo.getLocation()));
    }

    @DeleteMapping(value = "/deleteFile/{id}")
    public ResponseEntity<?> deleteFile(@PathVariable String id) {
        FileInfo fileInfo = fileInfoService.find(id);
        if (fileInfo == null) {
            return ResponseResult.fail("请检查数据!");
        }
        fileInfoService.delete(id);
        FileInfoUtils.deleteDirectory(fileInfo.getLocation());
        return ResponseResult.success("删除成功");
    }
}

工具类

package org.zz.platform.filemanagement.utils;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.zz.platform.filemanagement.model.ChunkInfo;

import java.io.File;
import java.io.IOException;
import java.nio.file.*;

public class FileInfoUtils {

    private final static Logger logger = LoggerFactory.getLogger(FileInfoUtils.class);

    public static String generatePath(String uploadFolder, ChunkInfo chunk) {
        StringBuilder sb = new StringBuilder();
        sb.append(uploadFolder).append("/").append(chunk.getIdentifier());
        if (!Files.isWritable(Paths.get(sb.toString()))) {
            try {
                Files.createDirectories(Paths.get(sb.toString()));
            } catch (IOException e) {
                logger.error(e.getMessage(), e);
            }
        }

        return sb.append("/")
                .append(chunk.getFilename())
                .append("-")
                .append(chunk.getChunkNumber()).toString();
    }

    /**
     * 文件合并
     *
     * @param file     文件全路径
     * @param folder   文件夹
     * @param filename 文件名称
     * @Author: WangHaodong
     * @Date 2021/1/19 14:22
     * @return: java.lang.String
     **/
    public static String merge(String file, String folder, String filename) {
        try {
            //先判断文件是否存在
            if (fileExists(file)) {
                //文件已存在
                return "300";
            } else {
                //不存在的话,进行合并
                Files.createFile(Paths.get(file));
                Files.list(Paths.get(folder))
                        .filter(path -> !path.getFileName().toString().equals(filename))
                        .sorted((o1, o2) -> {
                            String p1 = o1.getFileName().toString();
                            String p2 = o2.getFileName().toString();
                            int i1 = p1.lastIndexOf("-");
                            int i2 = p2.lastIndexOf("-");
                            return Integer.valueOf(p2.substring(i2)).compareTo(Integer.valueOf(p1.substring(i1)));
                        })
                        .forEach(path -> {
                            try {
                                //以追加的形式写入文件
                                Files.write(Paths.get(file), Files.readAllBytes(path), StandardOpenOption.APPEND);
                                //合并后删除该块
                                Files.delete(path);
                            } catch (IOException e) {
                                logger.error(e.getMessage(), e);
                            }
                        });
                return "200";
            }
        } catch (IOException e) {
            logger.error(e.getMessage(), e);
            //合并失败
            return "400";
        }
    }

    /**
     * 根据文件的全路径名判断文件是否存在
     *
     * @param file 文件全路径
     * @Author: WangHaodong
     * @Date 2021/1/19 14:24
     * @return: boolean
     **/
    public static boolean fileExists(String file) {
        boolean fileExists;
        Path path = Paths.get(file);
        fileExists = Files.exists(path, LinkOption.NOFOLLOW_LINKS);
        return fileExists;
    }

    /**
     * 删除目录(文件夹)以及目录下的文件
     *
     * @param path 路径
     * @Author: WangHaodong
     * @return: boolean
     */
    public static boolean deleteDirectory(String path) {
        path = path.substring(0,path.lastIndexOf(File.separator));
        File dirFile = new File(path);
        if (!dirFile.exists() || !dirFile.isDirectory()) {
            return false;
        }
        boolean flag = true;
        // 删除文件夹下的所有文件(包括子目录)
        File[] files = dirFile.listFiles();
        for (File file : files) {
            if (file.isFile()) {
                flag = deleteFile(file.getAbsolutePath());
            } else {
                flag = deleteDirectory(file.getAbsolutePath());
            }
            if (!flag) {
                break;
            }
        }
        if (!flag) {
            return false;
        }
        return dirFile.delete();
    }

    /**
     * 删除单个文件
     *
     * @param path
     * @Author: WangHaodong
     * @return: boolean
     */
    public static boolean deleteFile(String path) {
        boolean flag = false;
        File file = new File(path);
        // 路径为文件且不为空则进行删除
        if (file.isFile() && file.exists()) {
            file.delete();
            flag = true;
        }
        return flag;
    }

}

vue端

Upload.vue

<template>
  <el-dialog
      width="500px"
      :visible.sync="showDialog"
      :title="title"
      :close-on-click-modal="false"
      append-to-body>
    <!-- 上传器 -->
    <uploader
        ref="uploader"
        :options="options"
        :autoStart=false
        :file-status-text="fileStatusText"
        @file-added="onFileAdded"
        @file-success="onFileSuccess"
        @file-error="onFileError"
        class="uploader-ui">
      <uploader-unsupport></uploader-unsupport>
      <uploader-drop>
        <div>
          <uploader-btn id="global-uploader-btn" :attrs="attrs" ref="uploadBtn">选择文件<i
              class="el-icon-upload el-icon--right"></i></uploader-btn>
        </div>
      </uploader-drop>
      <uploader-list></uploader-list>
    </uploader>
  </el-dialog>
</template>

<script>
import SparkMD5 from 'spark-md5'
import {mergeFile} from '@/api/common_services/file-upload-service'

export default {
  data() {
    return {
      title: 'ssss',
      showDialog: false,
      options: {
        // 目标上传 URL,默认POST
        target: `${baseUrl}/filemanagement/rest/uploader/chunk`,
        // 分块大小(单位:字节)
        chunkSize: '2048000',
        // 上传文件时文件内容的参数名,对应chunk里的Multipart对象名,默认对象名为file
        fileParameterName: 'upfile',
        // 失败后最多自动重试上传次数
        maxChunkRetries: 3,
        // 是否开启服务器分片校验,对应GET类型同名的target URL
        testChunks: true,
        checkChunkUploadedByResponse(chunk, message) {
          debugger
          const objMessage = JSON.parse(message)
          if (objMessage.skipUpload) {
            return true
          }
          return (objMessage.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0
        },
        headers: {
          Authorization: serverConfig.authorization
        }
      },
      attrs: {},
      fileStatusText: {
        success: '上传成功',
        error: '上传失败',
        uploading: '上传中',
        paused: '暂停',
        waiting: '等待上传'
      }
    }
  },
  methods: {
    onFileAdded(file) {
      this.computeMD5(file)
    },
    /*
    第一个参数 rootFile 就是成功上传的文件所属的根 Uploader.File 对象,它应该包含或者等于成功上传文件;
    第二个参数 file 就是当前成功的 Uploader.File 对象本身;
    第三个参数就是 message 就是服务端响应内容,永远都是字符串;
    第四个参数 chunk 就是 Uploader.Chunk 实例,它就是该文件的最后一个块实例,如果你想得到请求响应码的话,chunk.xhr.status就是
    */
    onFileSuccess(rootFile, file, response, chunk) {
      mergeFile(file).then(responseData => {
        if (responseData.data.code === 415) {
          console.log('合并操作未成功,结果码:' + responseData.data.code)
        }
      }).catch(error => {
        console.log('合并后捕获的未知异常:' + error)
      })
    },
    onFileError(rootFile, file, response, chunk) {
      console.log('上传完成后异常信息:' + response)
    },

    /**
     * 计算md5,实现断点续传及秒传
     * @param file
     */
    computeMD5(file) {
      file.pause()

      // 单个文件的大小限制2G
      const fileSizeLimit = 2 * 1024 * 1024 * 1024
      console.log('文件大小:' + file.size)
      console.log('限制大小:' + fileSizeLimit)
      if (file.size > fileSizeLimit) {
        this.$message({
          showClose: true,
          message: '文件大小不能超过2G'
        })
        file.cancel()
      }

      const fileReader = new FileReader()
      const time = new Date().getTime()
      const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
      let currentChunk = 0
      const chunkSize = 10 * 1024 * 1000
      const chunks = Math.ceil(file.size / chunkSize)
      const spark = new SparkMD5.ArrayBuffer()
      // 由于计算整个文件的Md5太慢,因此采用只计算第1块文件的md5的方式
      const chunkNumberMD5 = 1

      loadNext()

      fileReader.onload = e => {
        spark.append(e.target.result)

        if (currentChunk < chunkNumberMD5) {
          loadNext()
        } else {
          const md5 = spark.end()
          file.uniqueIdentifier = md5
          file.resume()
          console.log(`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`)
        }
      }

      fileReader.onerror = function () {
        this.error(`文件${file.name}读取出错,请检查该文件`)
        file.cancel()
      }

      function loadNext() {
        const start = currentChunk * chunkSize
        const end = (start + chunkSize) >= file.size ? file.size : start + chunkSize

        fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end))
        currentChunk++
        console.log('计算第' + currentChunk + '块')
      }
    },
    close() {
      this.uploader.cancel()
    },
    error(msg) {
      this.$notify({
        title: '错误',
        message: msg,
        type: 'error',
        duration: 2000
      })
    }
  }
}
</script>

<style>
.uploader-ui {
  padding: 15px;
  margin: 40px auto 0;
  font-size: 12px;
  font-family: Microsoft YaHei;
  box-shadow: 0 0 10px rgba(0, 0, 0, .4);
}

.uploader-ui .uploader-btn {
  margin-right: 4px;
  font-size: 12px;
  border-radius: 3px;
  color: #FFF;
  background-color: #409EFF;
  border-color: #409EFF;
  display: inline-block;
  line-height: 1;
  white-space: nowrap;
}

.uploader-ui .uploader-list {
  max-height: 440px;
  overflow: auto;
  overflow-x: hidden;
  overflow-y: auto;
}
</style>

UpLoadFileList.vue

<template>
  <div class="content">
    <el-form :inline="true" class="query-form">
      <el-row :gutter="20">
        <el-col :span="6">
          <el-form-item>
            <el-button type="primary" icon="el-icon-upload" @click="uploadFiles()">文件上传</el-button>
          </el-form-item>
        </el-col>
        <el-col :span="6">
          <el-form-item label="文件名">
            <el-input v-model="query.nameSearch" size="mini" placeholder="输入文件名"></el-input>
          </el-form-item>
        </el-col>

        <el-col :span="6">
          <el-form-item>
            <el-button type="primary" icon="el-icon-search" @click="handleSearch">搜索</el-button>
          </el-form-item>
        </el-col>
      </el-row>
    </el-form>
    <!-- table主要区域 begin -->
    <el-table
        :data="tableData"
        stripe
        border
        style="width: 100%;"
        height="300"
        tooltip-effect="light"
        ref="multipleTable">
      <el-table-column prop="filename" align="center" label="文件名" show-overflow-tooltip></el-table-column>
      <el-table-column prop="totalSizeName" align="center" width="" label="文件大小"></el-table-column>
      <el-table-column align="center" label="上传时间">
        <template slot-scope="scope">
          {{formatDateTime(scope.row.uploadTime)}}
        </template>
      </el-table-column>
      <el-table-column label="操作" width="150" align="center">
        <template slot-scope="scope">
          <el-button
              type="text"
              icon="el-icon-download"
              class="blue"
              @click="handleDownload(scope.row)"
          >下载
          </el-button>
          <el-button
              type="text"
              icon="el-icon-delete"
              class="red"
              @click="handleDelete(scope.row)"
          >删除
          </el-button>
        </template>
      </el-table-column>
    </el-table>
    <!-- table主要区域 end -->
    <el-pagination
        layout="total, prev, pager, next, jumper"
        :total="this.page.total"
        :current-page="this.page.currentPage"
        :page-size="this.page.size"
        :page-sizes="this.page.page_sizes"
        @current-change="handleCurrentChange"
        @size-change="handleSizeChange"
    >
    </el-pagination>
    <upload ref="uploadForm"></upload>
  </div>
</template>

<script>
import {selectFileList, deleteFile, downloadFile} from '@/api/common_services/file-upload-service'
import Upload from './Upload'
import FileUtil from '@/utils/FileUtil'

export default {
  components: {Upload},
  data() {
    return {
      query: {
        nameSearch: ''
      },
      idShow: false,
      tableData: [],
      multipleSelection: [],
      uploadVisible: false,
      pageTotal: 0,
      form: {},
      id: -1,
      page: {
        total: 0,
        size: 10,
        page_sizes: [10, 20, 50, 100],
        currentPage: 1
      }
    }
  },
  created() {
    this.getFileList()
  },
  methods: {
    getFileList() {
      this.loading = true
      selectFileList({
        page: this.page.currentPage - 1,
        size: this.page.size,
        fileName: this.query.nameSearch
      }).then(res => {
        this.tableData = res.data.content
        this.page.total = res.data.total
        this.page.totalPages = 0
        this.loading = false
      })
    },
    handlerClose() {
      this.getFileList()
    },
    uploadFiles() {
      this.$refs.uploadForm.showDialog = true
    },
    handleSearch() {
      this.getFileList()
    },
    /**
     * 当选择项发生变化时会触发该事件
     */
    handleSelectionChange(val) { // 选中的行
      this.selectedRowsData = val
      console.log(this.selectedRowsData)
    },
    /**
     * 改变页数
     * @param currentPage
     */
    handleCurrentChange(currentPage) {
      this.page.currentPage = currentPage
      this.getFileList()
    },
    /**
     * 改变页码
     * @param size
     */
    handleSizeChange(size) {
      this.page.size = size
      this.getFileList()
    },
    handleDelete(row) {
      this.$confirm('确定删除文件 [' + row.filename + ']吗?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        deleteFile(row.id).then(res => {
          this.$message({
            type: 'success',
            message: '删除成功!'
          })
          this.getFileList()
        })
      }).catch(() => {
        this.$message({
          type: 'info',
          message: '取消删除'
        })
      })
    },
    // 下载
    handleDownload(row) {
      this.loadingOverLay = this.$loading({
        lock: true,
        text: '文件生成中',
        spinner: 'el-icon-loading',
        background: 'rgba(0,0,0,0.7)'
      })
      downloadFile(row.id).then(res => {
        const blob = new Blob([res.data], {
          type: 'application/octet-stream'
        })
        FileUtil.downloadFromFile(blob, row.filename)
      }).catch(err => {
        this.$message.error('下载出错' + err)
      })
      this.loadingOverLay.close()
    }
  }
}
</script>

<style scoped>
/deep/ .el-table__empty-text {
  line-height: 30px;
  width: 50%;
  color: #909399;
}
</style>

界面效果

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!