|
- const fs = require('fs');
- const path = require('path');
- const archiver = require('archiver');
- const axios = require('axios');
- const COS = require('cos-nodejs-sdk-v5');
- const dayjs = require('dayjs');
- const cosConfig = require('../config/cos.js');
-
- // 并发控制:同时最多执行的打包任务数
- const MAX_CONCURRENT = 2;
- let runningCount = 0;
-
- module.exports = class extends think.Service {
- /**
- * 启动任务处理循环
- */
- async startProcessing() {
- if (runningCount >= MAX_CONCURRENT) return;
- const taskModel = this.model('export_task');
- const task = await taskModel.getPendingTask();
- if (think.isEmpty(task)) return;
-
- runningCount++;
- try {
- await this.processTask(task);
- } catch (e) {
- think.logger.error(`[ExportTask] 任务 ${task.task_no} 异常:`, e);
- await taskModel.where({ id: task.id }).update({
- status: 3,
- error_log: e.message || '未知错误',
- finished_at: think.datetime(new Date())
- });
- } finally {
- runningCount--;
- // 继续处理下一个
- setTimeout(() => this.startProcessing(), 500);
- }
- }
-
- /**
- * 处理单个任务
- */
- async processTask(task) {
- const taskModel = this.model('export_task');
- const patientModel = this.model('patient');
-
- // 标记为打包中
- await taskModel.where({ id: task.id }).update({
- status: 1,
- started_at: think.datetime(new Date())
- });
-
- // 解析参数
- let fileTypes = [];
- let filterParams = {};
- try {
- fileTypes = JSON.parse(task.file_types || '[]');
- filterParams = JSON.parse(task.filter_params || '{}');
- } catch (e) {
- throw new Error('任务参数解析失败');
- }
-
- // 查询患者列表
- let patients = [];
- const exportScope = filterParams.export_scope === 'selected' ? 'selected' : 'filter';
- if (exportScope === 'selected') {
- const patientIds = Array.isArray(filterParams.patient_ids)
- ? Array.from(new Set(filterParams.patient_ids.map(id => parseInt(id, 10)).filter(Boolean)))
- : [];
- if (patientIds.length) {
- patients = await patientModel
- .where({ id: ['in', patientIds], is_deleted: 0 })
- .order('id DESC')
- .select();
- }
- } else {
- patients = await patientModel.getAll(filterParams);
- }
- if (!patients.length) {
- await taskModel.where({ id: task.id }).update({
- status: 2,
- total_files: 0,
- processed_files: 0,
- finished_at: think.datetime(new Date()),
- error_log: '没有符合条件的患者数据'
- });
- return;
- }
-
- // 收集所有需要下载的文件
- const downloadList = this._buildDownloadList(patients, fileTypes);
- const totalFiles = downloadList.reduce((sum, p) => sum + p.files.length, 0);
-
- await taskModel.where({ id: task.id }).update({ total_files: totalFiles });
-
- if (totalFiles === 0) {
- await taskModel.where({ id: task.id }).update({
- status: 2,
- processed_files: 0,
- finished_at: think.datetime(new Date()),
- error_log: '所选类型下没有可导出的附件'
- });
- return;
- }
-
- // 创建临时 ZIP 文件
- const tmpDir = path.join(think.ROOT_PATH, 'runtime/export');
- if (!fs.existsSync(tmpDir)) {
- fs.mkdirSync(tmpDir, { recursive: true });
- }
- const zipFileName = `${task.task_no}.zip`;
- const zipFilePath = path.join(tmpDir, zipFileName);
-
- // 打包
- let processedFiles = 0;
- const errors = [];
-
- await new Promise((resolve, reject) => {
- const output = fs.createWriteStream(zipFilePath);
- const archive = archiver('zip', { zlib: { level: 5 } });
-
- output.on('close', resolve);
- archive.on('error', reject);
- archive.pipe(output);
-
- // 用 Promise 链串行处理每个患者
- const processAll = async () => {
- for (const patient of downloadList) {
- for (const file of patient.files) {
- try {
- const response = await axios.get(file.url, {
- responseType: 'arraybuffer',
- timeout: 30000
- });
- archive.append(Buffer.from(response.data), {
- name: `${patient.folder}/${file.name}`
- });
- processedFiles++;
- // 每处理10个文件更新一次进度
- if (processedFiles % 10 === 0) {
- await taskModel.where({ id: task.id }).update({
- processed_files: processedFiles
- });
- }
- } catch (e) {
- errors.push(`${patient.folder}/${file.name}: ${e.message}`);
- think.logger.warn(`[ExportTask] 下载失败: ${file.url} - ${e.message}`);
- }
- }
- }
- };
-
- processAll().then(() => {
- archive.finalize();
- }).catch(reject);
- });
-
- // 更新已处理数
- await taskModel.where({ id: task.id }).update({ processed_files: processedFiles });
-
- // 上传到 COS
- const cosKey = `uploads/cytx/zip/${dayjs().format('YYYY/MM')}/${zipFileName}`;
- const fileSize = fs.statSync(zipFilePath).size;
-
- const cos = new COS({
- SecretId: cosConfig.secretId,
- SecretKey: cosConfig.secretKey
- });
-
- await new Promise((resolve, reject) => {
- cos.putObject({
- Bucket: cosConfig.bucket,
- Region: cosConfig.region,
- Key: cosKey,
- Body: fs.createReadStream(zipFilePath),
- ContentType: 'application/zip'
- }, (err, data) => {
- if (err) reject(err);
- else resolve(data);
- });
- });
-
- // 生成下载 URL
- const fileUrl = `${cosConfig.cdnUrl}/${cosKey}`;
-
- // 更新任务状态
- await taskModel.where({ id: task.id }).update({
- status: 2,
- file_url: fileUrl,
- file_size: fileSize,
- processed_files: processedFiles,
- finished_at: think.datetime(new Date()),
- error_log: errors.length ? errors.join('\n') : ''
- });
-
- // 删除本地临时文件
- try {
- if (fs.existsSync(zipFilePath)) fs.unlinkSync(zipFilePath);
- } catch (e) {
- think.logger.warn(`[ExportTask] 删除临时文件失败: ${e.message}`);
- }
-
- think.logger.info(`[ExportTask] 任务 ${task.task_no} 完成,共 ${processedFiles}/${totalFiles} 个文件,${errors.length} 个失败`);
- }
-
- /**
- * 构建下载文件列表
- */
- _buildDownloadList(patients, fileTypes) {
- const list = [];
- for (const p of patients) {
- const folder = `${p.name}_${p.patient_no}`;
- const files = [];
-
- // 实名认证照片
- if (fileTypes.includes('id_photos')) {
- if (p.id_card_front) {
- files.push({ name: `身份证人像面${this._getExt(p.id_card_front)}`, url: p.id_card_front });
- }
- if (p.id_card_back) {
- files.push({ name: `身份证国徽面${this._getExt(p.id_card_back)}`, url: p.id_card_back });
- }
- if (p.photo) {
- files.push({ name: `免冠照片${this._getExt(p.photo)}`, url: p.photo });
- }
- }
-
- // 上传资料(检查报告/诊断证明)
- if (fileTypes.includes('documents')) {
- let docs = [];
- try {
- docs = JSON.parse(p.documents || '[]');
- } catch (e) { /* ignore */ }
- docs.forEach((url, idx) => {
- if (url) {
- files.push({ name: `检查报告_${idx + 1}${this._getExt(url)}`, url });
- }
- });
- }
-
- // 签字材料
- if (fileTypes.includes('signs')) {
- if (p.sign_income) {
- files.push({ name: `个人可支配收入声明${this._getExt(p.sign_income)}`, url: p.sign_income });
- }
- if (p.sign_privacy) {
- files.push({ name: `个人信息处理同意书${this._getExt(p.sign_privacy)}`, url: p.sign_privacy });
- }
- if (p.sign_promise) {
- files.push({ name: `声明与承诺${this._getExt(p.sign_promise)}`, url: p.sign_promise });
- }
- if (p.sign_privacy_jhr) {
- files.push({ name: `监护人个人信息处理同意书${this._getExt(p.sign_privacy_jhr)}`, url: p.sign_privacy_jhr });
- }
- }
-
- // 送检信息附件(送检单照片)
- if (fileTypes.includes('sample_photos')) {
- let samplePhotos = [];
- try {
- samplePhotos = Array.isArray(p.sample_photos) ? p.sample_photos : JSON.parse(p.sample_photos || '[]');
- } catch (e) { /* ignore */ }
- samplePhotos.forEach((url, idx) => {
- if (url) {
- files.push({ name: `送检单照片_${idx + 1}${this._getExt(url)}`, url });
- }
- });
- }
-
- if (files.length) {
- list.push({ folder, files });
- }
- }
- return list;
- }
-
- /**
- * 从 URL 提取文件扩展名
- */
- _getExt(url) {
- if (!url) return '.jpg';
- const pathname = url.split('?')[0];
- const ext = path.extname(pathname);
- return ext || '.jpg';
- }
- };
|