3011f41df1
- 云函数jjxReviewTimer完成审查后自动将HTML报告转换为Word文档并上传 - 新增buildDocx.js纯正则解析HTML报告生成A4公文版式docx(三级数据提取兼容结构漂移) - 兼容智能体插件直接返回docx链接(jjx-docx-link标记协议) - router/jjxDoReview同步支持Word生成(try降级不影响主流程) - 前端报告列表新增下载Word/重新审查按钮 - 修复上传/查询取真实uid(vk.getVuex而非页面本地data)
282 lines
10 KiB
JavaScript
282 lines
10 KiB
JavaScript
'use strict';
|
||
|
||
// 定时任务:每分钟检查 reviewing 状态的记录,触发审查
|
||
const db = uniCloud.database();
|
||
const _ = db.command;
|
||
|
||
// 导入审查执行函数(从 router 的 service 目录)
|
||
// 注意:定时任务是独立云函数,不能直接 require router 的 service
|
||
// 所以把审查逻辑直接写在这里
|
||
|
||
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
|
||
const BOT_ID = '7689821072762011698';
|
||
|
||
const { buildDocx, extractReportData, extractDocxLink, isDocxConvertible } = require('./buildDocx.js');
|
||
|
||
async function doReview(record) {
|
||
const { _id: record_id, file_id, user_id, file_name, file_size } = record;
|
||
|
||
const objectString = JSON.stringify([
|
||
{ type: 'file', file_id: file_id },
|
||
{ type: 'text', text: '请审查这份合同' }
|
||
]);
|
||
|
||
try {
|
||
// 1. 非流式发起对话(立即返回 chat_id)
|
||
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
|
||
method: 'POST',
|
||
dataType: 'json',
|
||
contentType: 'json',
|
||
timeout: 30000,
|
||
headers: { 'Authorization': 'Bearer ' + COZE_TOKEN },
|
||
data: {
|
||
bot_id: BOT_ID,
|
||
user_id: user_id || 'anonymous',
|
||
stream: false,
|
||
auto_save_history: true,
|
||
additional_messages: [{
|
||
role: 'user',
|
||
content: objectString,
|
||
content_type: 'object_string'
|
||
}]
|
||
}
|
||
});
|
||
|
||
const chatData = chatRes.data && chatRes.data.data;
|
||
const chatId = chatData && chatData.id;
|
||
const conversationId = chatData && chatData.conversation_id;
|
||
console.log('[审查] chat_id:', chatId, 'conversation_id:', conversationId);
|
||
|
||
if (!chatId) {
|
||
throw new Error('未获取到 chat_id: ' + JSON.stringify(chatRes.data));
|
||
}
|
||
|
||
// 保存 chat_id 和 conversation_id 到数据库
|
||
await db.collection('jjx-review-records').doc(record_id).update({
|
||
chat_id: chatId,
|
||
conversation_id: conversationId || '',
|
||
update_time: new Date()
|
||
});
|
||
console.log('[审查] 已保存 chat_id:', chatId);
|
||
|
||
// 2. 轮询对话状态(每5秒查一次,最多5分钟)
|
||
let answer = '';
|
||
let tokenCount = 0;
|
||
for (let i = 0; i < 60; i++) {
|
||
await new Promise(r => setTimeout(r, 5000));
|
||
|
||
const statusRes = await uniCloud.httpclient.request(
|
||
'https://api.coze.cn/v3/chat/retrieve?chat_id=' + chatId + '&conversation_id=' + conversationId,
|
||
{ method: 'GET', dataType: 'json', headers: { 'Authorization': 'Bearer ' + COZE_TOKEN }, timeout: 15000 }
|
||
);
|
||
|
||
const status = statusRes.data && statusRes.data.data && statusRes.data.data.status;
|
||
console.log('[审查] 第', i + 1, '次轮询, 状态:', status);
|
||
|
||
if (status === 'completed') {
|
||
// 保存 token 消耗信息
|
||
const usage = statusRes.data && statusRes.data.data && statusRes.data.data.usage;
|
||
tokenCount = (usage && usage.token_count) || 0;
|
||
console.log('[审查] Token 消耗:', tokenCount);
|
||
|
||
// 3. 获取消息列表
|
||
const msgRes = await uniCloud.httpclient.request(
|
||
'https://api.coze.cn/v3/chat/message/list?chat_id=' + chatId + '&conversation_id=' + conversationId,
|
||
{ method: 'GET', dataType: 'json', headers: { 'Authorization': 'Bearer ' + COZE_TOKEN }, timeout: 15000 }
|
||
);
|
||
|
||
const messages = msgRes.data && msgRes.data.data;
|
||
if (Array.isArray(messages)) {
|
||
for (const msg of messages) {
|
||
if (msg.type === 'answer' && msg.role === 'assistant' && msg.content) {
|
||
answer = msg.content; // answer 类型的完整内容
|
||
}
|
||
if (msg.type === 'tool_response' && msg.content) {
|
||
try {
|
||
const toolData = JSON.parse(msg.content);
|
||
if (toolData.url) answer += '\n\n📎 报告下载:' + toolData.url;
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
if (status === 'failed') {
|
||
throw new Error('对话失败');
|
||
}
|
||
}
|
||
|
||
if (!answer) answer = '审查未能获取结果,请稍后重试';
|
||
console.log('[审查] 结果长度:', answer.length);
|
||
|
||
// 计算消耗积分
|
||
// 积分 = token消耗 / 100(最低1积分)
|
||
const pointsUsed = Math.max(1, Math.round(tokenCount / 100));
|
||
console.log('[积分] token_count:', tokenCount, '→ 扣除积分:', pointsUsed);
|
||
|
||
// 扣积分
|
||
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 || '') + '(' + tokenCount + ' token)',
|
||
biz_type: 'contract_review',
|
||
biz_id: record_id,
|
||
_add_time: new Date()
|
||
});
|
||
}
|
||
|
||
// 保存 HTML 报告到云存储
|
||
let htmlUrl = '';
|
||
try {
|
||
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);
|
||
}
|
||
|
||
// 生成 Word 报告:优先用智能体插件生成的链接,否则本地结构化重建
|
||
let docxUrl = extractDocxLink(answer);
|
||
let docxFileId = '';
|
||
if (docxUrl) {
|
||
console.log('[Docx] 采用智能体插件生成的 Word 链接:', docxUrl);
|
||
} else if (!isDocxConvertible(answer)) {
|
||
// 智能体没返回完整报告(如「文件解析失败」的道歉文本,或 HTML 被截断),
|
||
// 不生成 Word,避免产出一份残缺文档
|
||
console.log('[Docx] 无完整 HTML 报告,跳过 Word 生成');
|
||
} else {
|
||
try {
|
||
const reportData = extractReportData(answer);
|
||
if (!reportData) {
|
||
console.log('[Docx] 既无插件链接也无结构化数据,跳过 Word 生成');
|
||
} else {
|
||
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;
|
||
}
|
||
console.log('[Docx] 本地 Word 报告生成成功:', docxFileId);
|
||
}
|
||
}
|
||
} 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,
|
||
token_count: tokenCount,
|
||
points_used: pointsUsed,
|
||
conversation_id: conversationId || '',
|
||
update_time: new Date()
|
||
});
|
||
|
||
console.log('审查完成:', record_id, file_name);
|
||
return { 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()
|
||
});
|
||
console.error('审查失败:', record_id, e.message);
|
||
return { code: -1, msg: '审查失败: ' + e.message };
|
||
}
|
||
}
|
||
|
||
exports.main = async (event, context) => {
|
||
// 先把卡在 processing 超过10分钟的记录重置为 reviewing(超时保护)
|
||
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
|
||
const stuckRecords = await db.collection('jjx-review-records')
|
||
.where({ status: 'processing', update_time: _.lt(tenMinutesAgo) })
|
||
.get();
|
||
for (const stuck of stuckRecords.data) {
|
||
await db.collection('jjx-review-records').doc(stuck._id).update({
|
||
status: 'reviewing',
|
||
update_time: new Date()
|
||
});
|
||
}
|
||
|
||
// 查询所有 reviewing 状态的记录(超过1分钟的,避免重复触发)
|
||
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
|
||
|
||
const pendingRecords = await db.collection('jjx-review-records')
|
||
.where({
|
||
status: 'reviewing',
|
||
review_started_at: _.lt(oneMinuteAgo)
|
||
})
|
||
.limit(5) // 每次最多处理5条
|
||
.get();
|
||
|
||
if (!pendingRecords.data.length) {
|
||
return { code: 0, msg: '无待处理记录' };
|
||
}
|
||
|
||
console.log('发现待处理审查记录:', pendingRecords.data.length, '条');
|
||
|
||
// 逐条处理(串行,避免并发问题)
|
||
const results = [];
|
||
for (const record of pendingRecords.data) {
|
||
// 先标记为 processing,避免重复触发
|
||
await db.collection('jjx-review-records').doc(record._id).update({
|
||
status: 'processing',
|
||
update_time: new Date()
|
||
});
|
||
|
||
try {
|
||
const result = await doReview(record);
|
||
results.push({ record_id: record._id, ...result });
|
||
} catch (e) {
|
||
// 审查失败,重置为 reviewing 以便下次重试
|
||
await db.collection('jjx-review-records').doc(record._id).update({
|
||
status: 'reviewing',
|
||
update_time: new Date()
|
||
});
|
||
console.error('审查异常,已重置状态:', record._id, e.message);
|
||
results.push({ record_id: record._id, code: -1, msg: e.message });
|
||
}
|
||
}
|
||
|
||
return { code: 0, msg: '处理完成', data: results };
|
||
}; |