'use strict'; const qiniu = require('qiniu'); const moment = require('moment'); const XLSX = require('xlsx-js-style'); const superagent = require('superagent'); const { convertHTMLToDOCX } = require('../utils/tools'); const getQwenUsage = (body = {}) => { const usage = body?.usage || {}; const inputTokens = Number(usage.prompt_tokens || usage.input_tokens || 0); const outputTokens = Number(usage.completion_tokens || usage.output_tokens || 0); const totalTokens = Number(usage.total_tokens || inputTokens + outputTokens || 0); return { inputTokens: Math.max(0, Math.floor(inputTokens)), outputTokens: Math.max(0, Math.floor(outputTokens)), totalTokens: Math.max(0, Math.floor(totalTokens)), }; }; const reportQuoteOptimizeUsage = async (ctx, { body, runningTime, quoteData }) => { const usage = getQwenUsage(body); if (!usage.totalTokens) return; const centerRequest = ctx?.app?.fs?.centerRequest; if (!centerRequest?.post) { ctx.logger?.warn?.('ai-center token统计接口未初始化,跳过引用优化token上报'); return; } try { await centerRequest.post('/chatLogs/report', { body: { app_id: 'ai-query', chat_item_data_id: String(quoteData?._id || quoteData?.id || ''), module_type: 'quote_optimize', fastgpt_source: 'qwen', origin_ip: ctx.ip || '', running_time: runningTime, duration_seconds: runningTime, input_tokens: usage.inputTokens, output_tokens: usage.outputTokens, total_tokens: usage.totalTokens, source_platform: 'ai-query', source_key: 'aichat_quote_optimize', model: body?.model || 'qwen3.6-flash' } }); } catch (error) { ctx.logger?.error?.('引用优化token上报失败:', error); } }; //[转发知识库列表] module.exports.getKnowledgeList = async (ctx, next) => { try { const { header, query } = ctx; const token = query.token; const {knowledgeUrl} = ctx.config.aiCenterUrl; const requestQuery = { page: query.page || 1, pageSize: query.pageSize || 12, parentId: query.parentId === undefined ? 'null' : query.parentId, }; if (query.keyword) requestQuery.keyword = query.keyword; if (query.search) requestQuery.search = query.search; if (query.userId) requestQuery.userId = query.userId; if (query.type) requestQuery.type = query.type; const res = await superagent .get(knowledgeUrl) .set('Accept', 'application/json') .set('Content-Type', 'application/json') .set('token', token) .query(requestQuery); ctx.status = res.status || 200; ctx.body = res.body; } catch (error) { const status = error?.status || error?.response?.status || 400; const body = error?.response?.body; const text = error?.response?.text; ctx.logger.error('转发知识库列表失败:', error); ctx.status = status; ctx.body = body || { message: text || error.message || '获取知识库列表失败', }; } }; module.exports.downloadScheme = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { qiniu: { dmn: domain, bkt: bucket, ak: accessKey, sk: secretKey } } = ctx.config; const { content, dataId, OS } = ctx.request.body; if (!content || !dataId) { throw '缺少参数' }; if (OS === 'android' || OS === 'ios') { let result; const cache = await models.ChatSchemeFileCache.findOne({ where: { dataId }, raw: true }); if (cache) { result = cache; } else { const docxBuffer = await convertHTMLToDOCX(content); // 上传七牛云 const mac = new qiniu.auth.digest.Mac(accessKey, secretKey); const options = { scope: bucket }; const putPolicy = new qiniu.rs.PutPolicy(options); const uploadToken = putPolicy.uploadToken(mac); const formUploader = new qiniu.form_up.FormUploader(); const putExtra = new qiniu.form_up.PutExtra(); const key = `ai-query/chat-schema/${moment().format('YYYYMMDDHHmmss')}/方案-${dataId}.docx`; const config = new qiniu.conf.Config(); config.zone = qiniu.zone.Zone_z0; const uploadResult = await new Promise((resolve, reject) => { formUploader.put(uploadToken, key, docxBuffer, putExtra, (respErr, respBody, respInfo) => { if (respErr) { reject(respErr); } else { resolve(respBody); } }) }) // 保存到数据库 const { key: qiniuKey } = uploadResult; const url = `${domain}/${qiniuKey}`; await models.ChatSchemeFileCache.create({ dataId, fileUrl: url }); result = { dataId, fileUrl: url }; } ctx.set('Content-Type', 'application/json'); ctx.status = 200; ctx.body = result; } else { const docxBuffer = await convertHTMLToDOCX(content); ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); ctx.set('Content-Disposition', `attachment; filename=${encodeURIComponent(`方案`)}.docx`); ctx.body = docxBuffer; ctx.status = 200; } } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '下载失败' }; } } module.exports.downloadWorkingHoursEstimate = async (ctx, next) => { try { const { body } = ctx.request; const { qiniu: { dmn: domain, bkt: bucket, ak: accessKey, sk: secretKey } } = ctx.config; // 验证数据完整性 if (!body || !body.project_overview || !body.requirement_breakdown || !body.estimation_summary) { throw '缺少必要的数据字段'; } // 创建新的工作簿 const workbook = XLSX.utils.book_new(); // 创建项目概览工作表 const projectOverviewData = [ ['项目概览'], [''], ['项目名称', body.project_overview.project_name || ''], ['项目类型', body.project_overview.project_type || ''], ['预估规模', body.project_overview.estimated_scale || ''] ]; const projectOverviewSheet = XLSX.utils.aoa_to_sheet(projectOverviewData); // 设置样式 projectOverviewSheet['A1'] = { v: '项目概览', s: { font: { bold: true, sz: 16, color: { rgb: "FFFFFF" } }, fill: { bgColor: { indexed: 64 }, fgColor: { rgb: "4F81BD" } }, alignment: { horizontal: "center", vertical: "center" } } }; // 设置列宽 projectOverviewSheet['!cols'] = [{ width: 15 }, { width: 30 }]; // 合并标题单元格 projectOverviewSheet['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 1 } }]; XLSX.utils.book_append_sheet(workbook, projectOverviewSheet, '项目概览'); // 创建需求分解工作表 const requirementData = [ ['需求分解详情'], [''], ['业务域', '功能单元', '任务描述', '参考任务描述', '管理/h', '研发/h', '测试/h', '任务工时统计/h', '功能工时统计/h', '域工时统计/h'] ]; // 填充需求分解数据 let mergeRanges = []; // 用于存储需要合并的单元格范围 let totalManagementHours = 0; let totalDevelopmentHours = 0; let totalTestingHours = 0; let totalProjectHours = 0; let totalUnitHours = 0; // 功能工时统计总和 let totalDomainHours = 0; // 域工时统计总和 body.requirement_breakdown.forEach((domain, domainIndex) => { let domainStartRow = requirementData.length; let domainTaskCount = 0; let domainManagementHours = 0; let domainDevelopmentHours = 0; let domainTestingHours = 0; // 累加域工时到总计 totalDomainHours += domain.domain_total_hours || 0; domain.functional_units.forEach((unit, unitIndex) => { let unitStartRow = requirementData.length; let unitManagementHours = 0; let unitDevelopmentHours = 0; let unitTestingHours = 0; // 累加功能单元工时到总计 totalUnitHours += unit.unit_total_hours || 0; unit.tasks.forEach((task, taskIndex) => { const managementHours = task.management?.subtotal_hours || 0; const developmentHours = task.development?.subtotal_hours || 0; const testingHours = task.testing?.subtotal_hours || 0; // 累加各级别的工时 unitManagementHours += managementHours; unitDevelopmentHours += developmentHours; unitTestingHours += testingHours; domainManagementHours += managementHours; domainDevelopmentHours += developmentHours; domainTestingHours += testingHours; totalManagementHours += managementHours; totalDevelopmentHours += developmentHours; totalTestingHours += testingHours; requirementData.push([ (unitIndex === 0 && taskIndex === 0) ? domain.business_domain : '', (taskIndex === 0) ? unit.unit_name : '', task.task_description || '', task.reference_description || '', managementHours, developmentHours, testingHours, task.total_task_hours || 0, (taskIndex === 0) ? (unit.unit_total_hours || 0) : '', (unitIndex === 0 && taskIndex === 0) ? (domain.domain_total_hours || 0) : '' ]); domainTaskCount++; }); // 记录功能单元合并范围 if (unit.tasks.length > 1) { mergeRanges.push({ s: { r: unitStartRow, c: 1 }, // 功能单元列 e: { r: unitStartRow + unit.tasks.length - 1, c: 1 } }); mergeRanges.push({ s: { r: unitStartRow, c: 8 }, // 功能工时统计列 e: { r: unitStartRow + unit.tasks.length - 1, c: 8 } }); } }); // 记录业务域合并范围 if (domainTaskCount > 1) { mergeRanges.push({ s: { r: domainStartRow, c: 0 }, // 业务域列 e: { r: domainStartRow + domainTaskCount - 1, c: 0 } }); mergeRanges.push({ s: { r: domainStartRow, c: 9 }, // 域工时统计列 e: { r: domainStartRow + domainTaskCount - 1, c: 9 } }); } }); totalProjectHours = totalManagementHours + totalDevelopmentHours + totalTestingHours; // 添加项目总计行 requirementData.push([ '总计', '', '', '', totalManagementHours, totalDevelopmentHours, totalTestingHours, totalProjectHours, totalUnitHours, totalDomainHours ]); const requirementSheet = XLSX.utils.aoa_to_sheet(requirementData); // 设置标题样式 requirementSheet['A1'] = { v: '需求分解详情', s: { font: { bold: true, sz: 16, color: { rgb: "FFFFFF" } }, fill: { bgColor: { indexed: 64 }, fgColor: { rgb: "4F81BD" } }, alignment: { horizontal: "center", vertical: "center" } } }; // 设置表头样式 for (let col = 0; col < 10; col++) { const cellRef = XLSX.utils.encode_cell({ r: 2, c: col }); if (requirementSheet[cellRef]) { requirementSheet[cellRef].s = { font: { bold: true, color: { rgb: "FFFFFF" } }, fill: { bgColor: { indexed: 64 }, fgColor: { rgb: "4F81BD" } }, alignment: { horizontal: "center", vertical: "center" }, border: { top: { style: "thin", color: { rgb: "000000" } }, bottom: { style: "thin", color: { rgb: "000000" } }, left: { style: "thin", color: { rgb: "000000" } }, right: { style: "thin", color: { rgb: "000000" } } } }; } } // 设置数据行样式和边框 for (let row = 3; row < requirementData.length; row++) { for (let col = 0; col < 10; col++) { const cellRef = XLSX.utils.encode_cell({ r: row, c: col }); if (requirementSheet[cellRef]) { const isTotalRow = requirementSheet[cellRef].v && requirementSheet[cellRef].v.toString() === '总计'; requirementSheet[cellRef].s = { alignment: { horizontal: "left", vertical: "center" }, font: isTotalRow ? { bold: true } : {}, fill: isTotalRow ? { bgColor: { indexed: 64 }, fgColor: { rgb: "E6F2FF" } } : {}, border: { top: { style: "thin", color: { rgb: "000000" } }, bottom: { style: "thin", color: { rgb: "000000" } }, left: { style: "thin", color: { rgb: "000000" } }, right: { style: "thin", color: { rgb: "000000" } } } }; } } } // 设置列宽 requirementSheet['!cols'] = [ { width: 15 }, { width: 20 }, { width: 35 }, { width: 30 }, { width: 10 }, { width: 10 }, { width: 10 }, { width: 15 }, { width: 15 }, { width: 15 } ]; // 合并标题单元格和数据单元格 requirementSheet['!merges'] = [ { s: { r: 0, c: 0 }, e: { r: 0, c: 9 } }, // 标题行合并 ...mergeRanges // 添加数据行的合并范围 ]; XLSX.utils.book_append_sheet(workbook, requirementSheet, '需求分解'); // 创建评估汇总工作表 const summaryData = [ ['评估汇总'], [''], ['公司标准总工时', body.estimation_summary.company_standard_total_hours || 0], [''], ['功能点分析'], ['内部逻辑文件数(ILF)', body.estimation_summary.function_point_analysis?.ilf_count || 0], ['外部接口文件数(EIF)', body.estimation_summary.function_point_analysis?.eif_count || 0], ['未调整功能点(UFP)', body.estimation_summary.function_point_analysis?.ufp || 0], ['软件类型因子', body.estimation_summary.function_point_analysis?.software_type_factor || 0], ['复用因子', body.estimation_summary.function_point_analysis?.reuse_factor || 0], ['调整后功能点', body.estimation_summary.function_point_analysis?.adjusted_fp || 0], ['基于功能点的工时', body.estimation_summary.function_point_analysis?.fp_based_hours || 0], [''], ['最终推荐工时', body.estimation_summary.final_recommended_hours || 0], ['风险评估', body.estimation_summary.risk_assessment || ''] ]; const summarySheet = XLSX.utils.aoa_to_sheet(summaryData); // 设置样式 summarySheet['A1'] = { v: '评估汇总', s: { font: { bold: true, sz: 16, color: { rgb: "FFFFFF" } }, fill: { bgColor: { indexed: 64 }, fgColor: { rgb: "4F81BD" } }, alignment: { horizontal: "center", vertical: "center" } } }; summarySheet['A5'] = { v: '功能点分析', s: { font: { bold: true, sz: 14, color: { rgb: "FFFFFF" } }, fill: { bgColor: { indexed: 64 }, fgColor: { rgb: "92A9D1" } }, alignment: { horizontal: "center", vertical: "center" } } }; // 设置列宽 summarySheet['!cols'] = [{ width: 20 }, { width: 25 }]; // 合并标题单元格 summarySheet['!merges'] = [ { s: { r: 0, c: 0 }, e: { r: 0, c: 1 } }, { s: { r: 4, c: 0 }, e: { r: 4, c: 1 } } ]; XLSX.utils.book_append_sheet(workbook, summarySheet, '评估汇总'); // 生成XLSX缓冲区 const xlsxBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer', cellStyles: true }); // 上传到七牛云 const mac = new qiniu.auth.digest.Mac(accessKey, secretKey); const options = { scope: bucket }; const putPolicy = new qiniu.rs.PutPolicy(options); const uploadToken = putPolicy.uploadToken(mac); const formUploader = new qiniu.form_up.FormUploader(); const putExtra = new qiniu.form_up.PutExtra(); const timestamp = moment().format('YYYYMMDDHHmmss'); const key = `ai-query/working-hours-estimate/${timestamp}/工时评估-${body.project_overview.project_name || 'project'}.xlsx`; const config = new qiniu.conf.Config(); config.zone = qiniu.zone.Zone_z0; const uploadResult = await new Promise((resolve, reject) => { formUploader.put(uploadToken, key, xlsxBuffer, putExtra, (respErr, respBody, respInfo) => { if (respErr) { reject(respErr); } else { resolve(respBody); } }); }); // 构造七牛云URL const { key: qiniuKey } = uploadResult; const url = `${domain}/${qiniuKey}`; ctx.status = 200; ctx.body = { success: true, message: '工时评估报告生成成功', fileUrl: url, fileName: `${body.project_overview.project_name || 'project'}.xlsx` }; } catch (error) { ctx.logger.error('生成工时评估报告失败:', error); ctx.status = 400; ctx.body = { success: false, message: typeof error === 'string' ? error : '生成工时评估报告失败', error: error.message || error }; } } //[优化引用数据] module.exports.optimizeQuoteData = async (ctx, next) => { try { const startTime = Date.now(); const { quoteData } = ctx.request.body || {}; const content = quoteData?.q || quoteData?.content || ''; const sourceName = quoteData?.sourceName || quoteData?.collection?.name || ''; const updateTime = quoteData?.updateTime || quoteData?.collection?.updateTime || ''; const linkMeta = { sourceName, updateTime, collectionId: quoteData?.collectionId || quoteData?.collection?.collection_id || quoteData?.collection?._id, sourceId: quoteData?.sourceId, datasetId: quoteData?.datasetId, collection: quoteData?.collection }; const apiUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'; const appKey = ctx.app.fs.config.ali.appKey if (!content) { throw '缺少引用内容' }; if (!appKey) { throw '千问优化应用未配置' }; const requestBody = { model: 'qwen3.6-flash', stream: false, enable_thinking: false, messages: [{ role: 'user', content: [ '请把下面的引用正文整理为标准 Markdown,标题从三级标题(###)开始。', '只允许结构化排版,不得增删事实内容,不得改写数字、单位、专有名词、时间和链接。', '如正文中已有 Markdown 链接或 URL,必须原样保留 href/url,不要改写、补全或删除。', '只返回整理后的 Markdown 正文,不要返回解释、代码块或 JSON。', sourceName ? `文件名:${sourceName}` : '', updateTime ? `时间:${updateTime}` : '', '引用正文:', content ].join('\n') }] }; const res = await superagent .post(apiUrl) .send(requestBody) .set({ Authorization: `Bearer ${appKey}`, 'Content-Type': 'application/json' }); const body = res.body || JSON.parse(res.text || '{}'); const contentMd = body?.choices?.[0]?.message?.content || ''; const runningTime = Number(((Date.now() - startTime) / 1000).toFixed(3)); reportQuoteOptimizeUsage(ctx, { body, runningTime, quoteData }); ctx.status = 200; ctx.body = { code: 200, data: { ...quoteData, ...linkMeta, q: contentMd || content, content: contentMd, originalQ: quoteData?.q, originalContent: quoteData?.content, raw: body } }; } catch (error) { ctx.logger.error('优化引用数据失败:', error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '优化引用数据失败' }; } };