前言
之前在开发微信小程序的时候,发现官方给每个小程序分配了5g的免费云存储空间和每个月5g的cdn流量(免费版):
在小程序的开发后台可以查看云存储上的文件,文件本质上是存在cdn上的,每个文件都提供了专属的downLoad url,靠着这个url我们就可以下载部署在云端的文件,也就是说上传的文件自带cdn加速。
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):
当需要对文件进行多选时,通过
setCheckList
来控制当前选中的文件列表,通过文件的唯一_id
来标识不同的文件。具体可进行下载或者删除等操作。const [fileList, setFList] = useReducer(listReducer, []);
来控制文件列表状态。useEffect
配合空数组做参数进行fileList
的初始化(可以类比传统class component的componentDidMount方法),向服务器请求文件列表,将内容解析后通过antd
的table
组件渲染在页面上,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
}
实现效果大致这样:
关于文件,延伸出来的内容不少,我有另外一篇文章进行了比较细致的分析,感兴趣的朋友可以移步关于点击下载文件的那些事
增加上传进度条,优化体验
作为一个合格的网盘,没有上传进度条体验是很糟糕的。那么如何实现呢?显然,在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
进度条和Spin
loading组件来协助展示,大致效果如下:
简单鉴权
既然是专属云盘,后续肯定是要部署到公网上的,为了避免被其他人误操作或刻意破坏,我们有必要加上登录和鉴权的机制,在项目的入口文件,我们添加上登录页的路由:
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从零开始部署你的前端服务
字节跳动面试官:请你实现一个大文件上传和断点续传