白嫖小程序云存储空间,手撸你的专属云盘

前言

之前在开发微信小程序的时候,发现官方给每个小程序分配了5g的免费云存储空间和每个月5g的cdn流量(免费版):

image

在小程序的开发后台可以查看云存储上的文件,文件本质上是存在cdn上的,每个文件都提供了专属的downLoad url,靠着这个url我们就可以下载部署在云端的文件,也就是说上传的文件自带cdn加速。
image

5G的空间不算少,自己的小程序用不到额外的云存储资源,这个资源拿来给自己搭建一个私有云盘岂不美哉?以后自己的一些小文件就可以放在上面,方便存储和下载。诸位如果没有开发过小程序也没有关系,在微信公众平台上随便申请个工具人小程序,然后开启云开发即可,我们只是白嫖云存储空间。项目地址见文末。

需求分析

要完成我们的设想,我们先罗列下我们需要哪些功能:

  • 文件本地上传到云存储
  • 当前文件列表的展示
  • 已上传文件的下载和删除
  • 简单的登录和api操作鉴权
  • 具有良好的交互,包括进度条等功能

小程序云存储的相关api支持服务器端调用,不支持浏览器直接调用,所以为了操作云存储的相关api,我们需要开启一个中继的node服务作为服务器,顺便管理我们的文件列表。
整个系统的工作流应该是这样的:在我们的前端服务通过用户交互,上传文件到中继的node服务上,node服务器将接收到的文件上传给小程序的云存储空间,获取返回的文件的相关信息(主要是download url),同时在数据库内维护文件列表的相关信息(直接存在小程序对应的数据库中即可)。前端服务会请求后端获取云存储中的文件列表,通过用户的交互可对各个文件进行删除和下载等操作(实际上是向node服务器发送请求,由node服务器调用官方的各种api来对云端的数据进行处理)。
在工具链的选择上,采取react + antd + typescript的技术方案,后端服务使用node + express。

核心功能实现

文件上传

上传逻辑前端部分

首先我们从数据流的源头开始,开始搭建文件核心上传部分index.tsx

import React, { useState, useEffect, useReducer } from 'react';
import * as s from './color.css';
import withStyles from 'isomorphic-style-loader/withStyles';
import { Layout, Upload, Card, Button, message, Table, Progress, Spin } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { upload } from '@utils/upload';
import { UploadFile, UploadChangeParam } from 'antd/lib/upload/interface';
import { fileObj, parseList, columns, FileListAction, ProgressObj, ProgressAction } from './accessory';
const { Header, Content, Footer } = Layout;
//  省略部分依赖

function ShowComponent() {
    //  文件上传列表的hooks
    const [fileList, setFList] = useReducer(listReducer, []);
    //  省略无关代码
    //  ......
    async function handleChange(info: UploadChangeParam<UploadFile<any>>) {
        const { fileList: newFileList, file } = info;
        //  上传文件的核心逻辑
        const ans = await upload(info);
        const { fileData = {} } = ans;
        if (fileData.fileName) {
            setFList({ type: 'update', payload: Object.assign(fileData, { key: fileData._id }) });
            message.success(`${info.file.name} 上传成功。`);
        } else {
          message.error(`${info.file.name} 上传失败。`);
          return;
        }
      }

    return (
        <Layout className={s.layout}>
            <Header>
                <div className={s.title}>自己的网盘</div>
            </Header>
            <Content style={{ padding: '50px 50px' }}>
                <div className={s.siteLayoutContent}>
                    <Upload
                        customRequest={() => {}}
                        onChange={handleChange}
                        showUploadList={false}
                        multiple={true}
                    >
                        <Button>
                            <UploadOutlined /> Click to Upload
                        </Button>
                    </Upload>
                </div>
            </Content>
        </Layout>
    )
}
export default withStyles(s)(ShowComponent);

这部分的逻辑很简单,主要是通过react+antd搭建UI,使用antd的Upload控件完成上传文件的相关交互,将获取到的文件对象传递给封装好的upload函数,接下来我们来看看upload.tsx中的逻辑:

import {UploadFile, UploadChangeParam } from 'antd/lib/upload/interface';
import { reqPost, apiMap, request, host } from '@utils/api';
import { ProgressObj, ProgressAction } from '../entry/component/content/accessory';

