'use strict'; const superagent = require('superagent'); const { spawn } = require('child_process'); const stream = require('stream'); const path = require('path'); const moment = require('moment'); module.exports.getTenderList = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { page, pageSize, userId } = ctx.request.query; const where = {}; if (userId) { where.userId = userId; } // 处理分页 const options = { where, order: [['updateAt', 'DESC']], raw: true, }; if (page && pageSize) { options.offset = (page - 1) * pageSize; options.limit = parseInt(pageSize); } const tenderList = await models.Tender.findAndCountAll(options); ctx.body = tenderList; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '获取标书列表失败' }; } } module.exports.getTenderDetail = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const tenderId = ctx.params.tenderId; if (!tenderId) { throw '缺少参数' }; const tender = await models.Tender.findOne({ where: { id: tenderId }, include: [ { model: models.TenderDirectory, } ], }); ctx.body = tender; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '获取标书详情失败' }; } } module.exports.addTender = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { name, fileUrl, onlyTechnicalPapers, userId, curIp } = ctx.request.body; if (!name || !fileUrl) { throw '缺少参数' }; const tender = await models.Tender.create( { name, fileUrl, status: 'pending', userId }, { returning: true } ); await models.AiQueryRecord.create({ userId: userId, ipAddress: curIp, clientId: 'pep-feixiaoshang', feature: '标书生成', time: moment(), }); ctx.body = tender; ctx.status = 200; // 执行生成目录任务,不影响此接口返回 generationDirectory(ctx, tender.id, fileUrl, onlyTechnicalPapers); } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '新增标书失败' }; } } module.exports.retryGenerationDirectory = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const id = ctx.params.tenderId; if (!id) { throw '缺少参数' }; const tender = await models.Tender.update( { status: 'pending' }, { where: { id }, returning: true } ); ctx.body = tender; ctx.status = 200; // 执行生成目录任务,不影响此接口返回 generationDirectory(ctx, id, tender[1][0].fileUrl, tender[1][0].onlyTechnicalPapers); } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '重新生成目录失败' }; } } async function generationDirectory(ctx, tenderId, fileUrl, onlyTechnicalPapers = false) { const transaction = await ctx.app.fs.dc.orm.transaction(); const { models } = ctx.app.fs.dc; try { const { tenderAppKey } = ctx.app.fs.config.fastGpt; const res = await superagent .post(`${ctx.app.fs.config.fastGpt.apiUrl}/api/v1/chat/completions`) .send({ "stream": false, "detail": false, "variables": { "generation_type": onlyTechnicalPapers ? '技术文件目录生成' : '目录生成' }, "messages": [ { "role": "user", "content": [ { "type": "file_url", "name": "文件", "url": fileUrl } ] } ] }) .set({ Authorization: `Bearer ${tenderAppKey}`, "Content-Type": "application/json", }) // .timeout(1000 * 60 * 24) .timeout({ response: 1000 * 60 * 24, // 等待服务器发送数据的超时时间(毫秒) deadline: 1000 * 60 * 24, // 整个请求完成的最大时间(毫秒) }).then(async res => { const directoryMd = res.body.choices[0].message.content; await models.Tender.update( { status: 'success' }, { where: { id: tenderId }, transaction } ); await parseAndInsertDirectories(directoryMd, models, transaction, tenderId); transaction.commit(); }, async error => { await models.Tender.update( { status: 'fail' }, { where: { id: tenderId } } ); ctx.logger.log(error); await transaction.rollback(); }) // const directoryMd = res.body.choices[0].message.content; // await new Promise((resolve) => { setTimeout(resolve, 5000) }); // const directoryMd = "\n# 1. 投标书 \n# 2. 开标一览表 \n# 3. 分项报价表 \n# 4. 开标一览明细表 \n# 5. 服务标准及要求响应/偏离表 \n# 6. 商务条款响应/偏离表 \n# 7. 投标人应当提交的资格证明文件 \n## 7.1 江西省政府采购供应商资格信用承诺函 \n## 7.2 法定代表人授权书 \n## 7.3 投标人的资格声明 \n## 7.4 投标保证金凭证 \n## 7.5 制造商出具的授权函 \n## 7.6 联合体协议 \n## 7.7 本项目的特定资格证明材料 \n# 8. 为落实政府采购政策投标人须提供的证明材料 \n## 8.1 中小企业声明(服务) \n## 8.2 省级以上监狱管理局、戒毒管理局出具的属于监狱企业证明文件 \n## 8.3 残疾人福利性单位声明函 \n## 8.4 节能产品认证证书 \n# 9. 技术文件 \n## 9.1 对项目的理解 \n### 9.1.1 采购需求及项目背景 \n#### 9.1.1.1 采购需求 \n#### 9.1.1.2 建设背景 \n#### 9.1.1.3 建设目标 \n### 9.1.2 实施内容理解 \n#### 9.1.2.1 升级数据资源中心 \n##### 9.1.2.1.1 数据目录系统升级 \n##### 9.1.2.1.2 数据库管控 \n##### 9.1.2.1.3 数据治理系统升级 \n###### 9.1.2.1.3.1 数据处理系统 \n###### 9.1.2.1.3.2 数据标准管理 \n###### 9.1.2.1.3.3 数据质量管理 \n###### 9.1.2.1.3.4 元数据管理 \n###### 9.1.2.1.3.5 数据建模管理 \n###### 9.1.2.1.3.6 标签管理 \n###### 9.1.2.1.3.7 算法开发 \n#### 9.1.2.1.4 数据服务平台 \n#### 9.1.2.1.5 数据技术规范 \n#### 9.1.2.1.6 数据库能力 \n#### 9.1.2.2 技术支撑中心 \n##### 9.1.2.2.1 能力组件中心 \n##### 9.1.2.2.2 能力开放中心 \n##### 9.1.2.2.3 安全管控中心 \n##### 9.1.2.2.4 能力技术规范 \n#### 9.1.2.3 一体化服务门户 \n##### 9.1.2.3.1 工作门户 \n#### 9.1.2.4 国产化适配 \n### 9.1.3 满足采购需求外的技术功能和软件产品能力 \n#### 9.1.3.1 技术功能 \n#### 9.1.3.2 软件产品能力 \n## 9.2 工作规划描述 \n### 9.2.1 需求分析 \n### 9.2.2 总体设计 \n### 9.2.3 详细设计 \n### 9.2.4 软件实现 \n### 9.2.5 软件测试 \n### 9.2.6 用户培训 \n### 9.2.7 系统上线 \n### 9.2.8 项目验收 \n## 9.3 项目测试方案 \n### 9.3.1 测试目标和原则 \n### 9.3.2 测试内容 \n### 9.3.3 测试程序 \n## 9.4 质量保障 \n### 9.4.1 质量保障体系 \n### 9.4.2 质量保障措施 \n### 9.4.3 阶段评审 \n### 9.4.4 日常检查 \n### 9.4.5 安装维护检查 \n### 9.4.6 文档 \n### 9.4.7 软件系统、设备的集成、调试质量控制 \n## 9.5 项目管理机构及人员 \n### 9.5.1 项目管理组织机构 \n### 9.5.2 项目人员配置及分工 \n### 9.5.3 岗位职责及工作界面划分 \n## 9.6 培训计划和培训方案 \n### 9.6.1 培训计划 \n### 9.6.2 培训方案 \n#### 9.6.2.1 培训目标 \n#### 9.6.2.2 培训内容 \n#### 9.6.2.3 培训质量保证措施 \n## 9.7 售后服务与支撑 \n### 9.7.1 售后服务承诺 \n### 9.7.2 运维服务承诺 \n### 9.7.3 服务人员承诺 \n## 9.8 知识产权 \n## 9.9 项目保密 \n# 10. 其他资料" } catch (error) { // await transaction.rollback(); ctx.logger.log(error); try { await models.Tender.update( { status: 'fail' }, { where: { id: tenderId } } ); } catch (err) { console.log(`修改状态失败,tender.id=${tenderId}`); ctx.logger.log(err); } } } // 解析并插入目录树 async function parseAndInsertDirectories(input, models, transaction, tenderId) { // 删除原目录 await models.TenderDirectory.destroy({ where: { tenderId }, transaction }); // 1. 解析输入字符串为树形结构 const lines = input.split('\n').filter(line => line.trim() !== ''); const tree = []; const stack = []; for (const line of lines) { // 提取编号和名称 const match = line.match(/^(#+)\s+([\d.]+\s+.*)/); if (!match) continue; const level = match[1].length; // 计算层级 const fullName = match[2].trim(); // 获取完整名称(包括编号) // 构建当前节点 const node = { level, fullName, children: [] }; // 根据层级插入到树中 while (stack.length > 0 && stack[stack.length - 1].level >= level) { stack.pop(); // 回退到上一级 } if (stack.length === 0) { tree.push(node); // 如果栈为空,说明是根节点 } else { stack[stack.length - 1].children.push(node); // 否则作为子节点添加 } stack.push(node); // 当前节点入栈 } // 2. 递归插入目录树到数据库 async function insertNode(node, parentId = null, order = 1) { // 插入当前节点 const directory = await models.TenderDirectory.create({ tenderId, name: node.fullName, // 使用完整名称(包含编号) parentId, order, }, { transaction }); // 插入子节点 for (let i = 0; i < node.children.length; i++) { await insertNode(node.children[i], directory.id, i + 1); // 子节点的 order 从 1 开始 } } // 遍历树并插入所有节点 for (let i = 0; i < tree.length; i++) { await insertNode(tree[i], null, i + 1); // 根节点的 order 从 1 开始 } } module.exports.putTender = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const tenderId = ctx.params.tenderId; const body = ctx.request.body; if (!tenderId) { throw '缺少参数' }; await models.Tender.update( { ...body }, { where: { id: tenderId } } ); ctx.status = 204; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '修改标书失败' }; } } module.exports.delTender = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models } = ctx.app.fs.dc; const tenderId = ctx.params.tenderId; if (!tenderId) { throw '缺少参数' }; await models.TenderDirectory.destroy({ where: { tenderId }, transaction }); await models.Tender.destroy({ where: { id: tenderId }, transaction }); ctx.status = 204; await transaction.commit(); } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '删除标书失败' }; } } module.exports.exportTender = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const tenderId = ctx.params.tenderId; if (!tenderId) { throw '缺少参数' }; const tender = await models.Tender.findOne({ where: { id: tenderId }, attributes: ['id', 'name'], raw: true, }); const tenderDirectories = await models.TenderDirectory.findAll({ where: { tenderId }, attributes: ['id', 'name', 'content', 'parentId', 'order'], raw: true, }); const tree = buildTree(tenderDirectories); const mdContent = generateMdFromTree(tree); // 调用 Pandoc 将 Markdown 字符串转换为 DOCX const pandoc = spawn('pandoc', [ '-f', 'markdown', // -f markdown: 指定输入格式为 markdown '-t', 'docx', // 指定输出格式为 docx '--toc', // 生成目录 '--metadata', 'toc-title=目录', // 元数据:目录名 '--reference-doc', path.resolve(__dirname, '../static/custom-reference.docx'), // 格式参考文档 '-o', '-' // 指定输出到 stdout (标准输出) ]); // 创建一个可写流来捕获 Pandoc 的 stdout let docxBuffers = []; pandoc.stdout.on('data', (data) => { docxBuffers.push(data); }); // 处理 Pandoc 进程的错误 let pandocError = ''; pandoc.stderr.on('data', (data) => { pandocError += data.toString(); }); // 将 Markdown 字符串写入 Pandoc 的 stdin const stdinStream = new stream.Readable(); stdinStream.push(mdContent); stdinStream.push(null); // 表示输入结束 stdinStream.pipe(pandoc.stdin); // 等待 Pandoc 进程结束 await new Promise((resolve, reject) => { pandoc.on('close', (code) => { if (code !== 0) { console.error('Pandoc stderr:', pandocError); reject(new Error(`Pandoc exited with code ${code}. Error: ${pandocError}`)); } else { resolve(); } }); pandoc.on('error', (err) => { console.error('Failed to start Pandoc process:', err); reject(err); }); }); const docxBuffer = Buffer.concat(docxBuffers); // 设置响应头,告诉浏览器这是一个 DOCX 文件并应该下载 const fileName = encodeURI(`${tender.name}.docx`); ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); ctx.set('Content-Disposition', `attachment; filename="${fileName}"`); ctx.set('Content-Length', docxBuffer.length.toString()); ctx.body = docxBuffer; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '导出标书失败' }; } } function buildTree(items) { const map = {}; const tree = []; // 构建 ID 到节点的映射 items.forEach(item => { map[item.id] = { ...item, children: [] }; }); // 构建树结构 items.forEach(item => { const node = map[item.id]; if (item.parentId && map[item.parentId]) { map[item.parentId].children.push(node); } else if (!item.parentId) { tree.push(node); } }); // 按 order 排序 function sortNode(node) { node.children.sort((a, b) => a.order - b.order); node.children.forEach(sortNode); } tree.sort((a, b) => a.order - b.order); tree.forEach(sortNode); return tree; } function generateMdFromTree(tree, level = 1) { let mdContent = ''; function traverse(node, currentLevel) { const heading = '#'.repeat(currentLevel); mdContent += `${heading} ${node.name}\n${node.content || ''}\n\n`; if (node.children.length > 0) { node.children.forEach(child => traverse(child, currentLevel + 1)); } } tree.forEach(node => traverse(node, level)); return mdContent.trim(); } module.exports.contentGenerate = async (ctx, next) => { try { const { tenderAppKey } = ctx.app.fs.config.fastGpt; const { models, orm: sequelize } = ctx.app.fs.dc; const { tenderId } = ctx.params; const { directoryId, fileUrl } = ctx.request.body; if (!tenderId || !directoryId || !fileUrl) { throw '缺少请求参数' }; let text = ''; const result = await sequelize.query(` WITH RECURSIVE parent_tree AS ( SELECT id, name, parent_id FROM tender_directory WHERE id = ${directoryId} UNION ALL SELECT d.id, d.name, d.parent_id FROM tender_directory d INNER JOIN parent_tree pt ON d.id = pt.parent_id ) SELECT string_agg(name, '-' ORDER BY id ASC) AS path FROM parent_tree; `, { type: sequelize.QueryTypes.SELECT }); if (result.length > 0) { text = '章节:' + result[0].path; } else { throw '章节查询错误'; } const curDirectory = await models.TenderDirectory.findOne({ where: { id: directoryId, tenderId }, attributes: ['chapterRequirements', 'chapterWords'], raw: true }); let variables = {}; if (curDirectory?.chapterRequirements) { text = text + '\n章节需求:' + curDirectory.chapterRequirements; } if (typeof curDirectory?.chapterWords === 'number') { variables = { word_count_requirement: curDirectory.chapterWords }; } const res = await superagent .post(`${ctx.app.fs.config.fastGpt.apiUrl}/api/v1/chat/completions`) .send({ "stream": false, "detail": false, "variables": variables, "messages": [ { "role": "user", "content": [ { "type": "file_url", "name": "文件", "url": fileUrl }, { "type": "text", "text": text } ] } ] }) .set({ Authorization: `Bearer ${tenderAppKey}`, "Content-Type": "application/json", }) const contentMd = res.body.choices[0].message.content; await models.TenderDirectory.update( { content: contentMd }, { where: { id: directoryId } } ); ctx.body = contentMd; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '章节内容生成失败' }; } } // 内容润色 module.exports.contentEmbellish = async (ctx, next) => { try { const { tenderAppKey } = ctx.app.fs.config.fastGpt; const { tenderId } = ctx.params; const { directoryId, fileUrl, selectedText, embellishDemand } = ctx.request.body; if (!tenderId || !directoryId || !fileUrl || !selectedText || !embellishDemand) { throw '缺少请求参数' }; const res = await superagent .post(`${ctx.app.fs.config.fastGpt.apiUrl}/api/v1/chat/completions`) .send({ "stream": false, "detail": false, "variables": { "generation_type": '章节内容修改' }, "messages": [ { "role": "user", "content": [ { "type": "file_url", "name": "文件", "url": fileUrl }, { "type": "text", "text": `待优化段落:\n${selectedText}\n\n用户需求:\n${embellishDemand}\n` } ] } ] }) .set({ Authorization: `Bearer ${tenderAppKey}`, "Content-Type": "application/json", }) const contentMd = res.body.choices[0].message.content; ctx.body = contentMd; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '章节文字润色失败' }; } } module.exports.addTenderDirectory = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { tenderId } = ctx.params; const body = ctx.request.body; if (!tenderId || !body) { throw '缺少参数' }; await models.TenderDirectory.create(body); ctx.status = 204; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '添加章节失败' }; } } // 修改节点顺序 async function moveNode(ctx, tenderId, id, direction, transaction) { const { models } = ctx.app.fs.dc; // 1. 获取目标节点的父节点 ID 和当前顺序 const targetNode = await models.TenderDirectory.findByPk(id, { attributes: ["parentId", "order"], }); if (!targetNode) { throw new Error(`Node with id ${id} not found`); } const { parentId, order: currentOrder } = targetNode; // 2. 获取同级节点(根据 parentId 分组) const siblings = await models.TenderDirectory.findAll({ attributes: ["id", "order"], where: { parentId, tenderId, }, order: [["order", "ASC"]], raw: true, }); // 找到目标节点在同级节点中的索引 const targetIndex = siblings.findIndex(node => node.id == id); if (targetIndex === -1) { throw new Error(`Node with id ${id} not found in siblings`); } // 3. 根据方向计算交换的目标节点 let swapIndex; if (direction === "up") { if (targetIndex === 0) { console.log("Already at the top, cannot move up further."); return; // 已经是第一个节点,无法继续上移 } swapIndex = targetIndex - 1; // 上移:与前一个节点交换顺序 } else if (direction === "down") { if (targetIndex === siblings.length - 1) { console.log("Already at the bottom, cannot move down further."); return; // 已经是最后一个节点,无法继续下移 } swapIndex = targetIndex + 1; // 下移:与后一个节点交换顺序 } else { throw new Error("Invalid direction. Use 'up' or 'down'."); } // 4. 获取需要交换顺序的节点 const swapNode = siblings[swapIndex]; // 5. 交换两个节点的顺序值 await models.TenderDirectory.update( { order: swapNode.order }, { where: { id }, transaction } ); await models.TenderDirectory.update( { order: currentOrder }, { where: { id: swapNode.id }, transaction } ); console.log(`Moved node ${id} ${direction}`); } module.exports.putTenderDirectory = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models } = ctx.app.fs.dc; const { tenderId, directoryId } = ctx.params; const body = ctx.request.body; if (!tenderId || !directoryId) { throw '缺少参数' }; if (body.direction) { await moveNode(ctx, tenderId, directoryId, body.direction, transaction); } else { await models.TenderDirectory.update( { ...body }, { where: { id: directoryId, tenderId }, transaction } ); } ctx.status = 204; await transaction.commit(); } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '修改章节失败' }; } } module.exports.delTenderDirectory = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const Op = ctx.app.fs.dc.ORM.Op; const { tenderId, directoryId } = ctx.params; if (!tenderId || !directoryId) { throw '缺少参数' }; // 递归删除所有子章节 async function getChildIds(parentId, allChildIds = []) { const children = await models.TenderDirectory.findAll({ attributes: ["id"], // 只查询 id 字段 where: { parentId }, }); if (children.length > 0) { const childIds = children.map(child => child.id); allChildIds.push(...childIds); // 对每个子节点递归查找其子节点 for (const childId of childIds) { await getChildIds(childId, allChildIds); } } return allChildIds; } // 获取目标节点及其所有子节点的 ID const allIdsToDelete = [Number(directoryId)]; // 包含目标节点本身 const childIds = await getChildIds(directoryId); allIdsToDelete.push(...childIds); console.log(allIdsToDelete, 'allIdsToDelete') await models.TenderDirectory.destroy({ where: { id: { [Op.in]: allIdsToDelete }, tenderId }, }); ctx.status = 204; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '删除章节失败' }; } }