导读:本专栏主要分享同学们在 XIAOJUSURVEY&北大开源实践课程的学习成果。
详细介绍请查看:【XIAOJUSURVEY& 北大】2024 滴滴开源 XIAOJUSURVEY 北大软微开源实践课
作者:Oseast
背景
本文主要是实现下载中心的源码分析,为了实现数据的导出有以下几个基本功能:
整合问卷回收内容
获取已经整合导出的问卷列表
下载问卷到本地
后台的文件删除
这四个基本功能就对应了四个 api 请求,这四个请求实现如下分析。
一、整合问卷回收内容
要定位问卷回收内容可通过前端返回指定的问卷元数据 id 实现,因此前端向后端发送请求必须要包括surveyId
这个参数。这个参数可以在问卷分析页通过动态路由轻松获得,即:surveyId: this.$route.params.id
。
后端收到这个数据之后,控制器SurveyDownloadController
通过 mongodb 查询问卷的基本题目信息交由生产者进行加工。为了防止问卷内容过多整合时间过长导致前端一直等待返回,这里实现了一个简单的消息队列进行解耦。控制器在把任务发送到消息队列上后直接返回,后续过程由后端自动完成。isMask
为是否脱敏保存的标志 messageService 是实现的简单的消息队列 responseSchema 是问卷的基本配置。
const { surveyId, isMask } = value;
const responseSchema = await this.responseSchemaService.getResponseSchemaByPageId(surveyId);
const id= await this.surveyDownloadService.createDownload({
surveyId,
responseSchema
});
this.messageService.addMessage({
responseSchema,
surveyId,
isMask,
id,
});
return {
code: 200,
data: { message: '正在生成下载文件,请稍后查看'
},
};
复制代码
在消息队列中每加入一条任务就会唤醒一位消费者,这个消费者会将问卷信息发送给 surveyDownloadService 进行加工:
private processMessages = async (): Promise<void> => {
if (this.processing >= this.concurrency || this.queue.length === 0) {
return;
}
const messagesToProcess = Math.min(this.queue.length, this.concurrency - this.processing);
const messages = this.queue.splice(0, messagesToProcess);
this.processing += messagesToProcess;
await Promise.all(messages.map(async (message) => {
console.log(`开始计算: ${message}`);
await this.handleMessage(message);
this.emit('messageProcessed', message);
}));
this.processing -= messagesToProcess;
if (this.queue.length > 0) {
setImmediate(() => this.processMessages());
}
};
async handleMessage(message: QueueItem) {
const { surveyId, responseSchema, isMask,id } = message;
await this.surveyDownloadService.getDownloadPath({
responseSchema,
surveyId,
isMask,
id
});
}
复制代码
surveyDownloadService
先通过responseSchema
获取题目的基本信息listHead
,然后通过数据库收集对应问卷的回收数据,然后把回收表中的回答和题目对应起来并格式化,组成listBody
,再把这两个列表保存成文件存放在后端相应位置,同时数据库中创建一个对应的信息表:
async getDownloadPath({
surveyId,
responseSchema,
isMask,
id,
}: {
surveyId: string;
responseSchema: ResponseSchema;
isMask: boolean;
id: object;
}) {
const dataList = responseSchema?.code?.dataConf?.dataList || [];
const Head = getListHeadByDataList(dataList);
const listHead=this.formatHead(Head);
const dataListMap = keyBy(dataList, 'field');
const where = {
pageId: surveyId,
'curStatus.status': {
$ne: 'removed',
},
};
const [surveyResponseList, total] =
await this.surveyResponseRepository.findAndCount({
where,
order: {
createDate: -1,
},
});
const [surveyMeta] = await this.SurveyDmetaRepository.find({
where: {
surveyPath: responseSchema.surveyPath,
},
});
const listBody = surveyResponseList.map((submitedData) => {
const data = submitedData.data;
const dataKeys = Object.keys(data);
for (const itemKey of dataKeys) {
if (typeof itemKey !== 'string') {
continue;
}
if (itemKey.indexOf('data') !== 0) {
continue;
}
// 获取题目id
const itemConfigKey = itemKey.split('_')[0];
// 获取题目
const itemConfig: DataItem = dataListMap[itemConfigKey];
// 题目删除会出现,数据列表报错
if (!itemConfig) {
continue;
}
// 处理选项的更多输入框
if (
this.radioType.includes(itemConfig.type) &&
!data[`${itemConfigKey}_custom`]
) {
data[`${itemConfigKey}_custom`] =
data[`${itemConfigKey}_${data[itemConfigKey]}`];
}
// 将选项id还原成选项文案
if (
Array.isArray(itemConfig.options) &&
itemConfig.options.length > 0
) {
const optionTextMap = keyBy(itemConfig.options, 'hash');
data[itemKey] = Array.isArray(data[itemKey])
? data[itemKey]
.map((item) => optionTextMap[item]?.text || item)
.join(',')
: optionTextMap[data[itemKey]]?.text || data[itemKey];
}
}
return {
...data,
difTime: (submitedData.difTime / 1000).toFixed(2),
createDate: moment(submitedData.createDate).format(
'YYYY-MM-DD HH:mm:ss',
),
};
});
if (isMask) {
// 脱敏
listBody.forEach((item) => {
this.pluginManager.triggerHook('desensitiveData', item);
});
}
let titlesCsv = listHead.map(question => `"${question.title.replace(/<[^>]*>/g, '')}"`).join(',') + '\n';
// 获取工作区根目录的路径
const rootDir = process.cwd();
const timestamp = Date.now();
const fs = require('fs');
const path = require('path');
const filePath = join(rootDir, 'download',`${surveyMeta.owner}`, `${surveyMeta.title}_${timestamp}.csv`);
const dirPath = path.dirname(filePath);
fs.mkdirSync(dirPath, { recursive: true });
listBody.forEach(row => {
const rowValues = listHead.map(head => {
const value = row[head.field];
if (typeof value === 'string') {
// 处理字符串中的特殊字符
return `"${value.replace(/"/g, '""').replace(/<[^>]*>/g, '')}"`;
}
return `"${value}"`; // 其他类型的值(数字、布尔等)直接转换为字符串
});
titlesCsv += rowValues.join(',') + '\n';
});
const BOM = '\uFEFF';
let size = 0;
const newSurveyDownload= await this.SurveyDownloadRepository.findOne({
where: {
_id: id,
}
});
fs.writeFile(filePath, BOM + titlesCsv, { encoding: 'utf8' }, (err) => {
if (err) {
console.error('保存文件时出错:', err);
} else {
console.log('文件已保存:', filePath);
fs.stat(filePath, (err, stats) => {
if (err) {
console.error('获取文件大小时出错:', err);
} else {
console.log('文件大小:', stats.size);
size = stats.size;
const filename = `${surveyMeta.title}_${timestamp}.csv`;
const fileType = 'csv';
newSurveyDownload.pageId= surveyId,
newSurveyDownload.surveyPath=responseSchema.surveyPath,
newSurveyDownload.title=responseSchema.title,
newSurveyDownload.filePath= filePath,
newSurveyDownload.filename=filename,
newSurveyDownload.fileType=fileType,
newSurveyDownload.fileSize=String(size),
newSurveyDownload.downloadTime=String(Date.now()),
newSurveyDownload.onwer=surveyMeta.owner
newSurveyDownload.curStatus = {
status: RECORD_STATUS.NEW,
date: Date.now(),
};
this.SurveyDownloadRepository.save(newSurveyDownload);
}
});
}
});
复制代码
第一个接口的基本功能就实现成功了
二、获取已经整合导出的问卷列表
前端需要把已经导出的问卷展示给用户因此需要从后端获取这一个链表,所需要的参数为owner
,即问卷的所有者。page
和pageSize
是前端展示所需要的数据,含义为当前页和每页数据量。此接口调用surveyDownloadService.getDownloadList
方法获取下载列表,传入ownerId
、page
和pageSize
作为参数。
从服务方法返回的结果中解构出total
和listBody
。total
表示总条目数,listBody
是列表项的数组。对listBody
中的每个项进行遍历,格式化downloadTime
和fileSize
字段。
downloadTime
使用 moment 库转换为指定格式(YYYY-MM-DD HH:mm:ss)
。fileSize
根据大小自动转换为合适的单位(Bytes, KB, MB, GB, TB)
。
async downloadList(
@Query()
queryInfo: GetDownloadListDto,
@Request() req,
) {
const { value, error } = GetDownloadListDto.validate(queryInfo);
if (error) {
this.logger.error(error.message, { req });
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { ownerId, page, pageSize } = value;
const { total, listBody } =
await this.surveyDownloadService.getDownloadList({
ownerId,
page,
pageSize,
});
return {
code: 200,
data: {
total:total,
listBody:listBody.map((data) => {
const fmt = 'YYYY-MM-DD HH:mm:ss';
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
let unitIndex = 0;
let size = Number(data.fileSize);
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
data.downloadTime = moment(Number(data.downloadTime)).format(fmt);
data.fileSize = `${size.toFixed()} ${units[unitIndex]}`;
return data;
}),
},
};
}
复制代码
getDownloadList
的作用是从数据库中获取一个下载列表。
函数的主要步骤如下:基于传入的owner
和记录的当前状态 curStatus.status
不等于 REMOVED
。使用 SurveyDownloadRepository.findAndCount
方法查询数据库。这个方法同时返回满足条件的记录列表 surveyDownloadList
和总记录数 total
。查询时,指定了每页的记录数 pageSize
,根据createDate
降序,将查询到的记录列表 surveyDownloadList
映射为一个新的列表listBody
。
async getDownloadList({
ownerId,
page,
pageSize,
}: {
ownerId: string;
page: number;
pageSize: number;
}) {
const where = {
onwer: ownerId,
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},
};
const [surveyDownloadList, total] =
await this.SurveyDownloadRepository.findAndCount({
where,
take: pageSize,
skip: (page - 1) * pageSize,
order: {
createDate: -1,
},
});
const listBody = surveyDownloadList.map((data) => {
return {
_id: data._id,
filename: data.filename,
fileType: data.fileType,
fileSize: data.fileSize,
downloadTime: data.downloadTime,
curStatus: data.curStatus.status,
owner: data.onwer,
};
});
return {
total,
listBody,
};
}
复制代码
前端接受到数据后,根据 listbody 构建页面:
<el-table v-if="total"
ref="multipleListTable"
class="list-table"
:data="dataList"
empty-text="暂无数据"
row-key="_id"
header-row-class-name="tableview-header"
row-class-name="tableview-row"
cell-class-name="tableview-cell"
style="width: 100%"
v-loading="loading">
<el-table-column
v-for="field in fieldList"
:key="field.key"
:prop="field.key"
:label="field.title"
:width="field.width"
class-name="link">
</el-table-column>
<el-table-column
label="操作"
width="200">
<template v-slot="{ row }">
<el-button
type="text"
size="small"
@click="handleDownload(row)">
下载
</el-button>
<el-button
type="text"
size="small"
@click="openDeleteDialog(row)">
删除
</el-button>
<el-dialog
v-model="centerDialogVisible"
title="Warning"
width="500"
align-center>
<span>确认删除文件吗?</span>
<template #footer>
<div class="dialog-footer">
<el-button
@click="centerDialogVisible = false">
取消
</el-button>
<el-button
type="primary"
@click=confirmDelete>
确认
</el-button>
</div>
</template>
</el-dialog>
</template>
</el-table-column>
</el-table>
const dataList = computed(() => {
return (data.value as DownloadItem[]).map((item:DownloadItem) => {
if (typeof item === 'object' && item !== null) {
return {
...item,
}
}
})
})
const fieldList = computed(() => {
return map(fields, (f) => {
return get(downloadListConfig, f)
})
})
const downloadListConfig = {
filename: {
title: '文件名称',
key: 'filename',
width: 340,
tip: true
},
fileType: {
title: '格式',
key: 'fileType',
width: 200,
tip: true
},
fileSize: {
title: '预估大小',
key: 'fileSize',
width: 140,
},
downloadTime: {
title: '下载时间',
key: 'downloadTime',
width: 240
},
curStatus: {
title: '状态',
key: 'curStatus',
comp: 'StateModule'
},
}
复制代码
三、下载问卷
下载问卷的功能很简单。
首先从前端获取 owner
和 fileName
,然后使用 process.cwd()
获取当前工作目录,并组合成完整的文件路径。
使用 util.promisify
将 fs.access
转换为返回 Promise
的函数,检查文件是否存在。如果文件存在,设置响应头 Content-Type
和 Content-Disposition
,使浏览器下载文件。
创建文件读取流并将其管道到响应对象。处理文件流事件:end
事件表示文件传输完成,error
事件表示文件传输中出现错误。
如果文件不存在,捕获异常并返回 404
状态码和错误信息。
async getDownloadfileByName(
@Query() queryInfo: DownloadFileByNameDto,
@Res() res: Response,
) {
const { value, error } = DownloadFileByNameDto.validate(queryInfo);
if (error) {
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { owner, fileName } = value;
const rootDir = process.cwd(); // 获取当前工作目录
const filePath = join(rootDir, 'download', owner, fileName);
// 使用 util.promisify 将 fs.access 转换为返回 Promise 的函数
const access = util.promisify(fs.access);
try {
console.log('检查文件路径:', filePath);
await access(filePath, fs.constants.F_OK);
// 文件存在,设置响应头并流式传输文件
res.setHeader('Content-Type', 'application/octet-stream');
console.log('文件存在,设置响应头');
const encodedFileName = encodeURIComponent(fileName);
const contentDisposition = `attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`
res.setHeader('Content-Disposition', contentDisposition);
console.log('设置响应头成功,文件名:', encodedFileName);
const fileStream = fs.createReadStream(filePath);
console.log('创建文件流成功');
fileStream.pipe(res);
fileStream.on('end', () => {
console.log('文件传输完成');
});
fileStream.on('error', (streamErr) => {
console.error('文件流错误:', streamErr);
res.status(500).send('文件传输中出现错误');
});
} catch (err) {
console.error('文件不存在:', filePath);
res.status(404).send('文件不存在');
}
}
复制代码
前端在请求成功后,将响应数据转换为 Blob 对象。使用 window.URL.createObjectURL
创建一个指向 Blob 对象的 URL。创建一个 <a>
元素,并将 href
属性设置为 Blob URL,download
属性设置为文件名。将 <a>
元素添加到文档中,并模拟点击以触发下载。下载完成后,调用 window.URL.revokeObjectURL
释放创建的 Blob URL,避免内存泄漏。
const handleDownload = async (row: any) => {
if(row.curStatus == 'removed'){
ElMessage.error('文件已删除')
return
}
const fileName = row.filename;
const owner=row.owner
axios({
method: 'get',
url: '/api/survey/surveyDownload/getdownloadfileByName?fileName=' + fileName+'&owner='+owner,
responseType: 'blob', // 设置响应类型为 Blob
})
.then((response: { data: BlobPart }) => {
const blob = new Blob([response.data]);
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = fileName;
// 添加到文档中
document.body.appendChild(link);
// 模拟点击链接来触发文件下载
link.click();
// 清理资源
window.URL.revokeObjectURL(blobUrl);
})
.catch((error: any) => {
console.error('下载文件时出错:', error);
});
复制代码
四、问卷删除
这个功能也很简单,把需要删除的文件名和文件拥有者的名字发送给后端,后端直接根据这两个参数在删除文件并修改数据库就好了。
async deleteFileByName(
@Query() queryInfo: DownloadFileByNameDto,
@Res() res: Response,
) {
const { value, error } = DownloadFileByNameDto.validate(queryInfo);
if (error) {
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { owner,fileName } = value;
try {
const result = await this.surveyDownloadService.deleteDownloadFile({ owner,fileName });
// 根据 deleteDownloadFile 的返回值执行不同操作
if (result === 0) {
return res.status(404).json({
code: 404,
message: '文件状态已删除或文件不存在',
});
}
return res.status(200).json({
code: 200,
message: '文件删除成功',
data: {},
});
} catch (error) {
return res.status(500).json({
code: 500,
message: '删除文件时出错',
error: error.message,
});
}
}
}
复制代码
至此四个主要的功能全部实现了。
关于我们
感谢看到最后,我们是一个多元、包容的社区,我们已有非常多的小伙伴在共建,欢迎你的加入。
Github:XIAOJUSURVEY
社区交流群
Star
开源不易,请star一下 ❤️❤️❤️,你的支持是我们最大的动力。
评论