const SIZE = 1 * 1024 * 1024; // 切片大小

// 生成文件切片
function createFileChunk(file: File | Blob | undefined, size = SIZE) {
    if (!file) {
        return [];
    }
    const fileChunkList = [];
    let cur = 0;
    while (cur < file.size) {
        //  对字节码进行切割
        fileChunkList.push({ file: file.slice(cur, cur + size) });
        cur += size;
    }
    return fileChunkList;
}

interface FileObj extends File {
    name: string;
}

//  发送单个的文件切片
export async function uploadFile(params: FormData, fileName: string) {
    return request(host + apiMap.UPLOAD_FILE_SLICE, {
        method: 'post',
        data: params,
    });
}

//  给服务器发送合并切片的逻辑
export async function fileMergeReq(name: string, fileSize: number) {
    return reqPost(apiMap.MERGE_SLICE, { fileName: name, size: SIZE, fileSize: fileSize });
}

export async function upload(info: UploadChangeParam<UploadFile<any>>) {
    //  获取切片的文件列表
    const fileList = createFileChunk(info.file.originFileObj);
    if (!info.file.originFileObj) {
        return '';
    }
    const { name: filename, size: fileSize } = info.file.originFileObj as FileObj;
    //  生成数据包list
    const dataPkg = fileList.map(({ file }, index) => ({
        chunk: file,
        hash: `${filename}-${index}` // 文件名 + 数组下标
        }));
    //  通过formdata依次发送数据包
    const uploadReqList = dataPkg.map(({ chunk, hash}) => {
        const formData = new FormData();
        formData.append('chunk', chunk);
        formData.append('hash', hash);
        formData.append('filename', filename);
        return formData
    });
    const promiseArr = uploadReqList.map(item => uploadFile(item, filename));
    await Promise.all(promiseArr);
    //  全部发送完成后发送合并切片的请求
    const ans = await fileMergeReq(filename, fileSize);
    callBack({ type: 'delete', fileName: filename });
    return ans;
}

这里的逻辑并不复杂,核心是思想是将用户上传的文件切成每个1M的文件切片,并做好标记,将所有的文件切片送到服务器,服务器接收到所有的切片后告知前端接收完成,前端发送合并请求,告知服务器可以将所有的文件切片依据做好的标记合并成原文件。

上传逻辑server端部分

接下来我们看看服务器端与之配合的代码:

let ownTool = require('xiaohuli-package');
let fs = require('fs');
const request = require('request-promise');
const fse = require('fs-extra');
const path = require('path');
const multiparty = require('multiparty');
const { getToken, verifyToken, apiPrefix, errorSend, loginVerify, ENV_ID } = require('../baseUtil');
const { uploadApi, downLoadApi, queryApi, addApi, updateApi } = require('./apiDomain');

const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存储目录

//  读取文件流,并将其pipe到写文件流
const pipeStream = (path, writableStream) => 
    new Promise(resolve => {
        const readStream = fse.createReadStream(path);
        readStream.on('end', () => {
            fse.unlinkSync(path);
            resolve()
        });
        readStream.pipe(writableStream);
    })

