Files
jjx-web/uniCloud-tcb/cloudfunctions/jjxReviewTimer/index.js
T
ngele 95024bb2a6 feat: AI审查报告支持下载Word + 我的报告支持重新审查
- 云函数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文件
2026-10-23 19:07:05 +08:00

282 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };
};