Django大文件分块上传和分块下载

下面是简单的demo,可能有Bug,需要的看官拿去自己拓展吧。

views.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import re
import stat
import base64
import shutil
import pickle
import rehash
import mimetypes
import posixpath
from django.conf import settings
from urllib.parse import unquote
from django.core.cache import caches
from django.utils._os import safe_join
from django.utils.http import http_date
from django.views.static import was_modified_since
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse, Http404, FileResponse, HttpResponseNotModified


def remove_file(file_path):
    if not os.path.isfile(file_path):
        return
    os.remove(file_path)


def write_file(file_path_temp, file):
    if not os.path.isdir(os.path.dirname(file_path_temp)):
        os.makedirs(os.path.dirname(file_path_temp))
    try:
        with open(file_path_temp, 'ab') as destination:
            destination.write(file['code'])
    except OSError as exc:
        return exc.errno


def check_file(file, file_path, file_path_temp):
    # is_end表示整个文件都传输完毕了

    if file['percent'][0] == '0' and os.path.exists(file_path_temp):
        remove_file(file_path_temp)  # 开始上传时删除已有的暂存文件
    elif file['percent'][0] != '0' and not os.path.exists(file_path_temp):
        # 上传过程中暂存文件丢失
        # firefox浏览器调用接口删除文件并取消传输后还会上传一个文件块,这里做兼容
        return {'code': '1', 'msg': '暂存文件丢失,上传失败', 'is_end': True}

    # 校验文件内容及大小
    if not file['code']:
        return {'code': '1', 'msg': '不能上传空文件'}
    if not 0 < file['size'] <= settings.max_size:
        return {'code': '1', 'msg': '文件大小不符合要求'}

    # 获得已传输的文件大小
    file_size = len(file['code'])
    if os.path.exists(file_path_temp):
        file_size += os.path.getsize(file_path_temp)

    # 校验文件大小
    if not 0 < file_size <= settings.max_size:
        return {'code': '1', 'msg': '文件大小不符合要求'}
    if (file['percent'][1] == '100' and file_size != file['size']) or file_size > file['size']:
        return {'code': '1', 'msg': '文件大小校验失败'}

    # 生成已传输的文件块的md5值
    if file['percent'][0] == '0':
        file_hash = rehash.md5()
    else:
        file_hash = caches['default'].get(file_path_temp)
        if not file_hash:
            return {'code': '1', 'msg': '文件校验值过期'}
        file_hash = pickle.loads(base64.b64decode(file_hash.encode()))
    file_hash.update(file['code'])

    # 校验md5值,保证内容一致性
    if file['md5'] != file_hash.hexdigest():
        return {'code': '1', 'msg': '文件校验失败'}

    if file['percent'][1] == '100' or file_size == file['size']:  # 传输完毕的状态
        if write_file(file_path_temp, file) == 36:
            return {'code': '1', 'msg': '文件名过长'}
        shutil.move(file_path_temp, file_path)
        return {'code': '0', 'msg': '上传并提交成功', 'is_end': True}
    else:
        if write_file(file_path_temp, file) == 36:
            return {'code': '1', 'msg': '文件名过长'}

        # 暂存md5的hash值
        caches['default'].set(file_path_temp, base64.b64encode(pickle.dumps(file_hash)).decode(), 60)
        return {'code': '0', 'msg': '上传并提交文件块成功', 'is_end': False}


@login_required
def upload_file(request):
    file = {
        'name': unquote(request.META['HTTP_FILE_UPLOAD_NAME']),
        'percent': request.META['HTTP_FILE_UPLOAD_PERCENT'].replace('NaN-Infinity', '0-100').split('-'),
        'size': int(request.META['HTTP_FILE_UPLOAD_SIZE']),
        'md5': request.META['HTTP_FILE_UPLOAD_MD5'],
        'code': request.body
    }  # 文件分为不大于100KB的小块传输

    # 传输过程中直接跳到其他页面
    if file['percent'][1].startswith('http'):
        return JsonResponse({'code': '-4', 'msg': '传输已终止', 'is_end': True})

    file_path = os.path.join(settings.BASE_DIR, 'upload_files/%s' % file['name'])
    file_path_temp = file_path + '.temp'

    if file['percent'][0] == '0' and caches['default'].get(file_path):  # 同名文件上传冲突,不进行其它操作
        return JsonResponse({'code': '1', 'msg': '同名文件正在上传'})
    else:  # 标记文件正在上传
        caches['default'].set(file_path, 'uploading', 2)

    response = check_file(file, file_path, file_path_temp)
    # 文件上传失败或者上传完毕后,清理暂存文件和缓存
    if response['code'] != '0' or response['is_end']:
        remove_file(file_path_temp)
        caches['default'].delete_pattern(file_path + '*')
    return JsonResponse(response)