//  合并接收到文件chunk
const mergeFileChunk = async (filePath, fileName, size) => {
    const chunkDir = path.resolve(UPLOAD_DIR, fileName);
    const chunkPaths = await fse.readdir(chunkDir);
    chunkPaths.sort((a, b) => a.split('-')[1] - b.split('-')[1]);
    //  对所有的文件切片完成写文件流操作
    await Promise.all(chunkPaths.map((chunkPath, index) =>
        pipeStream(path.resolve(chunkDir, chunkPath),
            fse.createWriteStream(filePath, { start: index * size, end: (index + 1) * size })
        )
    ));
    //  删除中间的过渡文件
    try {
        //  反复改名啥的很奇怪,但是不这样就会有报错,导致请求返回pending,可能是windows下的bug
        //  文件夹的名字和文件名字不能重复
        await fse.move(filePath, path.resolve(UPLOAD_DIR, `p${fileName}`)).catch(e => {
            console.log(e)
        });
        fse.removeSync(chunkDir);
        await fse.move(path.resolve(UPLOAD_DIR, `p${fileName}`), path.resolve(UPLOAD_DIR, `${fileName}`)).catch(e => {
            console.log(e);
        });
    } catch(e) {
        await fse.move(path.resolve(UPLOAD_DIR, `p${fileName}`), path.resolve(UPLOAD_DIR, `${fileName}`)).catch(e => {
            console.log(e)
        });
    }
}
//  上传本地合并的文件到云存储
async function uploadToCloud(filePath, fileName) {
    const wxToken = await getToken();
    const fullPath = path.resolve(filePath, fileName);
    const doamin = uploadApi + wxToken;
    //  获取图片上传相关信息
    let a = await ownTool.netModel.post(doamin, {
        env: ENV_ID,
        path: fileName
    })
    const { authorization, url, token: newToken, cos_file_id, file_id} = a;
    //  真正上传图片
    const option = {
        method: 'POST',
        uri: url,
        formData: {
            "Signature": authorization,
            "key": fileName,
            "x-cos-security-token": newToken,
            "x-cos-meta-fileid": cos_file_id,
            "file": {
                //  读取文件流,作为属性值上传
                value: fs.createReadStream(fullPath),
                options: {
                    filename: 'test',
                    //contentType: file.type
                }
            }
        }
    }
    await request(option);
    //  获取图片的下载链接
    const getDownDomain = downLoadApi + wxToken;
    let imgInfo = await ownTool.netModel.post(getDownDomain, {
        env: ENV_ID,
        file_list: [{
            fileid: file_id,
            max_age: 7200
        }]
    });
    //  server中转的图片删掉
    fs.unlink(fullPath, (e) => {
        if(e) {
            console.log(e);
        }
    })
    return imgInfo;
}

//  更新数据库中的文件列表
async function updateList(fileObj, fileName, size) {
    const { download_url, fileid } = fileObj;
    const dataInfo = {
        fileName,
        downloadUrl: download_url,
        fileId: fileid,
        size,
        timeStamp: Date.now()
    };
    const dataInfoString = JSON.stringify(dataInfo);
    const wxToken = await getToken();
    let fileId = '';
    let isNew = false;
    //  先看有没有同名文件
    const res = await ownTool.netModel.post(
        queryApi + wxToken, {
        env: ENV_ID,
        //  查询数据
        query: 'db.collection(\"fileList\").where({ fileName: "' + fileName +'"}).get()'
    });

    //  如果已经有了,就更新记录
    if (res.data.length) {
        fileId = JSON.parse(res.data[0])._id;
        const res1 = await ownTool.netModel.post(updateApi + wxToken, {
            env: ENV_ID,
            //  query语句,功能是给filelist这个集合更新数据
            query: 'db.collection(\"fileList\").where({ fileName: "' + fileName + '"}).update({ data: ' + dataInfoString +'})'
        })
    //  否则新建一个
    } else {
        const res2 = await ownTool.netModel.post(addApi + wxToken, {
            env: ENV_ID,
            //  query语句,功能是给filelist这个集合添加数据
            query: 'db.collection(\"fileList\").add({ data: ' + dataInfoString +'})'
        })
        fileId = res2.id_list[0];
        isNew = true;
    }
    const finalData = Object.assign(dataInfo, { _id: fileId });
    return { fileData: finalData, isNew };
}

function uploadFileApi(app) {
    //  接收上传的文件片段
    app.post(apiPrefix + '/uploadFile', async function(req, res) {
        //  通过multiparty这个库解析上传的form data,并生成本地文件蠢哭
        const multipart = new multiparty.Form();
        multipart.parse(req, async (err, fields, files) => {
            if (err) {
                console.log(err);
                return;
            }
            const [chunk] = files.chunk;
            const [hash] = fields.hash;
            const [filename] = fields.filename;
            const chunkDir = path.resolve(UPLOAD_DIR, filename);
            if (!fse.existsSync(chunkDir)) {
                await fse.mkdirs(chunkDir).catch(e => {
                    console.log(e)
                });
            }
            await fse.move(chunk.path, `${chunkDir}/${hash}`);
            res.end('received file chunk');
        })
    })

    //  合并文件
    app.post(apiPrefix + '/fileMergeReq', async function(req, res) {
        const { fileName, size, fileSize } = req.body;
        const filePath = path.resolve(UPLOAD_DIR, `${fileName}`, `${fileName}`);
        //  合并文件chunk
        await mergeFileChunk(filePath, fileName, size);
        //  上传文件到云存储
        const fileInfo = await uploadToCloud(UPLOAD_DIR, `${fileName}`);
        //  更新文件列表
        const dbInfo = await updateList(fileInfo.file_list[0], fileName, fileSize);
        res.send(dbInfo);
    })
}

