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.
543 lines
21 KiB
543 lines
21 KiB
'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 : '优化引用数据失败'
|
|
};
|
|
}
|
|
};
|
|
|