【XIAOJUSURVEY& 北大】实现数据导出的前后端全流程

88 阅读5分钟

导读:本专栏主要分享同学们在XIAOJUSURVEY&北大开源实践课程的学习成果。

专栏背景:【XIAOJUSURVEY& 北大】2024 滴滴开源 XIAOJUSURVEY 北大软微开源实践课

文内项目Github:XIAOJUSURVEY

作者:Oseast

背景

本文主要是实现下载中心的源码分析,为了实现数据的导出有以下几个基本功能:

  1. 整合问卷回收内容
  2. 获取已经整合导出的问卷列表
  3. 下载问卷到本地
  4. 后台的文件删除

这四个基本功能就对应了四个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,即问卷的所有者。pagepageSize是前端展示所需要的数据,含义为当前页和每页数据量。此接口调用surveyDownloadService.getDownloadList方法获取下载列表,传入ownerIdpagepageSize作为参数。

从服务方法返回的结果中解构出totallistBodytotal表示总条目数,listBody是列表项的数组。对listBody中的每个项进行遍历,格式化downloadTimefileSize字段。

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'
  },
}

三、下载问卷

下载问卷的功能很简单。

首先从前端获取 ownerfileName,然后使用 process.cwd() 获取当前工作目录,并组合成完整的文件路径。

使用 util.promisifyfs.access 转换为返回 Promise 的函数,检查文件是否存在。如果文件存在,设置响应头 Content-TypeContent-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一下 ❤️❤️❤️,你的支持是我们最大的动力。