exports.uploadFileApi = uploadFileApi;

这里涉及到了小程序http api的调用,调用前需要获取调用token,再配合相关参数完成请求,详情请查阅官方文档,这里的逻辑与前端一一对应,首先是接受前端上传过来的文件切片,将他们解析并保存到临时目录,等到前端发送过来文件合并的请求后,将先前接受到的文件切片合并成原始文件。随后调用小程序官方api,将本地的文件上传到云存储上,根据返回的fileId,获取文件部署在cdn上的download url,并将其返回给前端。

文件列表展示

文件上传的核心功能完成之后,接下来要处理的是文件列表的展示,这里我们使用react中的hooks来作为状态管理的工具。更新index.tsx中的代码

import { usePageManager, getQueryString, SINGLE_PAGE_SIZE } from '@utils/commonTools';
//  省略部分依赖

function ShowComponent() {
    //  控制页码的自定义hook
    const [pageObj, setPage] = usePageManager();
    //  选中的文件的状态
    const [chekcList, setCheckList] = useState([]);

    function listReducer(state: Array<fileObj>, action: FileListAction): Array<fileObj> {
        //  文件列表状态更新
        const fileUpdate = () => {
            //  找出要更新的文件
            const index = state.findIndex(item => item._id === action.payload._id);
            //  如果找不到,表示是新增
            if (index >= 0) {
                const target = state[index];
                //  修改时间戳
                target.timeStamp = action.payload.timeStamp;
                return [...state.slice(0, index), target, ...state.slice(index + 1)];
            } else {
                //  新增文件
                return (action?.payload ? [action.payload] : []).concat([...state])
            }
        }
        const actionMap = {
          //    初始化内容
          init: () => action?.list || [],
          update: fileUpdate,
          //    删除文件
          delete: () => state.filter(item => action.keys.findIndex(sitem => sitem ===item._id) === -1)
        };
        return actionMap[action.type]();
      }
    //  文件列表的状态
    const [fileList, setFList] = useReducer(listReducer, []);
    //  初始化内容
    useEffect(() => {
        const initList = async function() {
            //  向后端查询文件列表内容
            const res = await post(apiMap.QUERY_LIST, {
                queryString: getQueryString(1)
            });
            const list = parseList(res);
            //  设置总页码
            setPage({ total: res.pager.Total });
            //  初始化文件列表
            setFList({ type: 'init', list })
        };
        initList();
    }, []);

    async function handleChange(info: UploadChangeParam<UploadFile<any>>) {
        //  省略部分代码
      }
    //  table点击下一页时的回调
    async function detail(page: number) {
        //  查询下一页的内容
        const res = await post(apiMap.QUERY_LIST, {
            queryString: getQueryString(page)
        });
        const showList = parseList(res);
        //  设置页码
        setPage({ current: page, total: res.pager.Total });
        //  重置文件列表
        setFList({ type: 'init', list: showList });
    }

    async function deleteFile() {
        const deleteList = fileList.filter(item => chekcList.findIndex(sitem => item._id === sitem) >= 0)
            .map(item => item.fileId);
        await post(apiMap.DELETE_FILE, {
            deleteFileList: deleteList
        });
        setFList({ type: 'delete', keys: chekcList });
    }

    function getNotification() {
        //  省略部分代码
    }

    const paginaConfig = {
        onChange: detail,
        total: pageObj.total,
        current: pageObj.current,
        pageSize: SINGLE_PAGE_SIZE,
    };
    return (
        <Layout className={s.layout}>
            <Header>
                <div className={s.title}>自己的网盘</div>
            </Header>
            {getNotification()}
            <Content style={{ padding: '50px 50px' }}>
                <div className={s.siteLayoutContent}>
                    <Upload
                        customRequest={() => {}}
                        onChange={handleChange}
                        showUploadList={false}
                        multiple={true}
                    >
                        <Button>
                            <UploadOutlined /> Click to Upload
                        </Button>
                    </Upload>
                    <Button className={s.deleteBtn} onClick={deleteFile} type='dashed'>删除</Button>
                    <Button className={s.downLBtn} onClick={downloadFile} type='primary'>下载</Button>
                    <Table
                        rowSelection={{
                            type: 'checkbox',
                            onChange: (selectedRowKeys, selectedRows) => {
                                setCheckList(selectedRowKeys);
                            },
                        }}
                        pagination={paginaConfig} columns={columns} dataSource={fileList} />
                </div>
            </Content>
            <Footer style={{ textAlign: 'center' }}>Produced by 广兰路地铁</Footer>
        </Layout>
    )
}

