'use strict'; const superagent = require('superagent'); const moment = require('moment'); const { getQuotaService, trackGenerateUsage: sharedTrackGenerateUsage, recordAiQueryLog, } = require('../utils/aiUsage'); const { respondQuotaLimited } = require('../utils/quotaResponse'); const { reportBusinessCall } = require('../services/dashboardReporter'); const TENDER_TYPE_ENUM = { 'project': '工程类', 'product': '产品采购类', 'software': '纯软件类', 'operation': '运维类', } const TENDER_STATUS_ENUM = { 'parsed': '解析完成', 'chaptersGenerating': '预览目录生成中', 'chaptersGenerateSuccess': '预览目录生成成功', 'chaptersGenerateFailed': '预览目录生成失败', 'chaptersCreated': '已确认创建目录', 'contentGenerating': '内容生成中', 'contentGenerateSuccess': '内容生成成功', 'contentGenerateFailed': '内容生成失败', } const trackGenerateUsage = (ctx, payload) => sharedTrackGenerateUsage(ctx, { ...payload, docKind: getQuotaService(ctx)?.DOC_KIND.BID, analyticsApplicationId: 'stable-tender', analyticsAppKey: ctx.app.fs.config.fastGpt?.v2TenderAppKey, }); const resolveCurrentRegisterSource = async (ctx, preferredUserId = null) => { ctx.fs = ctx.fs || {}; if (ctx.fs.currentRegisterSource !== undefined) { return ctx.fs.currentRegisterSource; } const fromRequestBody = String(ctx.request?.body?.registerSource || '').trim(); if (fromRequestBody) { ctx.fs.currentRegisterSource = fromRequestBody; return fromRequestBody; } const userInfo = ctx.fs?.curUser?.userInfo || {}; const fromUserInfo = String( userInfo.registerSource || userInfo.register_source || userInfo.source || '' ).trim(); if (fromUserInfo) { ctx.fs.currentRegisterSource = fromUserInfo; return fromUserInfo; } const internalUserId = preferredUserId || ctx.fs?.userIdMapping?.internalUserId || ctx.request.body?.creator || ctx.request.body?.userId || ctx.request.body?.userid || null; if (!internalUserId) { ctx.fs.currentRegisterSource = ''; return ''; } const normalizedUserId = Number(internalUserId); if (!Number.isFinite(normalizedUserId) || normalizedUserId <= 0) { ctx.fs.currentRegisterSource = ''; return ''; } const { models } = ctx.app.fs.dc; const tenderUser = await models.TenderUser.findOne({ attributes: ['registerSource'], where: { id: normalizedUserId }, raw: true, }); const registerSource = String(tenderUser?.registerSource || '').trim(); ctx.fs.currentRegisterSource = registerSource; return registerSource; }; const withRegisterSourceVariables = async (ctx, payload = {}, preferredUserId = null) => { const registerSource = await resolveCurrentRegisterSource(ctx, preferredUserId); return { ...payload, variables: { ...(payload.variables || {}), registerSource, }, }; }; module.exports.extractTitle = async (ctx, next) => { try { const { fileUrl, annex } = ctx.request.body; if (!fileUrl) { throw '缺少参数' }; const { apiUrl: fastGptApiUrl, v2TenderAppKey } = ctx.app.fs.config.fastGpt; const sendContent = [ { "type": "file_url", "url": fileUrl } ] if (annex?.length) { sendContent.push(...annex.map(item => ({ "type": "file_url", "url": item }))) } const res = await superagent .post(`${fastGptApiUrl}/api/v1/chat/completions`) .send(await withRegisterSourceVariables(ctx, { "stream": false, "detail": true, "variables": { "generation_type": '提取标题' }, "messages": [ { "role": "user", "content": sendContent } ] })) .set({ Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }) ctx.body = { title: res.body.choices[0].message.content }; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '提取标题失败' }; } } module.exports.getTenderList = async (ctx, next) => { try { const { models, ORM: { Op } } = ctx.app.fs.dc; const { page, pageSize, creator, name } = ctx.request.query; const where = {}; if (creator) { where.creator = creator; } if (name) { where.name = { [Op.like]: `%${name}%` }; } const options = { where, order: [['updateAt', 'DESC']], raw: true, }; if (page && pageSize) { options.offset = (page - 1) * pageSize; options.limit = parseInt(pageSize); } const list = await models.V2Tenders.findAndCountAll(options); ctx.body = list; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '获取标书列表失败' }; } } module.exports.createTender = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models } = ctx.app.fs.dc; const quotaService = getQuotaService(ctx); const { apiUrl: fastGptApiUrl, v2TenderAppKey } = ctx.app.fs.config.fastGpt; const { name, type, fileUrl, annex, creator, curIp, confirmCharge, confirmToken, } = ctx.request.body; if (!name || !type || !fileUrl) { throw '缺少参数' }; const effectiveCreator = creator || ctx.request.body?.userId || ctx.request.body?.userid || ctx.fs?.userIdMapping?.internalUserId; const userId = quotaService?.normalizeUserId?.(effectiveCreator); let quotaCheck = { passed: true }; if (userId) { quotaCheck = await quotaService.checkCreateQuota({ transaction, userId, docKind: quotaService.DOC_KIND.BID, confirmCharge, confirmToken, externalUserId: ctx?.fs?.userIdMapping?.externalUserId || '', }); if (quotaCheck?.passed === false) { await transaction.rollback(); respondQuotaLimited(ctx, { message: quotaCheck.message, limitType: 'create', docKind: 'bid', code: quotaCheck.code, confirmToken: quotaCheck.confirmToken || null, confirmExpireAt: quotaCheck.confirmExpireAt || null, }); return; } } // 调用AI解析评分标准 const sendContent = [ { "type": "file_url", "url": fileUrl } ] if (annex?.length) { sendContent.push(...annex.map(item => ({ "type": "file_url", "url": item }))) } const res = await superagent .post(`${fastGptApiUrl}/api/v1/chat/completions`) .send(await withRegisterSourceVariables(ctx, { "stream": false, "detail": true, "variables": { "generation_type": '解析评分标准' }, "messages": [ { "role": "user", "content": sendContent } ] })) .set({ Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }) const resContent = res.body.choices[0].message.content; // 解析 AI 返回的评分标准内容 const fullMatch = resContent.match(/<完整评分项>([\s\S]*?)<\/完整评分项>/); const techMatch = resContent.match(/<技术评分项>([\s\S]*?)<\/技术评分项>/); const businessMatch = resContent.match(/<商务评分项>([\s\S]*?)<\/商务评分项>/); const completeRatingItems = fullMatch ? fullMatch[1].trim() : ''; const technologyRatingItems = techMatch ? techMatch[1].trim() : ''; const businessRatingItems = businessMatch ? businessMatch[1].trim() : ''; // 新增标书 const tender = await models.V2Tenders.create( { name, type, fileUrl, annex, completeRatingItems, technologyRatingItems, businessRatingItems, status: 'parsed', creator: effectiveCreator }, { returning: true, transaction }, ); if (userId) { const createShouldCharge = quotaCheck?.chargeRequired === true; const createAmountYuan = createShouldCharge ? Number(quotaCheck?.chargeAmountYuan || quotaService.createChargeYuan || 5) : 0; const createRequestId = createShouldCharge ? quotaService.createCreateChargeRequestId({ userId, docKind: quotaService.DOC_KIND.BID, docId: tender.id, }) : null; const createEventId = await quotaService.recordCreateEvent({ transaction, userId, docKind: quotaService.DOC_KIND.BID, docId: tender.id, source: 'manual', billingStatus: createShouldCharge ? quotaService.BILLING_STATUS.PENDING : quotaService.BILLING_STATUS.SKIP, amountYuan: createAmountYuan, requestId: createRequestId, }); if (createShouldCharge) { if (!createEventId) { throw '创建计费流水写入失败,请重试'; } const aideductUserId = await quotaService.resolveChargeUserId({ transaction, userId, externalUserId: ctx?.fs?.userIdMapping?.externalUserId || '', }); const settledStatus = await quotaService.settleGenerateBilling({ eventId: createEventId, aideductUserId, amountYuan: createAmountYuan, requestId: createRequestId, logger: ctx.logger, }); if (settledStatus !== quotaService.BILLING_STATUS.CONFIRMED) { throw '扣费失败,请稍后重试'; } } } // 新增日志记录 await recordAiQueryLog(ctx, { userId: effectiveCreator, ipAddress: curIp, feature: '标书生成-V2', transaction, }); await transaction.commit(); await trackGenerateUsage(ctx, { creator: tender?.creator, docId: tender?.id, responseBody: res.body, }); ctx.body = tender; ctx.status = 200; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '新增标书失败' }; } } module.exports.modifyTender = 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.V2Tenders.update( { ...body, updateAt: moment() }, { 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.V2TenderChapterVersions.destroy({ where: { tenderId }, transaction }); await models.V2TenderChapters.destroy({ where: { tenderId }, transaction }); await models.V2Tenders.destroy({ where: { id: tenderId }, transaction }); await transaction.commit(); ctx.status = 204; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '删除标书失败' }; } } module.exports.generateChapters = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); const { models, ORM: { Op } } = ctx.app.fs.dc; const { tenderId } = ctx.params; try { const { apiUrl: fastGptApiUrl, v2TenderAppKey } = ctx.app.fs.config.fastGpt; const { ratingItem, completeRatingItems, technologyRatingItems, businessRatingItems } = ctx.request.body; if (!tenderId || !['completeRatingItems', 'technologyRatingItems', 'businessRatingItems'].includes(ratingItem)) { throw '参数错误' }; const tender = await models.V2Tenders.findOne({ where: { id: tenderId }, raw: true }); await models.V2Tenders.update( { status: 'chaptersGenerating', ratingItem, completeRatingItems, technologyRatingItems, businessRatingItems, updateAt: moment() }, { where: { id: tenderId } } ); const sendContent = [ { "type": "text", "text": ratingItem === 'completeRatingItems' ? completeRatingItems : ratingItem === 'technologyRatingItems' ? technologyRatingItems : businessRatingItems }, { "type": "file_url", "url": tender.fileUrl } ] if (tender?.annex?.length) { sendContent.push(...tender.annex.map(item => ({ "type": "file_url", "url": item }))) } const res = await superagent .post(`${fastGptApiUrl}/api/v1/chat/completions`) .send(await withRegisterSourceVariables(ctx, { "stream": false, "detail": true, "variables": { "generation_type": ratingItem === 'completeRatingItems' ? '完整目录生成' : ratingItem === 'technologyRatingItems' ? '技术目录生成' : '商务目录生成' }, "messages": [ { "role": "user", "content": sendContent } ] })) .set({ Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }) const resContent = res.body.choices[0].message.content; const previewChapters = JSON.parse(resContent); await models.V2Tenders.update( { previewChapters, status: 'chaptersGenerateSuccess', updateAt: moment() }, { where: { id: tenderId }, transaction }, ); await transaction.commit(); await trackGenerateUsage(ctx, { creator: tender?.creator, docId: tenderId, responseBody: res.body, }); ctx.status = 200; ctx.body = previewChapters; } catch (error) { await transaction.rollback(); await models.V2Tenders.update( { status: 'chaptersGenerateFailed', updateAt: moment() }, { where: { id: tenderId } }, ); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '目录生成失败' }; } } module.exports.sortChapters = async (ctx, next) => { try { const { models, ORM: { Op } } = ctx.app.fs.dc; const { apiUrl: fastGptApiUrl, v2TenderAppKey } = ctx.app.fs.config.fastGpt; const { tenderId } = ctx.params; const previewChapters = ctx.request.body; if (!tenderId || !previewChapters) { throw '参数错误' }; const tender = await models.V2Tenders.findOne({ attributes: ['id', 'creator'], where: { id: tenderId }, raw: true, }); const res = await superagent .post(`${fastGptApiUrl}/api/v1/chat/completions`) .send(await withRegisterSourceVariables(ctx, { "stream": false, "detail": true, "variables": { "generation_type": "整理预览目录" }, "messages": [ { "role": "user", "content": JSON.stringify(previewChapters) } ] })) .set({ Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }) const resContent = res.body.choices[0].message.content; const newPreviewChapters = JSON.parse(resContent); await models.V2Tenders.update( { previewChapters: newPreviewChapters, updateAt: moment() }, { where: { id: tenderId } }, ); await trackGenerateUsage(ctx, { creator: tender?.creator, docId: tenderId, responseBody: res.body, }); ctx.status = 200; ctx.body = newPreviewChapters; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '整理章节失败' }; } } module.exports.createChapters = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models, ORM: { Op } } = ctx.app.fs.dc; const { tenderId } = ctx.params; const { previewChapters, targetPages, targetWords } = ctx.request.body; if (!tenderId || !previewChapters) { throw '参数错误' }; // 删除原目录和内容 await models.V2TenderChapterVersions.destroy({ where: { tenderId: Number(tenderId) }, transaction }); await models.V2TenderChapters.destroy({ where: { tenderId: Number(tenderId) }, transaction }); // 分层插入章节数据 const insertChaptersRecursively = async (chapters, parentId = null, sortOrder = 0, level = 0) => { for (let i = 0; i < chapters.length; i++) { const chapter = chapters[i]; // 插入当前章节 const insertedChapter = await models.V2TenderChapters.create({ tenderId: Number(tenderId), title: chapter.name, targetPages: level === 0 ? targetPages : null, targetWords: level === 0 ? targetWords : null, parentId, sortOrder: sortOrder + i, level }, { transaction }); // 如果有子章节,递归插入 if (chapter.child && chapter.child.length > 0) { await insertChaptersRecursively(chapter.child, insertedChapter.id, 0, level + 1); } } }; await insertChaptersRecursively(previewChapters); await models.V2Tenders.update( { previewChapters: previewChapters, status: 'chaptersCreated', updateAt: moment() }, { where: { id: tenderId }, transaction }, ); await transaction.commit(); ctx.status = 204; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '创建目录失败' }; } } module.exports.getTenderChapters = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const tenderId = ctx.params.tenderId; if (!tenderId) { throw '缺少参数: tenderId' }; const chapters = await models.V2TenderChapters.findAll({ where: { tenderId: tenderId }, order: [['id', 'ASC']], include: [ { model: models.V2TenderChapterVersions, where: { isCurrent: true, }, required: false, } ], }); ctx.body = chapters; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '获取标书章节失败' }; } } module.exports.addTenderChapters = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { tenderId } = ctx.params; const body = ctx.request.body; if (!tenderId || !body?.title) { throw '缺少参数' }; body.tenderId = Number(tenderId); // 查询同级的最后一个章节 const lastChild = await models.V2TenderChapters.findOne({ where: { tenderId, parentId: body.parentId || null }, attributes: ['sortOrder'], order: [['sortOrder', 'DESC']], raw: true, }); if (lastChild) { body.sortOrder = lastChild.sortOrder + 1; } else { body.sortOrder = 0; } await models.V2TenderChapters.create(body); ctx.status = 204; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '添加章节失败' }; } } module.exports.modifyTenderChapters = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models, ORM: { Op } } = ctx.app.fs.dc; const { tenderId, chapterId } = ctx.params; const body = ctx.request.body; if (!tenderId || !chapterId) { throw '缺少参数' }; // 检查是否需要修改章节顺序或父章节 if (body.sortOrder !== undefined || body.parentId !== undefined) { // 获取目标章节信息 const targetChapter = await models.V2TenderChapters.findOne({ where: { id: chapterId, tenderId }, attributes: ['parentId', 'sortOrder'], raw: true, transaction }); if (!targetChapter) { throw '章节未找到'; } const { parentId: originalParentId, sortOrder: originalSortOrder } = targetChapter; const newParentId = body.parentId !== undefined ? body.parentId : originalParentId; const newSortOrder = body.sortOrder !== undefined ? body.sortOrder : originalSortOrder; //[updates][按固定顺序串行更新,避免并发更新触发数据库死锁] const applySortUpdatesSequentially = async (updates = []) => { for (const update of updates) { await models.V2TenderChapters.update( { sortOrder: update.sortOrder, updateAt: moment() }, { where: { id: update.id }, transaction } ); } }; // 如果父章节改变了,需要重新计算排序 if (newParentId !== originalParentId) { // 更新章节的父章节 await models.V2TenderChapters.update( { parentId: newParentId }, { where: { id: chapterId }, transaction } ); // 批量更新原父章节下的所有章节排序 const originalSiblingChapters = await models.V2TenderChapters.findAll({ attributes: ['id', 'sortOrder'], where: { tenderId, parentId: originalParentId, }, order: [['sortOrder', 'ASC']], raw: true, transaction }); // 重新计算原父章节下剩余章节的排序 const originalUpdates = originalSiblingChapters .filter(chapter => chapter.id !== parseInt(chapterId)) .map((chapter, index) => ({ id: chapter.id, sortOrder: index })); // 批量更新原父章节下剩余章节的排序 if (originalUpdates.length > 0) { await applySortUpdatesSequentially(originalUpdates); } // 批量更新新父章节下的所有章节排序 const newSiblingChapters = await models.V2TenderChapters.findAll({ attributes: ['id', 'sortOrder'], where: { tenderId, parentId: newParentId, id: { [Op.ne]: parseInt(chapterId) } }, order: [['sortOrder', 'ASC']], raw: true, transaction }); // 创建移动章节对象并插入到新位置 const movingChapter = { id: parseInt(chapterId) }; newSiblingChapters.splice(newSortOrder, 0, movingChapter); // 准备批量更新数据 const newUpdates = newSiblingChapters.map((chapter, index) => ({ id: chapter.id, sortOrder: index })); // 批量更新新父章节下所有章节的排序 await applySortUpdatesSequentially(newUpdates); } else { // 父章节未改变,只修改排序 // 获取所有兄弟章节 const siblingChapters = await models.V2TenderChapters.findAll({ attributes: ['id', 'sortOrder'], where: { tenderId, parentId: originalParentId, }, order: [['sortOrder', 'ASC']], raw: true, transaction }); // 从当前位置移除目标章节并将其插入到新位置 const currentChapterIndex = siblingChapters.findIndex(chapter => chapter.id === parseInt(chapterId)); const [chapterToMove] = siblingChapters.splice(currentChapterIndex, 1); siblingChapters.splice(newSortOrder, 0, chapterToMove); // 准备批量更新数据 const updates = siblingChapters.map((chapter, index) => ({ id: chapter.id, sortOrder: index })); // 批量更新所有兄弟章节的排序 await applySortUpdatesSequentially(updates); } } else { // 其它修改(不涉及排序和父章节) await models.V2TenderChapters.update( { ...body, updateAt: moment() }, { where: { id: chapterId, tenderId }, transaction } ); } await transaction.commit(); ctx.status = 204; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '修改章节失败' }; } } module.exports.delTenderChapters = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models, ORM: { Op } } = ctx.app.fs.dc; const { tenderId, chapterId } = ctx.params; if (!tenderId || !chapterId) { throw '缺少参数' }; // 获取所有子章节ID async function getChildIds(parentId, allChildIds = []) { const children = await models.V2TenderChapters.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(chapterId)]; // 包含目标节点本身 const childIds = await getChildIds(chapterId); allIdsToDelete.push(...childIds); await models.V2TenderChapterVersions.destroy({ where: { chapterId: { [Op.in]: allIdsToDelete }, tenderId }, transaction }); await models.V2TenderChapters.destroy({ where: { id: { [Op.in]: allIdsToDelete }, tenderId }, transaction }); await transaction.commit(); ctx.status = 204; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '删除章节失败' }; } } module.exports.startGenerateContent = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models, ORM: { Op } } = ctx.app.fs.dc; const quotaService = getQuotaService(ctx); const { tenderId } = ctx.params; const { targetCharts, targetTables, requirements, confirmCharge, confirmToken, } = ctx.request.body; if (!tenderId) { throw '缺少参数' }; if (requirements) { await models.V2Tenders.update( { requirements, updateAt: moment() }, { where: { id: tenderId }, transaction } ); } if (targetCharts || targetTables) { await models.V2TenderChapters.update( { targetCharts, targetTables, updateAt: moment() }, { where: { tenderId }, transaction } ); } const tender = await models.V2Tenders.findOne({ where: { id: tenderId }, raw: true, transaction }); const allChapters = await models.V2TenderChapters.findAll({ where: { tenderId }, raw: true, transaction }); const shouldCountFullRegenerateRewrite = String(tender?.status || '') === 'contentGenerateSuccess'; const userId = quotaService?.normalizeUserId?.(tender?.creator); let rewriteOverLimit = false; if (shouldCountFullRegenerateRewrite && userId) { const sectionCount = allChapters.filter((chapter) => Number(chapter?.level) === 1).length; const consumeRewriteCount = Math.max(1, sectionCount || 0); const rewriteQuotaCheck = await quotaService.checkAndRecordRewrite({ transaction, userId, docId: Number(tenderId), docKind: quotaService.DOC_KIND.BID, chapterKey: `full_content:${tenderId}`, action: 'rewrite_full_content', consumeCount: consumeRewriteCount, confirmCharge, confirmToken, externalUserId: ctx?.fs?.userIdMapping?.externalUserId || '', }); if (rewriteQuotaCheck?.passed === false) { await transaction.rollback(); respondQuotaLimited(ctx, { message: rewriteQuotaCheck.message, limitType: 'rewrite', docKind: 'bid', code: rewriteQuotaCheck.code, confirmToken: rewriteQuotaCheck.confirmToken || null, confirmExpireAt: rewriteQuotaCheck.confirmExpireAt || null, }); return; } rewriteOverLimit = rewriteQuotaCheck?.overLimit === true; } generateAllContent(ctx, tender, allChapters, { rewriteOverLimit }); await transaction.commit(); ctx.status = 204; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '启动内容生成任务失败' }; } } const generateAllContent = async (ctx, tender, allChapters, options = {}) => { const { models } = ctx.app.fs.dc; const { apiUrl: fastGptApiUrl, v2TenderAppKey } = ctx.app.fs.config.fastGpt; const rewriteOverLimit = options?.rewriteOverLimit === true; const registerSource = await resolveCurrentRegisterSource(ctx, tender?.creator); try { // 更新标书状态为生成 await models.V2Tenders.update( { status: 'contentGenerating', updateAt: moment() }, { where: { id: tender.id } } ); // 构建章节映射,方便查找父级章 const chapterMap = new Map(); allChapters.forEach(chapter => { chapterMap.set(chapter.id, chapter); }); // 筛选出没有子章节的叶子章节 const leafChapters = allChapters.filter(chapter => { // 检查是否有其他章节parentId 等于当前章节id return !allChapters.some(c => c.parentId === chapter.id); }); console.log(`开始生成内容,${leafChapters.length} 个叶子章节`); // 构建完整章节路径的辅助函 const buildChapterPath = (chapter) => { const path = []; let current = chapter; while (current) { path.unshift(current.title); current = current.parentId ? chapterMap.get(current.parentId) : null; } return path.join('-'); }; // 构建完整目录结构文本 const buildChapterStructure = (chapters) => { const chaptersByParent = new Map(); // parentId 分组 chapters.forEach(chapter => { const parentId = chapter.parentId || 'root'; if (!chaptersByParent.has(parentId)) { chaptersByParent.set(parentId, []); } chaptersByParent.get(parentId).push(chapter); }); // sortOrder 排序 chaptersByParent.forEach(siblings => { siblings.sort((a, b) => a.sortOrder - b.sortOrder); }); const lines = []; const buildLevel = (parentId, prefix = '') => { const children = chaptersByParent.get(parentId) || []; children.forEach((chapter, index) => { const number = prefix ? `${prefix}.${index + 1}` : `${index + 1}`; lines.push(`${number} ${chapter.title}`); // 递归处理子章 buildLevel(chapter.id, number); }); }; buildLevel('root'); return lines.join('\n'); }; const chaptersText = buildChapterStructure(allChapters); let lastContent = ''; let hasChapterError = false; const usageTrackQueue = []; const enqueueGenerateUsage = (payload) => { usageTrackQueue.push(payload); }; // 依次生成每个叶子章节的内 for (const chapter of leafChapters) { try { const chapterPath = buildChapterPath(chapter); console.log(`tender ${tender.id} 正在生成章节: ${chapterPath}`); // 构建发送的内容 const variables = { generation_type: '内容生成', lastContent, chapters: chaptersText, registerSource, }; if (tender.requirements) { variables.globalRequirements = tender.requirements; } if (chapter.requirements) { variables.chapterRequirements = chapter.requirements; } if (chapter.writingDirection) { variables.chapterWritingDirection = chapter.writingDirection; } let sendText = `编写章节${chapterPath}`; if (chapter.targetCharts) { sendText += `\n图表数:${chapter.targetCharts}`; } if (chapter.targetTables) { sendText += `\n表格数:${chapter.targetTables}`; } if (chapter.targetSegmentsWords) { sendText += `\n目标字数:约${chapter.targetSegmentsWords}字`; } const sendContent = [ { "type": "text", "text": sendText }, { "type": "file_url", "url": tender.fileUrl } ] if (tender.annex?.length) { sendContent.push(...tender.annex.map(item => ({ "type": "file_url", "url": item }))); } // 调用 FastGPT API 生成章节内容 const res = await superagent .post(`${fastGptApiUrl}/api/v1/chat/completions`) .send(await withRegisterSourceVariables(ctx, { "stream": false, "detail": true, "variables": variables, "messages": [ { "role": "user", "content": sendContent } ] }, tender?.creator)) .set({ Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }); const content = res.body.choices[0].message.content; lastContent = content; // 检查该章节是否已有版本记录 const existingVersion = await models.V2TenderChapterVersions.findOne({ where: { tenderId: tender.id, chapterId: chapter.id }, attributes: ['version'], order: [['version', 'DESC']], raw: true }); const newVersion = existingVersion ? existingVersion.version + 1 : 1; // 如果存在旧版本,将所有旧版本标记为非当前版本 if (existingVersion) { await models.V2TenderChapterVersions.update( { isCurrent: false }, { where: { tenderId: tender.id, chapterId: chapter.id, isCurrent: true, } } ); } // 存入新版 await models.V2TenderChapterVersions.create({ tenderId: tender.id, chapterId: chapter.id, version: newVersion, isCurrent: true, content: content }); enqueueGenerateUsage({ creator: tender?.creator, docId: tender?.id, chapterKey: `chapter:${chapter.id}`, responseBody: res.body, ignoreCreateFree: rewriteOverLimit, allowChapterFirstFree: !rewriteOverLimit, }); console.log(`tender ${tender.id} 章节生成成功: ${chapterPath}, 版本: ${newVersion}`); } catch (chapterError) { hasChapterError = true; console.error(`tender ${tender.id} 章节生成失败: ${chapter.title}`, chapterError); // 继续处理下一个章节,不中断整个流程 } } if (hasChapterError) { await models.V2Tenders.update( { status: 'contentGenerateFailed', updateAt: moment() }, { where: { id: tender.id } } ); return; } // 更新标书状态为生成成功 await models.V2Tenders.update( { status: 'contentGenerateSuccess', updateAt: moment() }, { where: { id: tender.id } } ); if (usageTrackQueue.length > 0) { setImmediate(() => { Promise.allSettled( usageTrackQueue.map((payload) => trackGenerateUsage(ctx, payload)) ).catch((usageError) => { ctx?.logger?.log?.(usageError); }); }); } await reportBusinessCall({ ctx, applicationId: 'stable-tender', eventId: `tender:${tender.id}:content:${Date.now()}`, traceId: `tender:${tender.id}:content`, userId: tender.creator, }); console.log('所有章节内容生成完成'); } catch (error) { console.log('generateAllContent error:', error) await models.V2Tenders.update( { status: 'contentGenerateFailed', updateAt: moment() }, { where: { id: tender.id } } ); } }; module.exports.reWriteChapterContent = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); const { models, ORM: { Op } } = ctx.app.fs.dc; try { const { apiUrl: fastGptApiUrl, v2TenderAppKey } = ctx.app.fs.config.fastGpt; const { tenderId, chapterId } = ctx.params; const { targetSections, targetSegments, targetSegmentsWords, writingDirection, confirmCharge, confirmToken, } = ctx.request.body; if (!tenderId || !chapterId) { throw '缺少参数' }; const tender = await models.V2Tenders.findOne({ where: { id: tenderId }, raw: true, }); const registerSource = await resolveCurrentRegisterSource(ctx, tender?.creator); const quotaService = getQuotaService(ctx); const userId = quotaService?.normalizeUserId?.(tender?.creator); let rewriteOverLimit = false; if (userId) { const quotaTransaction = await ctx.app.fs.dc.orm.transaction(); try { const rewriteQuotaCheck = await quotaService.checkAndRecordRewrite({ transaction: quotaTransaction, userId, docId: Number(tenderId), docKind: quotaService.DOC_KIND.BID, chapterKey: `chapter:${chapterId}`, action: 'rewrite', confirmCharge, confirmToken, externalUserId: ctx?.fs?.userIdMapping?.externalUserId || '', }); if (rewriteQuotaCheck?.passed === false) { await quotaTransaction.rollback(); await transaction.rollback(); respondQuotaLimited(ctx, { message: rewriteQuotaCheck.message, limitType: 'rewrite', docKind: 'bid', code: rewriteQuotaCheck.code, confirmToken: rewriteQuotaCheck.confirmToken || null, confirmExpireAt: rewriteQuotaCheck.confirmExpireAt || null, }); return; } rewriteOverLimit = rewriteQuotaCheck?.overLimit === true; await quotaTransaction.commit(); } catch (quotaError) { await quotaTransaction.rollback(); throw quotaError; } } await models.V2TenderChapters.update( { targetSections, targetSegments, targetSegmentsWords, writingDirection }, { where: { id: chapterId, tenderId } } ); const chapter = await models.V2TenderChapters.findOne({ where: { id: chapterId, tenderId }, raw: true, }); const usageTrackQueue = []; const enqueueGenerateUsage = (payload) => { usageTrackQueue.push(payload); }; if ( chapter.level === 2 || (chapter.level === 1 && !targetSegments) || (chapter.level === 0 && !targetSections) ) { const variables = { generation_type: '内容生成', registerSource, }; if (tender.requirements) { variables.globalRequirements = tender.requirements; } if (chapter.requirements) { variables.chapterRequirements = chapter.requirements; } if (chapter.writingDirection) { variables.chapterWritingDirection = chapter.writingDirection; } const secondChapter = await models.V2TenderChapters.findOne({ attributes: ['id', 'title', 'parentId'], where: { id: chapter.parentId, tenderId }, raw: true, }); let topChapter = null; if (secondChapter) { topChapter = await models.V2TenderChapters.findOne({ attributes: ['title'], where: { id: secondChapter.parentId, tenderId }, raw: true, }); } let sendText = topChapter && secondChapter ? `编写章节:${topChapter.title}-${secondChapter.title}-${chapter.title}` : secondChapter ? `编写章节:${secondChapter.title}-${chapter.title}` : `编写章节:${chapter.title}`; if (chapter.targetCharts) { sendText += `\n图表数:${chapter.targetCharts}`; } if (chapter.targetTables) { sendText += `\n表格数:${chapter.targetTables}`; } if (chapter.targetSegmentsWords) { sendText += `\n目标字数:约${chapter.targetSegmentsWords}字`; } const sendContent = [ { "type": "text", "text": sendText }, { "type": "file_url", "url": tender.fileUrl } ]; if (tender.annex?.length) { sendContent.push(...tender.annex.map(item => ({ "type": "file_url", "url": item }))); } const res = await superagent.post(`${fastGptApiUrl}/api/v1/chat/completions`) .send(await withRegisterSourceVariables(ctx, { "stream": false, "detail": true, "variables": variables, "messages": [ { "role": "user", "content": sendContent } ] }, tender?.creator)) .set({ Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }); const content = res.body.choices[0].message.content; enqueueGenerateUsage({ creator: tender?.creator, docId: tender?.id, chapterKey: `chapter:${chapter.id}`, responseBody: res.body, ignoreCreateFree: rewriteOverLimit, allowChapterFirstFree: !rewriteOverLimit, }); const existingVersion = await models.V2TenderChapterVersions.findOne({ where: { tenderId: tender.id, chapterId: chapter.id }, attributes: ['version'], order: [['version', 'DESC']], raw: true }); const newVersion = existingVersion ? existingVersion.version + 1 : 1; if (existingVersion) { await models.V2TenderChapterVersions.update( { isCurrent: false }, { where: { chapterId, tenderId, isCurrent: true }, transaction } ); } await models.V2TenderChapterVersions.create( { tenderId: tender.id, chapterId: chapter.id, version: newVersion, isCurrent: true, content: content }, { transaction } ); } else if (chapter.level === 1) { // 段 const splitRes = await superagent.post(`${fastGptApiUrl}/api/v1/chat/completions`) .send(await withRegisterSourceVariables(ctx, { "stream": false, "detail": true, "variables": { generation_type: '生成部分目录' }, "messages": [ { "role": "user", "content": `父目录:${chapter.title}\n生成${targetSegments}个二级目录,不要生成三级目录\n生成方向:${writingDirection}` } ] }, tender?.creator)) .set({ Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }); const splitContent = JSON.parse(splitRes.body.choices[0].message.content); enqueueGenerateUsage({ creator: tender?.creator, docId: tender?.id, responseBody: splitRes.body, ignoreCreateFree: rewriteOverLimit, allowChapterFirstFree: !rewriteOverLimit, }); // 删除旧的子目录和内容 const oldChapters = await models.V2TenderChapters.findAll({ attributes: ['id'], where: { parentId: chapterId, tenderId }, raw: true, transaction }); await models.V2TenderChapterVersions.destroy({ where: { chapterId: { [Op.in]: oldChapters.map(item => item.id) } }, transaction }); await models.V2TenderChapters.destroy({ where: { parentId: Number(chapterId), tenderId }, transaction }); // 创建新的子目录 const newChapters = await models.V2TenderChapters.bulkCreate(splitContent.map((item, index) => ({ parentId: Number(chapterId), tenderId: Number(tenderId), title: item.name, level: 2, sortOrder: index, })), { returning: true, transaction }); // 生成内容 let lastContent = ''; for (const item of newChapters) { if (chapter) { const variables = { generation_type: '内容生成', lastContent, registerSource, }; if (tender.requirements) { variables.globalRequirements = tender.requirements; } if (item.writingDirection) { variables.chapterWritingDirection = item.writingDirection; } const topChapter = await models.V2TenderChapters.findOne({ attributes: ['title'], where: { id: chapter.parentId, tenderId }, raw: true, }); let sendText = `${topChapter.title}-${chapter.title}-${item.title}`; if (targetSegmentsWords) { sendText += `\n目标字数:约${targetSegmentsWords}字`; } const sendContent = [ { "type": "text", "text": sendText }, { "type": "file_url", "url": tender.fileUrl } ]; if (tender.annex?.length) { sendContent.push(...tender.annex.map(item => ({ "type": "file_url", "url": item }))); } const res = await superagent.post(`${fastGptApiUrl}/api/v1/chat/completions`) .send(await withRegisterSourceVariables(ctx, { "stream": false, "detail": true, "variables": variables, "messages": [ { "role": "user", "content": sendContent } ] }, tender?.creator)) .set({ Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }); const content = res.body.choices[0].message.content; lastContent = content; enqueueGenerateUsage({ creator: tender?.creator, docId: tender?.id, chapterKey: `chapter:${item.id}`, responseBody: res.body, ignoreCreateFree: rewriteOverLimit, allowChapterFirstFree: !rewriteOverLimit, }); await models.V2TenderChapterVersions.create( { tenderId: tender.id, chapterId: item.id, version: 1, isCurrent: true, content: content }, { transaction } ); } } } else if (chapter.level === 0) { // 章 // 调用fastgpt生成目录结构 const splitRes = await superagent.post(`${fastGptApiUrl}/api/v1/chat/completions`) .send(await withRegisterSourceVariables(ctx, { "stream": false, "detail": true, "variables": { generation_type: '生成部分目录' }, "messages": [ { "role": "user", "content": `父目录:${chapter.title}\n生成${targetSections}个二级目录,每个二级目录生成${targetSegments}个三级目录\n生成方向:${writingDirection}` } ] }, tender?.creator)) .set({ Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }); const splitContent = JSON.parse(splitRes.body.choices[0].message.content); enqueueGenerateUsage({ creator: tender?.creator, docId: tender?.id, responseBody: splitRes.body, ignoreCreateFree: rewriteOverLimit, allowChapterFirstFree: !rewriteOverLimit, }); // 删除旧的子目录和所有子孙目录的内容 const oldSections = await models.V2TenderChapters.findAll({ attributes: ['id'], where: { parentId: Number(chapterId), tenderId: Number(tenderId) }, raw: true, transaction }); if (oldSections.length > 0) { const oldSegments = await models.V2TenderChapters.findAll({ attributes: ['id'], where: { parentId: { [Op.in]: oldSections.map(item => item.id) }, tenderId: Number(tenderId) }, raw: true, transaction }); // 删除段的版本记录 if (oldSegments.length > 0) { await models.V2TenderChapterVersions.destroy({ where: { chapterId: { [Op.in]: oldSegments.map(item => item.id) } }, transaction }); } // 删除节的版本记录 await models.V2TenderChapterVersions.destroy({ where: { chapterId: { [Op.in]: oldSections.map(item => item.id) } }, transaction }); // 删除所有段 if (oldSegments.length > 0) { await models.V2TenderChapters.destroy({ where: { id: { [Op.in]: oldSegments.map(item => item.id) } }, transaction }); } // 删除所有节 await models.V2TenderChapters.destroy({ where: { parentId: Number(chapterId), tenderId: Number(tenderId) }, transaction }); } let lastContent = ''; // 创建新的节和段 for (let i = 0; i < splitContent.length; i++) { const sectionData = splitContent[i]; // 创建节 const newSection = await models.V2TenderChapters.create({ parentId: Number(chapterId), tenderId: Number(tenderId), title: sectionData.name, level: 1, sortOrder: i, }, { transaction }); // 创建段 if (sectionData.child && sectionData.child.length > 0) { const newSegments = await models.V2TenderChapters.bulkCreate( sectionData.child.map((segmentData, segmentIndex) => ({ parentId: newSection.id, tenderId: Number(tenderId), title: segmentData.name, level: 2, sortOrder: segmentIndex, })), { returning: true, transaction } ); // 为每个段生成内容 for (const segment of newSegments) { const variables = { generation_type: '内容生成', lastContent, registerSource, }; if (tender.requirements) { variables.globalRequirements = tender.requirements; } if (segment.writingDirection) { variables.chapterWritingDirection = segment.writingDirection; } let sendText = `编写章节:${chapter.title}-${newSection.title}-${segment.title}`; if (targetSegmentsWords) { sendText += `\n目标字数:约${targetSegmentsWords}字`; } const sendContent = [ { "type": "text", "text": sendText }, { "type": "file_url", "url": tender.fileUrl } ]; if (tender.annex?.length) { sendContent.push(...tender.annex.map(item => ({ "type": "file_url", "url": item }))); } const res = await superagent.post(`${fastGptApiUrl}/api/v1/chat/completions`) .send(await withRegisterSourceVariables(ctx, { "stream": false, "detail": true, "variables": variables, "messages": [ { "role": "user", "content": sendContent } ] }, tender?.creator)) .set({ Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }); const content = res.body.choices[0].message.content; lastContent = content; enqueueGenerateUsage({ creator: tender?.creator, docId: tender?.id, chapterKey: `chapter:${segment.id}`, responseBody: res.body, ignoreCreateFree: rewriteOverLimit, allowChapterFirstFree: !rewriteOverLimit, }); await models.V2TenderChapterVersions.create( { tenderId: tender.id, chapterId: segment.id, version: 1, isCurrent: true, content: content }, { transaction } ); } } } } else { // throw '无效的章节级别'; } await transaction.commit(); if (usageTrackQueue.length > 0) { setImmediate(() => { Promise.allSettled( usageTrackQueue.map((payload) => trackGenerateUsage(ctx, payload)) ).catch((usageError) => { ctx?.logger?.log?.(usageError); }); }); } await reportBusinessCall({ ctx, applicationId: 'stable-tender', eventId: `tender:${tenderId}:rewrite:${Date.now()}`, traceId: `tender:${tenderId}:rewrite:${chapterId}`, userId: tender?.creator, }); ctx.status = 204; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == 'string' ? error : '重写章节内容失败' }; } }; module.exports.getChapterVersions = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { chapterId } = ctx.params; const versions = await models.V2TenderChapterVersions.findAll({ where: { chapterId: Number(chapterId) }, order: [['version', 'desc']], raw: true }); ctx.status = 200; ctx.body = versions; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: '获取章节版本列表失败' }; } }; module.exports.saveChapterEdit = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models } = ctx.app.fs.dc; const { tenderId, chapterId } = ctx.params; const { content } = ctx.request.body; // 旧版本处理 const oldVersion = await models.V2TenderChapterVersions.findOne({ where: { chapterId: Number(chapterId), isCurrent: true }, raw: true }); if (oldVersion) { await models.V2TenderChapterVersions.update({ isCurrent: false }, { where: { chapterId: Number(chapterId), isCurrent: true }, transaction }); } await models.V2TenderChapterVersions.create({ tenderId: Number(tenderId), chapterId: Number(chapterId), version: oldVersion ? oldVersion.version + 1 : 1, isCurrent: true, content: content }, { transaction }); await transaction.commit(); ctx.status = 204; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: '保存章节编辑失败' }; } };