feat: 合同审查轮询方案(先审查后扣积分、HTML报告下载、数据库记录)

This commit is contained in:
2026-10-13 17:36:38 +08:00
parent 904fd1f8f1
commit 4625e7c130
83 changed files with 845 additions and 350 deletions
@@ -23,7 +23,7 @@
"concurrency": 1,
"memorySize": 512,
"path": "/http/router",
"timeout": 60,
"timeout": 600,
"triggers": [],
"keepRunningAfterReturn": false
},
@@ -0,0 +1,30 @@
module.exports = {
main: async (event) => {
let { data = {}, userInfo, util } = event;
let { db } = util;
const { pageIndex = 1, pageSize = 50 } = data;
const uid = (data && data.uid) || '';
if (!uid) return { code: -1, msg: '未登录' };
const where = { user_id: uid };
const countRes = await db.collection('jjx-review-records').where(where).count();
const listRes = await db.collection('jjx-review-records')
.where(where)
.orderBy('_add_time', 'desc')
.skip((pageIndex - 1) * pageSize)
.limit(pageSize)
.get();
return {
code: 0,
msg: 'ok',
data: {
total: countRes.total,
rows: listRes.data
}
};
}
};
@@ -1,47 +1,27 @@
// 云函数:client/ai/pub/getReviewResult
module.exports = {
main: async (event) => {
let { data = {} } = event;
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
const { chat_id, conversation_id } = data;
if (!chat_id || !conversation_id) return { code: -1, msg: '参数不完整' };
// 查询对话状态
const statusRes = await uniCloud.httpclient.request(
'https://api.coze.cn/v3/chat?chat_id=' + chat_id + '&conversation_id=' + conversation_id + '&bot_id=7689821072762011698',
{ method: 'GET', dataType: 'text', headers: { 'Authorization': 'Bearer ' + COZE_TOKEN }, timeout: 15000 }
);
let status = 'unknown';
try {
const parsed = JSON.parse(statusRes.data);
status = parsed.data && parsed.data.status;
} catch (e) {}
if (status !== 'completed') {
return { code: 0, data: { status } };
}
// 获取消息
const msgRes = await uniCloud.httpclient.request(
'https://api.coze.cn/v3/chat/message/list?chat_id=' + chat_id + '&conversation_id=' + conversation_id,
{ method: 'GET', dataType: 'text', headers: { 'Authorization': 'Bearer ' + COZE_TOKEN }, timeout: 15000 }
);
let answer = '';
try {
const parsed = JSON.parse(msgRes.data);
const messages = parsed.data;
if (Array.isArray(messages)) {
for (const m of messages) {
if (m.type === 'answer' && m.role === 'assistant') {
answer = m.content;
break;
}
}
let { data = {}, userInfo, util } = event;
let { db } = util;
const { record_id } = data;
if (!record_id) return { code: -1, msg: '缺少 record_id' };
const record = await db.collection('jjx-review-records').doc(record_id).get();
if (!record.data.length) return { code: -1, msg: '记录不存在' };
const rec = record.data[0];
return {
code: 0,
data: {
status: rec.status,
result: rec.result || '',
points_used: rec.points_used || 0,
html_url: rec.html_url || '',
contract_info: rec.contract_info || {},
error: rec.error || ''
}
} catch (e) {}
return { code: 0, data: { status: 'completed', answer } };
};
}
};
};
@@ -0,0 +1,137 @@
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 接口(流式模式,等待完整响应,和 Demo 一致)
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
method: 'POST',
dataType: 'text',
contentType: 'json',
timeout: 300000, // 5分钟超时
headers: { 'Authorization': 'Bearer ' + COZE_TOKEN },
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'
}]
}
});
// 从 SSE 流中提取回答
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];
}
}
} 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()
});
}
// 从回复中提取 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()
});
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()
});
return { code: -1, msg: '审查失败: ' + e.message };
}
}
};
@@ -1,12 +1,130 @@
// Markdown 转 HTML
function mdToHtml(md) {
if (!md) return '';
let html = md;
// 标题
html = html.replace(/^####\s+(.+)$/gm, '<h5>$1</h5>');
html = html.replace(/^###\s+(.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^##\s+(.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^#\s+(.+)$/gm, '<h2>$1</h2>');
// 加粗
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// 链接
html = html.replace(/(https?:\/\/[^\s\)\]\"\<]+)/g, '<a href="$1" target="_blank">$1</a>');
// 代码块
html = html.replace(/```[\w]*\n([\s\S]*?)```/g, '<pre class="code-block">$1</pre>');
// 行内代码
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// 列表
html = html.replace(/^[\-\•]\s+(.+)$/gm, '<li>$1</li>');
html = html.replace(/^(\d+)\.\s+(.+)$/gm, '<li>$1. $2</li>');
// 换行
html = html.replace(/\n\n/g, '</p><p>');
html = html.replace(/\n/g, '<br>');
html = '<p>' + html + '</p>';
// 清理空段落
html = html.replace(/<p>\s*<\/p>/g, '');
return html;
}
// 生成精美 HTML 报告
function generateHtmlReport(markdown, fileName, reportDate, info) {
const riskLevel = info.risk_level || '待评估';
const riskSummary = info.risk_summary || '';
const riskCount = info.risk_count || { high: 0, medium: 0, low: 0 };
const contractName = info.contract_name || fileName || '合同审查报告';
const contractType = info.contract_type || '';
// 风险等级颜色
let riskColor = '#67c23a';
if (riskLevel.includes('高')) riskColor = '#f56c6c';
else if (riskLevel.includes('中高')) riskColor = '#e6a23c';
else if (riskLevel.includes('中')) riskColor = '#e6a23c';
else if (riskLevel.includes('中低')) riskColor = '#409eff';
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${contractName} - 审查报告</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: #f5f7fa; color: #333; line-height: 1.8; }
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #a92924, #d4574e); color: #fff; padding: 40px; border-radius: 12px 12px 0 0; }
.header h1 { font-size: 24px; margin-bottom: 8px; }
.header .subtitle { opacity: 0.85; font-size: 14px; }
.summary-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 24px 40px; background: #fff; border-bottom: 1px solid #eee; }
.summary-card { text-align: center; padding: 16px; border-radius: 8px; background: #f9f9f9; }
.summary-card .label { font-size: 12px; color: #999; margin-bottom: 4px; }
.summary-card .value { font-size: 20px; font-weight: 700; }
.body { background: #fff; padding: 32px 40px; border-radius: 0 0 12px 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
.body h2 { font-size: 18px; color: #222; margin: 28px 0 12px; padding-bottom: 8px; border-bottom: 2px solid #a92924; }
.body h3 { font-size: 16px; color: #333; margin: 20px 0 8px; }
.body h4 { font-size: 14px; color: #444; margin: 16px 0 6px; }
.body p { margin: 8px 0; font-size: 14px; }
.body li { margin: 4px 0 4px 20px; font-size: 14px; }
.body strong { color: #a92924; }
.body a { color: #a92924; text-decoration: underline; }
.risk-badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600; color: #fff; }
.risk-high { background: #f56c6c; }
.risk-medium { background: #e6a23c; }
.risk-low { background: #67c23a; }
.code-block { background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 12px 16px; font-size: 13px; overflow-x: auto; margin: 8px 0; font-family: "SFMono-Regular", Consolas, monospace; }
.disclaimer { margin-top: 32px; padding: 16px 20px; background: #fff8e1; border-left: 4px solid #e6a23c; border-radius: 4px; font-size: 12px; color: #b8860b; }
.footer { text-align: center; padding: 24px; font-size: 12px; color: #999; }
@media print { body { background: #fff; } .container { padding: 0; } .header { border-radius: 0; } .body { box-shadow: none; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📋 合同审查报告</h1>
<div class="subtitle">${contractName} | ${reportDate}</div>
</div>
<div class="summary-cards">
<div class="summary-card">
<div class="label">综合风险等级</div>
<div class="value" style="color:${riskColor}">${riskLevel}</div>
</div>
<div class="summary-card">
<div class="label">风险统计</div>
<div class="value"><span style="color:#f56c6c">${riskCount.high||0}</span> / <span style="color:#e6a23c">${riskCount.medium||0}</span> / <span style="color:#67c23a">${riskCount.low||0}</span></div>
</div>
<div class="summary-card">
<div class="label">合同类型</div>
<div class="value" style="font-size:14px">${contractType || '待识别'}</div>
</div>
</div>
<div class="body">
<h2>总体评估</h2>
<p>${riskSummary || '暂无总体评估'}</p>
<h2>详细审查意见</h2>
${mdToHtml(markdown.replace(/```json[\s\S]*?```/, ''))}
<div class="disclaimer">
⚠️ <strong>免责声明</strong>:本报告由 AI 系统自动生成,仅供参考,不构成正式法律意见。涉及重大金额或复杂交易的合同,建议咨询执业律师。
</div>
</div>
<div class="footer">
金久信签约宝 · AI 合同审查服务 | 报告生成时间:${new Date().toLocaleString('zh-CN')}
</div>
</div>
</body>
</html>`;
}
module.exports = {
main: async (event) => {
let { data = {}, userInfo } = event;
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
const BOT_ID = '7689821072762011698';
const db = uniCloud.database();
const _ = db.command;
let fileId = data.file_id;
// 模式1:只上传文件(选文件时调用)
// ===== 模式1:上传文件 + 创建待审查记录 =====
if (data.upload_only && data.file_base64) {
const fileBuffer = Buffer.from(data.file_base64, 'base64');
const boundary = '----FormBoundary' + Date.now();
@@ -28,58 +146,121 @@ module.exports = {
'Content-Length': uploadBody.length.toString()
},
content: uploadBody,
timeout: 60000
timeout: 600000
});
if (uploadRes.data.code !== 0) {
return { code: -1, msg: '文件上传失败: ' + uploadRes.data.msg };
}
fileId = uploadRes.data.data.id;
return { code: 0, msg: 'ok', data: { file_id: fileId } };
// 同时上传到 uniCloud 云存储(用于查看/下载)
let fileUrl = '';
try {
const cloudPath = 'review-files/' + Date.now() + '_' + (data.file_name || 'file');
const uploadFileRes = await uniCloud.uploadFile({
cloudPath: cloudPath,
fileContent: fileBuffer
});
if (uploadFileRes.fileID) {
const urlRes = await uniCloud.getTempFileURL({
fileList: [uploadFileRes.fileID]
});
if (urlRes.fileList && urlRes.fileList[0]) {
fileUrl = urlRes.fileList[0].tempFileURL;
}
}
} catch (e) {
console.log('云存储上传失败(不影响审查):', e.message);
}
// 创建待审查记录
const recordRes = await db.collection('jjx-review-records').add({
user_id: (data && data.uid) || 'anonymous',
file_name: data.file_name || '',
file_size: data.file_size || 0,
file_id: fileId,
file_url: fileUrl,
chat_id: '',
conversation_id: '',
status: 'uploaded',
result: '',
points_used: 0,
_add_time: new Date(),
update_time: new Date()
});
return { code: 0, msg: 'ok', data: { file_id: fileId, record_id: recordRes.id, file_url: fileUrl } };
} catch (e) {
return { code: -1, msg: '上传异常: ' + e.message };
}
}
// 模式2:发起审查(不等结果,立即返回 chat_id)
if (!fileId) {
return { code: -1, msg: '缺少 file_id' };
// ===== 模式2:发起审查(只更新状态,立即返回)=====
if (!fileId) return { code: -1, msg: '缺少 file_id' };
const recordId = data.record_id;
if (!recordId) return { code: -1, msg: '缺少 record_id' };
// 检查当前状态
const record = await db.collection('jjx-review-records').doc(recordId).get();
if (record.data.length) {
const rec = record.data[0];
// 已完成,直接返回结果
if (rec.status === 'completed') {
return {
code: 0, msg: 'ok',
data: {
record_id: recordId,
status: 'completed',
result: rec.result,
points_used: rec.points_used,
html_url: rec.html_url || '',
contract_info: rec.contract_info || {}
}
};
}
// 正在审查中,直接返回
if (rec.status === 'reviewing') {
return { code: 0, msg: 'ok', data: { record_id: recordId, status: 'reviewing' } };
}
// 失败,返回错误
if (rec.status === 'failed') {
return { code: 0, msg: 'ok', data: { record_id: recordId, status: 'failed', result: rec.result || '审查失败' } };
}
}
const objectString = JSON.stringify([
{ type: 'file', file_id: fileId },
{ type: 'text', text: '请审查这份合同' }
]);
// 更新状态为审查中
await db.collection('jjx-review-records').doc(recordId).update({
file_id: fileId,
file_name: data.file_name || '',
file_size: data.file_size || 0,
status: 'reviewing',
review_started_at: new Date(),
update_time: new Date()
});
// ===== 触发实际审查(await 确保数据库更新完成)=====
try {
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 },
const doReview = require('./jjxDoReview.js');
await doReview.main({
data: {
bot_id: BOT_ID,
record_id: recordId,
file_id: fileId,
user_id: (userInfo && userInfo.uid) || 'anonymous',
stream: false,
auto_save_history: true,
additional_messages: [{
role: 'user',
content: objectString,
content_type: 'object_string'
}]
file_name: data.file_name || '',
file_size: data.file_size || 0
}
});
const chatData = chatRes.data && chatRes.data.data;
return {
code: 0, msg: 'ok',
data: {
chat_id: chatData && chatData.id,
conversation_id: chatData && chatData.conversation_id
}
};
} catch (e) {
return { code: -1, msg: '审查发起失败: ' + e.message };
console.error('审查任务执行失败:', e);
}
return {
code: 0, msg: 'ok',
data: { record_id: recordId, status: 'reviewing' }
};
}
};
@@ -2,13 +2,23 @@
module.exports = {
main: async (event) => {
let { data = {}, userInfo, util } = event;
let { db, _ } = util;
const { pageIndex = 1, pageSize = 20, type } = data;
let { vk, db, _ } = util;
const { pageIndex = 1, pageSize = 50, biz_type } = data;
console.log('getLog uid:', userInfo.uid);
// 先查全部看看有没有数据
const allRes = await db.collection('jjx-points-log').limit(5).get();
console.log('getLog all data count:', allRes.data.length);
if (allRes.data.length > 0) {
console.log('getLog first record:', JSON.stringify(allRes.data[0]));
}
const where = { user_id: userInfo.uid };
if (type) where.type = type; // 可选筛选 earn/spend
if (biz_type) where.biz_type = biz_type;
const countRes = await db.collection('jjx-points-log').where(where).count();
console.log('getLog filtered count:', countRes.total);
const listRes = await db.collection('jjx-points-log')
.where(where)