这里我们使用了三个hook来协助我们管理状态,usePageManager这个自定义hook来控制文件列表的切页状态,const [chekcList, setCheckList] = useState([]);来控制多个文件的选中态(下图中的checkbox):

image

当需要对文件进行多选时,通过setCheckList来控制当前选中的文件列表,通过文件的唯一_id来标识不同的文件。具体可进行下载或者删除等操作。
const [fileList, setFList] = useReducer(listReducer, []);来控制文件列表状态。useEffect配合空数组做参数进行fileList的初始化(可以类比传统class component的componentDidMount方法),向服务器请求文件列表,将内容解析后通过antdtable组件渲染在页面上,table切页时会根据pagination上注册的onChange事件根据当前的页码去拉取新的内容并更新table。在文件上传完毕,或者删除时,都需要更新fileList的状态,此时调用setFList来更新当前的文件列表。

文件下载

这一部分的内容相对简单,这里笔者采取的方案是通过构造form表单,通过设置get method然后submit表单来完成文件的下载:

export function downloadUrlFile(url) {
    let tempForm = document.createElement('form')
    tempForm.action = url
    tempForm.method = 'get'
    tempForm.style.display = 'none'
    document.body.appendChild(tempForm)
    tempForm.submit()
    return tempForm
}

实现效果大致这样:

image

关于文件,延伸出来的内容不少,我有另外一篇文章进行了比较细致的分析,感兴趣的朋友可以移步关于点击下载文件的那些事

增加上传进度条,优化体验

作为一个合格的网盘,没有上传进度条体验是很糟糕的。那么如何实现呢?显然,在server端的文件合并,上传至云盘等步骤是没有办法量化的,很难用进度条的形式展示,唯一前端可控的就是文件切片与上传的过程。首先我们要定义一个新的列表来标识正在上传的文件的切片进度:

