feat: 合同审查异步方案(定时任务触发、非流式轮询扣子API、token计费、HTML报告下载)

This commit is contained in:
2026-10-14 21:27:11 +08:00
parent 4625e7c130
commit 4700ea4d98
11 changed files with 706 additions and 255 deletions
@@ -0,0 +1,53 @@
// client/ai/pub/doReviewTimer.js
module.exports = {
main: async (event) => {
let { data = {} } = event;
const { record_id, file_id, user_id, file_name, file_size } = data;
if (!record_id || !file_id) return { code: -1, msg: '参数错误' };
const db = uniCloud.database();
// 检查是否已经在审查中(防止重复触发)
const record = await db.collection('jjx-review-records').doc(record_id).get();
if (record.data.length && record.data[0].status === 'reviewing') {
// 检查是否刚触发(5分钟内不重复触发)
const lastUpdate = record.data[0].update_time;
if (lastUpdate && (Date.now() - new Date(lastUpdate).getTime() < 5 * 60 * 1000)) {
return { code: 0, msg: '已在审查中,跳过' };
}
}
// 更新状态为审查中
await db.collection('jjx-review-records').doc(record_id).update({
status: 'reviewing',
review_started_at: new Date(),
update_time: new Date()
});
// 调用实际的审查逻辑
try {
const doReview = require('./jjxDoReview.js');
await doReview.main({
data: {
record_id,
file_id,
user_id: user_id || 'anonymous',
file_name: file_name || '',
file_size: file_size || 0
}
});
return { code: 0, msg: '审查完成' };
} catch (e) {
console.error('审查失败:', 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 };
}
}
};
@@ -1,3 +1,4 @@
// client/ai/pub/jjxDoReview.js
module.exports = {
main: async (event) => {
let { data = {}, userInfo } = event;
@@ -17,13 +18,16 @@ module.exports = {
{ type: 'text', text: '请审查这份合同' }
]);
// 调用 Coze 接口(流式模式,等待完整响应,和 Demo 一致)
// 调用 Coze 接口(流式模式)
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
method: 'POST',
dataType: 'text',
dataType: 'stream', // 关键:改成 stream
contentType: 'json',
timeout: 300000, // 5分钟超时
headers: { 'Authorization': 'Bearer ' + COZE_TOKEN },
headers: {
'Authorization': 'Bearer ' + COZE_TOKEN,
'Accept': 'text/event-stream'
},
data: {
bot_id: BOT_ID,
user_id: user_id || 'anonymous',
@@ -37,92 +41,168 @@ module.exports = {
}
});
// 从 SSE 流中提取回答
// 从流中逐步解析
const stream = chatRes.data || chatRes.res;
let buffer = '';
let answer = '';
let conversationId = '';
const text = chatRes.data || '';
const lines = text.split('\n');
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const d = line.slice(5).trim();
if (d === '[DONE]' || d === '"[DONE]"') continue;
try {
const p = JSON.parse(d);
if (p.conversation_id) conversationId = p.conversation_id;
if (p.type === 'answer' && p.content) answer += p.content;
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];
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;
// 处理回答内容
if (p.type === 'answer' && p.content) {
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) {}
}
} catch (e) {}
}
if (!answer) {
answer = '审查未能获取结果,请稍后重试';
}
// 计算消耗积分
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()
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) {}
// 更新审查记录为完成
await db.collection('jjx-review-records').doc(record_id).update({
status: 'completed',
result: answer,
html_url: htmlUrl,
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);
}
});
}
// 从回复中提取 HTML 报告下载链接
let htmlUrl = '';
try {
const linkMatch = answer.match(/\[([^\]]*\.html)\]\((https?:\/\/[^\)]+)\)/);
if (linkMatch) {
htmlUrl = linkMatch[2];
} else {
const urlMatch = answer.match(/https?:\/\/[^\s\)\]]+\.html[^\s\)\]]*/);
if (urlMatch) htmlUrl = urlMatch[0];
}
} catch (e) {}
// 解析 JSON 结构化数据
let contractInfo = {};
try {
const jsonMatch = answer.match(/```json\s*([\s\S]*?)```/);
if (jsonMatch) contractInfo = JSON.parse(jsonMatch[1].trim());
} catch (e) {}
// 更新审查记录为完成
await db.collection('jjx-review-records').doc(record_id).update({
status: 'completed',
result: answer,
html_url: htmlUrl,
contract_info: contractInfo,
points_used: pointsUsed,
conversation_id: conversationId || '',
update_time: new Date()
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);
});
});
return { code: 0, msg: '审查完成' };
} catch (e) {
// 审查失败,更新记录
await db.collection('jjx-review-records').doc(record_id).update({
@@ -1,3 +1,4 @@
// client/ai/pub/review.js
// Markdown 转 HTML
function mdToHtml(md) {
if (!md) return '';
@@ -242,22 +243,7 @@ module.exports = {
update_time: new Date()
});
// ===== 触发实际审查(await 确保数据库更新完成)=====
try {
const doReview = require('./jjxDoReview.js');
await doReview.main({
data: {
record_id: recordId,
file_id: fileId,
user_id: (userInfo && userInfo.uid) || 'anonymous',
file_name: data.file_name || '',
file_size: data.file_size || 0
}
});
} catch (e) {
console.error('审查任务执行失败:', e);
}
// ===== 立即返回,审查由定时任务触发 =====
return {
code: 0, msg: 'ok',
data: { record_id: recordId, status: 'reviewing' }