95024bb2a6
- 云函数jjxReviewTimer完成审查后自动将HTML报告转换为Word文档并上传 - 新增buildDocx.js纯正则解析HTML报告生成A4公文版式docx(三级数据提取兼容结构漂移) - 兼容智能体插件直接返回docx链接(jjx-docx-link标记协议) - router/jjxDoReview同步支持Word生成(try降级不影响主流程) - 前端报告列表新增下载Word/重新审查按钮 - 修复上传/查询取真实uid(vk.getVuex而非页面本地data) - login.js清理调试日志、.gitignore忽略node_modules与lock文件
277 lines
11 KiB
JavaScript
277 lines
11 KiB
JavaScript
// client/ai/pub/jjxDoReview.js
|
||
|
||
// Word 报告生成器(依赖 docx,未安装时降级跳过,不影响审查)
|
||
let buildDocx = null;
|
||
let extractReportData = null;
|
||
let extractDocxLink = null;
|
||
let isDocxConvertible = null;
|
||
try {
|
||
const docxMod = require('./buildDocx.js');
|
||
buildDocx = docxMod.buildDocx;
|
||
extractReportData = docxMod.extractReportData;
|
||
extractDocxLink = docxMod.extractDocxLink;
|
||
isDocxConvertible = docxMod.isDocxConvertible;
|
||
} catch (e) {
|
||
console.log('[Docx] docx 依赖未安装,跳过 Word 生成:', e.message);
|
||
}
|
||
|
||
module.exports = {
|
||
main: async (event) => {
|
||
let { data = {}, userInfo } = event;
|
||
const { record_id, file_id, user_id, file_name, file_size } = data;
|
||
|
||
if (!record_id || !file_id) return { code: -1, msg: '参数错误' };
|
||
|
||
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
|
||
const BOT_ID = '7689821072762011698';
|
||
const db = uniCloud.database();
|
||
const _ = db.command;
|
||
|
||
try {
|
||
// 构造请求
|
||
const objectString = JSON.stringify([
|
||
{ type: 'file', file_id: file_id },
|
||
{ type: 'text', text: '请审查这份合同' }
|
||
]);
|
||
|
||
// 调用 Coze 接口(流式模式)
|
||
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
|
||
method: 'POST',
|
||
dataType: 'stream', // 关键:改成 stream
|
||
contentType: 'json',
|
||
timeout: 300000, // 5分钟超时
|
||
headers: {
|
||
'Authorization': 'Bearer ' + COZE_TOKEN,
|
||
'Accept': 'text/event-stream'
|
||
},
|
||
data: {
|
||
bot_id: BOT_ID,
|
||
user_id: user_id || 'anonymous',
|
||
stream: true,
|
||
auto_save_history: true,
|
||
additional_messages: [{
|
||
role: 'user',
|
||
content: objectString,
|
||
content_type: 'object_string'
|
||
}]
|
||
}
|
||
});
|
||
|
||
// 从流中逐步解析
|
||
const stream = chatRes.data || chatRes.res;
|
||
let buffer = '';
|
||
let answer = '';
|
||
let conversationId = '';
|
||
let lastUpdateLen = 0;
|
||
|
||
// 返回 Promise,等待流结束
|
||
return await new Promise((resolve, reject) => {
|
||
|
||
stream.on('data', (chunk) => {
|
||
buffer += chunk.toString();
|
||
const blocks = buffer.split('\n\n');
|
||
buffer = blocks.pop();
|
||
|
||
for (const block of blocks) {
|
||
const lines = block.split('\n');
|
||
let eventName = '';
|
||
let dataStr = '';
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
||
if (line.startsWith('data:')) dataStr = line.slice(5).trim();
|
||
}
|
||
|
||
if (!dataStr || dataStr === '[DONE]' || dataStr === '"[DONE]"') continue;
|
||
|
||
try {
|
||
const p = JSON.parse(dataStr);
|
||
if (p.conversation_id) conversationId = p.conversation_id;
|
||
|
||
// 处理回答内容
|
||
// 注意:conversation.message.completed 携带的是该条消息的完整内容,
|
||
// 若前面已经通过 delta 拼过,再拼一次就会重复,这里做去重
|
||
if (p.type === 'answer' && p.content) {
|
||
const isDup = eventName === 'conversation.message.completed' && answer.indexOf(p.content) >= 0;
|
||
if (!isDup) {
|
||
answer += p.content;
|
||
}
|
||
|
||
// 每积累 200 字更新一次数据库(前端轮询能看到进度)
|
||
if (answer.length - lastUpdateLen >= 200) {
|
||
lastUpdateLen = answer.length;
|
||
db.collection('jjx-review-records').doc(record_id).update({
|
||
result: answer,
|
||
status: 'reviewing',
|
||
update_time: new Date()
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
|
||
// 处理工具响应(报告下载链接)
|
||
if (p.type === 'tool_response' && p.content) {
|
||
try {
|
||
const toolData = JSON.parse(p.content);
|
||
if (toolData.url) answer += '\n\n📎 报告下载:' + toolData.url;
|
||
} catch (e) {
|
||
const urlMatch = p.content.match(/https?:\/\/[^\s\)\]\"\<]+\.pdf[^\s\)\]\"\<]*/i);
|
||
if (urlMatch) answer += '\n\n📎 报告下载:' + urlMatch[0];
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
});
|
||
|
||
stream.on('end', async () => {
|
||
if (!answer) {
|
||
answer = '审查未能获取结果,请稍后重试';
|
||
}
|
||
|
||
try {
|
||
// 计算消耗积分
|
||
const pointsUsed = Math.max(150, Math.ceil((file_size || 0) / (100 * 1024)) * 150);
|
||
|
||
// 扣积分
|
||
const userPoints = await db.collection('jjx-user-points').doc(user_id).get();
|
||
if (userPoints.data.length && userPoints.data[0].balance >= pointsUsed) {
|
||
await db.collection('jjx-user-points').doc(user_id).update({
|
||
balance: _.inc(-pointsUsed),
|
||
total_spent: _.inc(pointsUsed),
|
||
update_time: new Date()
|
||
});
|
||
|
||
const updatedPoints = await db.collection('jjx-user-points').doc(user_id).get();
|
||
await db.collection('jjx-points-log').add({
|
||
user_id: user_id,
|
||
type: 'spend',
|
||
amount: -pointsUsed,
|
||
balance_after: updatedPoints.data[0] ? updatedPoints.data[0].balance : 0,
|
||
reason: '合同审查 — ' + (file_name || ''),
|
||
biz_type: 'contract_review',
|
||
biz_id: record_id,
|
||
_add_time: new Date()
|
||
});
|
||
}
|
||
|
||
// 保存 HTML 报告到云存储
|
||
let htmlUrl = '';
|
||
try {
|
||
// 如果回复本身就是 HTML(包含 <!DOCTYPE html> 或 <html>)
|
||
let htmlContent = answer;
|
||
const htmlMatch = answer.match(/```html\s*([\s\S]*?)```/);
|
||
if (htmlMatch && htmlMatch[1]) {
|
||
htmlContent = htmlMatch[1].trim();
|
||
}
|
||
|
||
if (htmlContent.includes('<!DOCTYPE') || htmlContent.includes('<html')) {
|
||
const cloudPath = 'review-reports/' + Date.now() + '_' + (file_name || 'report').replace(/\.[^.]+$/, '') + '.html';
|
||
const uploadRes = await uniCloud.uploadFile({
|
||
cloudPath: cloudPath,
|
||
fileContent: Buffer.from(htmlContent, 'utf-8')
|
||
});
|
||
if (uploadRes.fileID) {
|
||
const urlRes = await uniCloud.getTempFileURL({ fileList: [uploadRes.fileID] });
|
||
if (urlRes.fileList && urlRes.fileList[0]) {
|
||
htmlUrl = urlRes.fileList[0].tempFileURL;
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.log('HTML报告上传失败:', e.message);
|
||
}
|
||
|
||
// 解析 JSON 结构化数据(如果有的话)
|
||
let contractInfo = {};
|
||
try {
|
||
const jsonMatch = answer.match(/```json\s*([\s\S]*?)```/);
|
||
if (jsonMatch) contractInfo = JSON.parse(jsonMatch[1].trim());
|
||
} catch (e) {}
|
||
|
||
// 生成 Word 报告:优先用智能体插件生成的链接,否则本地结构化重建
|
||
let docxUrl = extractDocxLink ? extractDocxLink(answer) : '';
|
||
let docxFileId = '';
|
||
if (docxUrl) {
|
||
console.log('[Docx] 采用智能体插件生成的 Word 链接:', docxUrl);
|
||
} else if (isDocxConvertible && !isDocxConvertible(answer)) {
|
||
// 智能体没返回完整报告(道歉/澄清文本,或 HTML 被截断),不生成残缺 Word
|
||
console.log('[Docx] 无完整 HTML 报告,跳过 Word 生成');
|
||
} else if (buildDocx && extractReportData) {
|
||
try {
|
||
const reportData = extractReportData(answer);
|
||
if (reportData) {
|
||
const docxBuffer = await buildDocx({
|
||
data: reportData,
|
||
fileName: file_name || '合同审查报告'
|
||
});
|
||
const baseName = (file_name || 'report').replace(/\.[^.]+$/, '');
|
||
const docxPath = 'review-reports/' + Date.now() + '_' + baseName + '_审查报告.docx';
|
||
const docxRes = await uniCloud.uploadFile({
|
||
cloudPath: docxPath,
|
||
fileContent: docxBuffer
|
||
});
|
||
if (docxRes.fileID) {
|
||
docxFileId = docxRes.fileID;
|
||
const docxUrlRes = await uniCloud.getTempFileURL({ fileList: [docxRes.fileID] });
|
||
if (docxUrlRes.fileList && docxUrlRes.fileList[0]) {
|
||
docxUrl = docxUrlRes.fileList[0].tempFileURL;
|
||
}
|
||
}
|
||
} else {
|
||
console.log('[Docx] 既无插件链接也无结构化数据,跳过 Word 生成');
|
||
}
|
||
} catch (e) {
|
||
console.log('[Docx] Word 报告生成失败(不影响审查结果):', e.message);
|
||
}
|
||
}
|
||
|
||
// 更新审查记录为完成
|
||
await db.collection('jjx-review-records').doc(record_id).update({
|
||
status: 'completed',
|
||
result: answer,
|
||
html_url: htmlUrl,
|
||
docx_url: docxUrl,
|
||
docx_file_id: docxFileId,
|
||
contract_info: contractInfo,
|
||
points_used: pointsUsed,
|
||
conversation_id: conversationId || '',
|
||
update_time: new Date()
|
||
});
|
||
|
||
resolve({ code: 0, msg: '审查完成' });
|
||
|
||
} catch (e) {
|
||
// 更新为失败
|
||
await db.collection('jjx-review-records').doc(record_id).update({
|
||
status: 'failed',
|
||
result: '审查失败: ' + e.message,
|
||
error: e.message,
|
||
update_time: new Date()
|
||
});
|
||
reject(e);
|
||
}
|
||
});
|
||
|
||
stream.on('error', async (err) => {
|
||
await db.collection('jjx-review-records').doc(record_id).update({
|
||
status: 'failed',
|
||
result: '审查失败: ' + err.message,
|
||
error: err.message,
|
||
update_time: new Date()
|
||
});
|
||
reject(err);
|
||
});
|
||
|
||
});
|
||
|
||
} catch (e) {
|
||
// 审查失败,更新记录
|
||
await db.collection('jjx-review-records').doc(record_id).update({
|
||
status: 'failed',
|
||
result: '审查失败: ' + e.message,
|
||
error: e.message,
|
||
update_time: new Date()
|
||
});
|
||
return { code: -1, msg: '审查失败: ' + e.message };
|
||
}
|
||
}
|
||
}; |