//  index.tsx
function ShowComponent() {{
    // 省略重复代码
    
    //  定义控制文件上传状态的hooks
    const [uploadProgressList, setUploadPL] = useReducer(uploadProFunc, []);

    //  维护上传列表的进度条
    function uploadProFunc(state: Array<ProgressObj>, action: ProgressAction): Array<ProgressObj> {
        const progressUpdate = () => {
            const index = state.findIndex(item => item.fileName === action.fileName);
            if (index >= 0) {
                const target = state[index];
                target.finishedChunks += action.finishedChunks;
                return [...state.slice(0, index), target, ...state.slice(index + 1)];
            } else {
                return (action?.payload ? [action.payload] : []).concat([...state])
            }
        }
        const actionMap = {
            update: progressUpdate,
            delete: () => state.filter(item => item.fileName !== action.fileName)
        };
        return actionMap[action.type]();
    }
}

我们需要将setUploadPL作为回调传入文件上传的操作中,在每个切片完成之后更新目标文件的进度:

//  index.tsx
function ShowComponent() {
    //  省略部分内容
    async function handleChange(info: UploadChangeParam<UploadFile<any>>) {
        const { fileList: newFileList, file } = info;
        //  console.log(info);
        const ans = await upload(info, setUploadPL);
        //  省略重复部分
    }
}

继续更新upload.tsx:

export async function uploadFile(params: FormData, fileName: string, cb: React.Dispatch<ProgressAction>) {
    return request(host + apiMap.UPLOAD_FILE_SLICE, {
        method: 'post',
        data: params,
    }).then(res => {
        //  追加回调函数,更新上传进度
        cb({ type: 'update', fileName, finishedChunks: 1})
    });
}

export async function upload(info: UploadChangeParam<UploadFile<any>>, callBack: React.Dispatch<ProgressAction>) {
    //  省略部分内容

    //  创建文件上传对象
    const initPro = {     
        fileName: filename,
        fullChunks: uploadReqList.length,
        finishedChunks: 0
    } as ProgressObj;
    //  创建一个文件上传的状态
    callBack({ type: 'update', fileName: filename, payload: initPro });
    //  追加回调
    const promiseArr = uploadReqList.map(item => uploadFile(item, filename, callBack));
    //  省略部分内容
}

这里简单解释下逻辑,在每次上传文件时,计算总计的文件切片数,然后通过调用传入的回调更新文件上传的状态,在上传文件切片的过程中,每个切片上传完毕后,更新该文件上传状态的finishedChunks属性,至此,我们已经能够追踪文件的上传态,接下来要做的就是利用文件上传的状态来绘制进度条,继续补充index.tsx中的代码。

function ShowComponent() {
    //  省略部分代码
    function getNotification() {
        const statusList = uploadProgressList.map((item, index) => {
            const { fileName, fullChunks, finishedChunks } = item;
            const percent = finishedChunks / fullChunks;
            return <div key={fileName} className={s.box}>
                <div>正在上传:{fileName}</div>
                <Progress percent={percent * 100} status="active"/>
                {percent === 1 ? <div className={s.uploading}>
                    <div className={s.loadingW}>正在等待服务器响应 </div>
                    <Spin />
                    </div>
                : null}
            </div>
        })
        return (
            <div className={s.boxwrapper}>
                {statusList}
            </div>
        )
    }

    return (
        <Layout className={s.layout}>
            // 省略部分代码
            {getNotification()}
        </Layout>
}

这里使用了antd中的Progress进度条和Spinloading组件来协助展示,大致效果如下:

image

简单鉴权

既然是专属云盘,后续肯定是要部署到公网上的,为了避免被其他人误操作或刻意破坏,我们有必要加上登录和鉴权的机制,在项目的入口文件,我们添加上登录页的路由:

import React from 'react';
import ReactDom from 'react-dom';
import Com from './component/content';
import Login from './component/login';
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import StyleContext from 'isomorphic-style-loader/StyleContext';

//  : any[]
const insertCss = (...styles: any[]) => {
    const removeCss = styles.map(style => style._insertCss())
    return () => removeCss.forEach(dispose => dispose())
  }

//  挂载组件
const mountNode = document.getElementById('main');

ReactDom.render(
    <StyleContext.Provider value={{ insertCss }}>
      <Router>
        <Switch>
            //  登录页
          <Route path='/cloudDisk/login.html' component={Login} />
            //  内容页
          <Route path='/cloudDisk/disk.html' component={Com} />
          <Route path='/cloudDisk/' component={Login} />  
        </Switch>
      </Router>
    </StyleContext.Provider>,
    mountNode
);

登录页login.tsx的实现非常简单,一个输入框加摁钮即可:

import React, { useState } from 'react';
import * as s from './index.css';
import withStyles from 'isomorphic-style-loader/withStyles';
import { Button, Form, Input, message } from 'antd';
import { post, apiMap } from '@utils/api';

const FormItem = Form.Item;

function Login() {
    const [secret, setSecret] = useState('');

    const info = async function(event: React.ChangeEvent<HTMLInputElement>) {
        setSecret(event.target.value);
    }

    async function enter() {
        //  请求接口,验证身份
        const res = await post(apiMap.LOGIN, {
            password: secret
        });
        //  如果鉴权成功,在localStorage中设置token,所有的请求都会带上token以便server端的校验
        if (res.verifyResult) {
            localStorage.setItem('tk', res.accessToken);
            window.location.href='/cloudDisk/disk.html';
        } else {
            message.error('密码错误!');
        }
    }

    return (
        <div className={s.bg}>
            <div className={s.title}>欢迎进入DIY云盘</div>
            <div className={s.wrapper}>
                <Form >
                    <FormItem className={s.input}>
                        <Input.Password onChange={info}/>
                    </FormItem>
                </Form>
                <Button className={s.button} onClick={enter} type='primary'>Submit</Button>
            </div>

        </div>
    )
}

export default withStyles(s)(Login);

鉴权逻辑的核心是在登录页设置校验,登录成功后后端将返回一个token,前端将此token存放在localStorage中,之后的所有请求都会带上这个token以便后端校验,校验通过可以进行后续操作,否则返回错误码,前端强制跳转登录页。在接口请求侧,我们统一添加token,相关逻辑在api.tsx中:

import { extend } from 'umi-request';
/**
 * 配置request请求时的默认参数
 */
const qulifiedRequest = extend({
    errorHandler, // 默认错误处理
    credentials: 'include', // 默认请求是否带上cookie
});

qulifiedRequest.use(async (ctx, next) => {
    await next();
    const { res } = ctx;
    //  如果是特殊的错误码,表示鉴权失败直接跳转登录页
    if (res?.response?.status === '401') {
        notification.error({
            message: `请求错误 鉴权失败`,
            description: '鉴权失败,请重新登陆',
        });
        setTimeout(() => window.location.href='/cloudDisk/login.html', 2000,);
    }
});

//  带鉴权的接口
export const reqPost = (url: string, para: object) =>
    qulifiedRequest(localHost + url, {
        method: 'post',
        //  所有请求都带上默认token
        data: Object.assign({}, para, {
            token: localStorage.getItem('tk')
        }),
    }
);

在server端,我们需要对所有的接口请求添加默认的拦截逻辑,首先校验前端请求的token是否正确,如果不正确将返回统一的错误码,正确将继续后续的处理逻辑。在处理登录请求时,如果密码正确,server将根据当前的时间戳和秘钥生成一个token,并将其返回给前端,相关内容在app.js中:

var express=require('express');
var app =express();
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
const { verifyToken, secret, apiPrefix, errorSend, loginVerify } = require('./baseUtil');

var jwt = require('jwt-simple');

app.use(bodyParser.json());
app.use(cookieParser());
app.use(bodyParser.urlencoded({extended: true}));
//设置跨域访问
app.all('*', function(req, res, next) {
    //  省略部分内容

    const rawUrl = req.url;
    //  统一处理鉴权逻辑
    if (!pathNotVerify.includes(rawUrl)) {
        if (verifyToken(req.body)) {
            //  如果通过鉴权,下一步
            next()
        } else {
            //  否则返回特殊错误码
            errorSend(res);
        }
    } else {
        next();
    }
});

//登陆接口 
app.post(apiPrefix + '/login', async function(req,res){
    const { password } = req.body;
    const verifyObj = await loginVerify(password);
    //  如果密码正确,返回签发的token
    if (verifyObj.verifyResult) {
        res.send({
            verifyResult: true,
            //  用户请求的鉴权token,使用jwt-simple这个库生成
            accessToken: jwt.encode(Object.assign(req.body, { tokenTimeStamp: Date.now() } ), secret)
        })
    } else {
        res.send({
            verifyResult: false,
        });
    }
});

server如何验证token呢?如果token的签发时间在2个小时之内,我们就认为token有效:

const jwt = require('jwt-simple');

const outOfDatePeriod = 2 * 60 * 60 * 1000;
const verifyToken = ({token = ''}) => {
    //  根据秘钥反向解析token,获取签发时的时间戳
    const res =  token ? jwt.decode(token, secret) : {};
    return (res.tokenTimeStamp + outOfDatePeriod) > Date.now();
}

至此主体工程全部完工。

部署和持续集成

部署按照个人的习惯来就好,直接在云机器上起express也好,用nginx也好,tomcat亦可,详细细节很多,这里由于篇幅原因不再赘述,只是推荐笔者之前写的一篇新手向入门帖docker+nginx+node+jenkins从零开始部署你的前端服务,事无巨细地介绍了从云机器配置到jenkins持续集成的全流程。

参考链接

项目前端代码git地址
项目后端代码git地址
小程序云开发https api官方文档
关于点击下载文件的那些事
docker+nginx+node+jenkins从零开始部署你的前端服务
字节跳动面试官:请你实现一个大文件上传和断点续传

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,110评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,443评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,474评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,881评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,902评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,698评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,418评论 3 419
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,332评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,796评论 1 316
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,968评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,110评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,792评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,455评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,003评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,130评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,348评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,047评论 2 355