@login_required
def download_file(request, path, document_root=None):
    # 防止目录遍历漏洞
    path = posixpath.normpath(path).lstrip('/')
    fullpath = safe_join(document_root, path)
    if os.path.isdir(fullpath):
        raise Http404('Directory indexes are not allowed here.')
    if not os.path.exists(fullpath):
        raise Http404('"%(path)s" does not exist' % {'path': fullpath})

    statobj = os.stat(fullpath)

    # 判断下载过程中文件是否被修改过
    if not was_modified_since(request.META.get('HTTP_IF_MODIFIED_SINCE'),
                              statobj.st_mtime, statobj.st_size):
        return HttpResponseNotModified()

    # 获取文件的content_type
    content_type, encoding = mimetypes.guess_type(fullpath)
    content_type = content_type or 'application/octet-stream'

    # 计算读取文件的起始位置
    start_bytes = re.search(r'bytes=(\d+)-', request.META.get('HTTP_RANGE', ''), re.S)
    start_bytes = int(start_bytes.group(1)) if start_bytes else 0

    # 打开文件并移动下标到起始位置,客户端点击继续下载时,从上次断开的点继续读取
    the_file = open(fullpath, 'rb')
    the_file.seek(start_bytes, os.SEEK_SET)

    # status=200表示下载开始,status=206表示下载暂停后继续,为了兼容火狐浏览器而区分两种状态
    # 关于django的response对象,参考:https://www.cnblogs.com/scolia/p/5635546.html
    # 关于response的状态码,参考:https://www.cnblogs.com/DeasonGuan/articles/Hanami.html
    # FileResponse默认block_size = 4096,因此迭代器每次读取4KB数据
    response = FileResponse(the_file, content_type=content_type, status=206 if start_bytes > 0 else 200)

    # 'Last-Modified'表示文件修改时间,与'HTTP_IF_MODIFIED_SINCE'对应使用,参考:https://www.jianshu.com/p/b4ecca41bbff
    response['Last-Modified'] = http_date(statobj.st_mtime)

    # 这里'Content-Length'表示剩余待传输的文件字节长度
    if stat.S_ISREG(statobj.st_mode):
        response['Content-Length'] = statobj.st_size - start_bytes
    if encoding:
        response['Content-Encoding'] = encoding

    # 'Content-Range'的'/'之前描述响应覆盖的文件字节范围,起始下标为0,'/'之后描述整个文件长度,与'HTTP_RANGE'对应使用
    # 参考:http://liqwei.com/network/protocol/2011/886.shtml
    response['Content-Range'] = 'bytes %s-%s/%s' % (start_bytes, statobj.st_size - 1, statobj.st_size)

    # 'Cache-Control'控制浏览器缓存行为,此处禁止浏览器缓存,参考:https://blog.youkuaiyun.com/cominglately/article/details/77685214
    response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
    return response

upload.js

import SparkMD5 from 'spark-md5'
import ajax_fetch from '@/config/ajax'
import { MessageBox, Message } from 'element-ui'

function onUpload(file, size, name, start, end, md5) {
    return new Promise((resolve, reject) => {
        ajax_fetch('/upload/', file, 'POST', {
            'file-upload-name': encodeURIComponent(name),
            'file-upload-percent': start + '-' + end,
            'file-upload-size': size,
            'file-upload-md5': md5
            'x-csrftoken': window.localStorage.getItem('csrfmiddlewaretoken'),
        }).then((res) => {
            resolve(res);
        }).catch(err => {
            reject(err)
        });
    })
}

