You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

287 lines
8.7 KiB

  1. const fs = require('fs');
  2. const path = require('path');
  3. const archiver = require('archiver');
  4. const axios = require('axios');
  5. const COS = require('cos-nodejs-sdk-v5');
  6. const dayjs = require('dayjs');
  7. const cosConfig = require('../config/cos.js');
  8. // 并发控制:同时最多执行的打包任务数
  9. const MAX_CONCURRENT = 2;
  10. let runningCount = 0;
  11. module.exports = class extends think.Service {
  12. /**
  13. * 启动任务处理循环
  14. */
  15. async startProcessing() {
  16. if (runningCount >= MAX_CONCURRENT) return;
  17. const taskModel = this.model('export_task');
  18. const task = await taskModel.getPendingTask();
  19. if (think.isEmpty(task)) return;
  20. runningCount++;
  21. try {
  22. await this.processTask(task);
  23. } catch (e) {
  24. think.logger.error(`[ExportTask] 任务 ${task.task_no} 异常:`, e);
  25. await taskModel.where({ id: task.id }).update({
  26. status: 3,
  27. error_log: e.message || '未知错误',
  28. finished_at: think.datetime(new Date())
  29. });
  30. } finally {
  31. runningCount--;
  32. // 继续处理下一个
  33. setTimeout(() => this.startProcessing(), 500);
  34. }
  35. }
  36. /**
  37. * 处理单个任务
  38. */
  39. async processTask(task) {
  40. const taskModel = this.model('export_task');
  41. const patientModel = this.model('patient');
  42. // 标记为打包中
  43. await taskModel.where({ id: task.id }).update({
  44. status: 1,
  45. started_at: think.datetime(new Date())
  46. });
  47. // 解析参数
  48. let fileTypes = [];
  49. let filterParams = {};
  50. try {
  51. fileTypes = JSON.parse(task.file_types || '[]');
  52. filterParams = JSON.parse(task.filter_params || '{}');
  53. } catch (e) {
  54. throw new Error('任务参数解析失败');
  55. }
  56. // 查询患者列表
  57. let patients = [];
  58. const exportScope = filterParams.export_scope === 'selected' ? 'selected' : 'filter';
  59. if (exportScope === 'selected') {
  60. const patientIds = Array.isArray(filterParams.patient_ids)
  61. ? Array.from(new Set(filterParams.patient_ids.map(id => parseInt(id, 10)).filter(Boolean)))
  62. : [];
  63. if (patientIds.length) {
  64. patients = await patientModel
  65. .where({ id: ['in', patientIds], is_deleted: 0 })
  66. .order('id DESC')
  67. .select();
  68. }
  69. } else {
  70. patients = await patientModel.getAll(filterParams);
  71. }
  72. if (!patients.length) {
  73. await taskModel.where({ id: task.id }).update({
  74. status: 2,
  75. total_files: 0,
  76. processed_files: 0,
  77. finished_at: think.datetime(new Date()),
  78. error_log: '没有符合条件的患者数据'
  79. });
  80. return;
  81. }
  82. // 收集所有需要下载的文件
  83. const downloadList = this._buildDownloadList(patients, fileTypes);
  84. const totalFiles = downloadList.reduce((sum, p) => sum + p.files.length, 0);
  85. await taskModel.where({ id: task.id }).update({ total_files: totalFiles });
  86. if (totalFiles === 0) {
  87. await taskModel.where({ id: task.id }).update({
  88. status: 2,
  89. processed_files: 0,
  90. finished_at: think.datetime(new Date()),
  91. error_log: '所选类型下没有可导出的附件'
  92. });
  93. return;
  94. }
  95. // 创建临时 ZIP 文件
  96. const tmpDir = path.join(think.ROOT_PATH, 'runtime/export');
  97. if (!fs.existsSync(tmpDir)) {
  98. fs.mkdirSync(tmpDir, { recursive: true });
  99. }
  100. const zipFileName = `${task.task_no}.zip`;
  101. const zipFilePath = path.join(tmpDir, zipFileName);
  102. // 打包
  103. let processedFiles = 0;
  104. const errors = [];
  105. await new Promise((resolve, reject) => {
  106. const output = fs.createWriteStream(zipFilePath);
  107. const archive = archiver('zip', { zlib: { level: 5 } });
  108. output.on('close', resolve);
  109. archive.on('error', reject);
  110. archive.pipe(output);
  111. // 用 Promise 链串行处理每个患者
  112. const processAll = async () => {
  113. for (const patient of downloadList) {
  114. for (const file of patient.files) {
  115. try {
  116. const response = await axios.get(file.url, {
  117. responseType: 'arraybuffer',
  118. timeout: 30000
  119. });
  120. archive.append(Buffer.from(response.data), {
  121. name: `${patient.folder}/${file.name}`
  122. });
  123. processedFiles++;
  124. // 每处理10个文件更新一次进度
  125. if (processedFiles % 10 === 0) {
  126. await taskModel.where({ id: task.id }).update({
  127. processed_files: processedFiles
  128. });
  129. }
  130. } catch (e) {
  131. errors.push(`${patient.folder}/${file.name}: ${e.message}`);
  132. think.logger.warn(`[ExportTask] 下载失败: ${file.url} - ${e.message}`);
  133. }
  134. }
  135. }
  136. };
  137. processAll().then(() => {
  138. archive.finalize();
  139. }).catch(reject);
  140. });
  141. // 更新已处理数
  142. await taskModel.where({ id: task.id }).update({ processed_files: processedFiles });
  143. // 上传到 COS
  144. const cosKey = `uploads/cytx/zip/${dayjs().format('YYYY/MM')}/${zipFileName}`;
  145. const fileSize = fs.statSync(zipFilePath).size;
  146. const cos = new COS({
  147. SecretId: cosConfig.secretId,
  148. SecretKey: cosConfig.secretKey
  149. });
  150. await new Promise((resolve, reject) => {
  151. cos.putObject({
  152. Bucket: cosConfig.bucket,
  153. Region: cosConfig.region,
  154. Key: cosKey,
  155. Body: fs.createReadStream(zipFilePath),
  156. ContentType: 'application/zip'
  157. }, (err, data) => {
  158. if (err) reject(err);
  159. else resolve(data);
  160. });
  161. });
  162. // 生成下载 URL
  163. const fileUrl = `${cosConfig.cdnUrl}/${cosKey}`;
  164. // 更新任务状态
  165. await taskModel.where({ id: task.id }).update({
  166. status: 2,
  167. file_url: fileUrl,
  168. file_size: fileSize,
  169. processed_files: processedFiles,
  170. finished_at: think.datetime(new Date()),
  171. error_log: errors.length ? errors.join('\n') : ''
  172. });
  173. // 删除本地临时文件
  174. try {
  175. if (fs.existsSync(zipFilePath)) fs.unlinkSync(zipFilePath);
  176. } catch (e) {
  177. think.logger.warn(`[ExportTask] 删除临时文件失败: ${e.message}`);
  178. }
  179. think.logger.info(`[ExportTask] 任务 ${task.task_no} 完成,共 ${processedFiles}/${totalFiles} 个文件,${errors.length} 个失败`);
  180. }
  181. /**
  182. * 构建下载文件列表
  183. */
  184. _buildDownloadList(patients, fileTypes) {
  185. const list = [];
  186. for (const p of patients) {
  187. const folder = `${p.name}_${p.patient_no}`;
  188. const files = [];
  189. // 实名认证照片
  190. if (fileTypes.includes('id_photos')) {
  191. if (p.id_card_front) {
  192. files.push({ name: `身份证人像面${this._getExt(p.id_card_front)}`, url: p.id_card_front });
  193. }
  194. if (p.id_card_back) {
  195. files.push({ name: `身份证国徽面${this._getExt(p.id_card_back)}`, url: p.id_card_back });
  196. }
  197. if (p.photo) {
  198. files.push({ name: `免冠照片${this._getExt(p.photo)}`, url: p.photo });
  199. }
  200. }
  201. // 上传资料(检查报告/诊断证明)
  202. if (fileTypes.includes('documents')) {
  203. let docs = [];
  204. try {
  205. docs = JSON.parse(p.documents || '[]');
  206. } catch (e) { /* ignore */ }
  207. docs.forEach((url, idx) => {
  208. if (url) {
  209. files.push({ name: `检查报告_${idx + 1}${this._getExt(url)}`, url });
  210. }
  211. });
  212. }
  213. // 签字材料
  214. if (fileTypes.includes('signs')) {
  215. if (p.sign_income) {
  216. files.push({ name: `个人可支配收入声明${this._getExt(p.sign_income)}`, url: p.sign_income });
  217. }
  218. if (p.sign_privacy) {
  219. files.push({ name: `个人信息处理同意书${this._getExt(p.sign_privacy)}`, url: p.sign_privacy });
  220. }
  221. if (p.sign_promise) {
  222. files.push({ name: `声明与承诺${this._getExt(p.sign_promise)}`, url: p.sign_promise });
  223. }
  224. if (p.sign_privacy_jhr) {
  225. files.push({ name: `监护人个人信息处理同意书${this._getExt(p.sign_privacy_jhr)}`, url: p.sign_privacy_jhr });
  226. }
  227. }
  228. // 送检信息附件(送检单照片)
  229. if (fileTypes.includes('sample_photos')) {
  230. let samplePhotos = [];
  231. try {
  232. samplePhotos = Array.isArray(p.sample_photos) ? p.sample_photos : JSON.parse(p.sample_photos || '[]');
  233. } catch (e) { /* ignore */ }
  234. samplePhotos.forEach((url, idx) => {
  235. if (url) {
  236. files.push({ name: `送检单照片_${idx + 1}${this._getExt(url)}`, url });
  237. }
  238. });
  239. }
  240. if (files.length) {
  241. list.push({ folder, files });
  242. }
  243. }
  244. return list;
  245. }
  246. /**
  247. * 从 URL 提取文件扩展名
  248. */
  249. _getExt(url) {
  250. if (!url) return '.jpg';
  251. const pathname = url.split('?')[0];
  252. const ext = path.extname(pathname);
  253. return ext || '.jpg';
  254. }
  255. };