async function uploadFile() {
    if(!document.getElementsByName('uploadForm')[0].files[0]){
        return;
    }
    return new Promise(function (resolve, reject) {
        var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,  // 兼容切片方法
            file = document.getElementsByName('uploadForm')[0].files[0],  // 文件对象
            chunkSize = 2 * 1024 * 1024,                             // 每块文件2MB
            chunks = Math.ceil(file.size / chunkSize),               // 文件分多少块
            currentChunk = 0,                                        // 已经传输了多少块
            md5 = '',
            spark = new SparkMD5.ArrayBuffer(),
            fileReader = new FileReader();
        fileReader.onload = async function (e) {
            spark.append(e.target.result);
            md5 = spark.digest();
            var start_p = (currentChunk / chunks) * 100,
                end_p = ((currentChunk + 1) / chunks) * 100;
                let response = await onUpload(e.target.result, file.size, file.name, start_p, end_p, md5);
            if (response.code == 0 && response.is_end) {
                // 传输文件结束
                resolve(response);
                Message.success(file.name + '文件上传成功!');
            } else if (response.code == 0 && !response.is_end) {
                if (/upload-file/.test(window.location.href)) {  //文件块传输成功,传输下一块
                    currentChunk++;
                    loadNext();
                } else {  // 传输过程中跳转到其他页面了
                    onUpload(e.target.result, file.size, file.name, 1, window.location.href, md5);
                }
            } else if (response.code == -4) {
                spark.end();  // 释放内存
            } else {
                spark.end();  // 释放内存
                Message.error(response.msg);
            }
        };
        function loadNext() {
            var start = currentChunk * chunkSize,
                end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
            fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));  // 这里会触发onload事件
        }
        loadNext();
    }).catch((err)=>{
        console.log('文件传输失败')
    })
}

export default uploadFile

urls.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from site import views
from django.urls import re_path

urlpatterns = [
    re_path(r'^/upload/?$', views.upload_file),
    re_path(r'^/download/(?P<path>.*)$', views.download_file, {'document_root': settings.MEDIA_ROOT}),
]

### 文件上传下载预览功能的实现 #### 1. 文件上传 为了在 Django 中实现文件上传,首先需要设置 `forms` `views` 来处理表单提交。下面是一个简单的例子来展示如何创建一个允许用户上传文件的功能。 ```python from django import forms from .models import Document class UploadFileForm(forms.ModelForm): class Meta: model = Document fields = ['file'] def upload_file(request): if request.method == 'POST': form = UploadFileForm(request.POST, request.FILES) if form.is_valid(): new_doc = Document(file=request.FILES['file']) new_doc.save() return HttpResponseRedirect(reverse('index')) else: form = UploadFileForm() return render(request, 'upload.html', {'form': form}) ``` 此代码片段展示了如何构建一个基于模型的表单并保存上传文件到数据库中[^2]。 #### 2. 文件下载 对于文件下载,在视图函数中可以通过读取存储路径中的文件并向客户端发送响应来进行操作: ```python import os from django.http import FileResponse from django.conf import settings def download_file(request, file_id): document = get_object_or_404(Document, pk=file_id) filepath = os.path.join(settings.MEDIA_ROOT, str(document.file)) response = FileResponse(open(filepath, 'rb'), as_attachment=True) return response ``` 这段代码实现了当用户提供有效的 ID 请求特定文件时返回该文件给用户的逻辑。 #### 3. 图片预览 要实现在前端页面上显示已上传图片的小缩略图或其他形式的即时反馈,可以在 HTML 表单内加入 JavaScript 或 jQuery 脚本来捕获图像数据并在 `<img>` 标签里动态更新其源属性 (`src`) 。这里给出一段基本的 JS 实现方法: ```html <script> function previewImage(input) { var reader = new FileReader(); reader.onload = function (e) { $('#preview').attr('src', e.target.result); } reader.readAsDataURL(input.files[0]); } </script> <form method="post" enctype="multipart/form-data"> {% csrf_token %} <input type='file' onchange="previewImage(this);" /> <img id="preview" src="#" alt="your image" style="display:none"/> <button type="submit">Upload Image</button> </form> ``` 上述脚本会在用户选择了新的图片之后立即加载它作为 img 元素的内容,并将其可见度设为 true 显示出来[^4]。 #### 4. 安全性最佳实践建议 - **验证输入**: 总是对来自用户的任何输入执行严格的验证。 - **限制大小/类型**: 设置合理的文件大小上限以及只接受指定类型的文件。 - **保护敏感信息**: 不要在公开可访问的地方暴露原始文件名或内部服务器结构的信息。 - **异步处理大文件**: 对于较大的文件考虑采用分块传输编码或者 WebSockets 进行渐进式接收。 - **防止恶意软件传播**: 执行病毒扫描其他必要的安全措施以确保不会无意间托管有害内容。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值