feat: 合同审查异步方案(定时任务触发、非流式轮询扣子API、token计费、HTML报告下载)
This commit is contained in:
+89
-149
@@ -543,7 +543,7 @@
|
|||||||
<view class="opt-icon">🔍</view>
|
<view class="opt-icon">🔍</view>
|
||||||
<view class="opt-title">标准审查</view>
|
<view class="opt-title">标准审查</view>
|
||||||
<view class="opt-desc">风险条款识别 · 合规性检查</view>
|
<view class="opt-desc">风险条款识别 · 合规性检查</view>
|
||||||
<view class="opt-points">{{ reviewPoints }} 积分</view>
|
<!-- <view class="opt-points">{{ reviewPoints }} 积分</view> -->
|
||||||
</view>
|
</view>
|
||||||
<!-- <view :class="['review-opt', reviewMode === 1 && 'selected']" @click="reviewMode = 1">
|
<!-- <view :class="['review-opt', reviewMode === 1 && 'selected']" @click="reviewMode = 1">
|
||||||
<view class="opt-icon">📊</view>
|
<view class="opt-icon">📊</view>
|
||||||
@@ -556,7 +556,7 @@
|
|||||||
<el-button type="primary" :loading="reviewLoading"
|
<el-button type="primary" :loading="reviewLoading"
|
||||||
:disabled="pointsBalance < reviewPoints + 200"
|
:disabled="pointsBalance < reviewPoints + 200"
|
||||||
@click="startReview">
|
@click="startReview">
|
||||||
{{ reviewLoading ? '审查中...' : '开始审查(' + reviewPoints + ' 积分)' }}
|
{{ reviewLoading ? '审查中...' : '开始审查' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<p v-if="pointsBalance < reviewPoints + 200" style="font-size:12px;color:#f56c6c;margin-top:6px;">
|
<p v-if="pointsBalance < reviewPoints + 200" style="font-size:12px;color:#f56c6c;margin-top:6px;">
|
||||||
⚠️ 积分不足,当前余额 {{ pointsBalance }},需要 {{ reviewPoints }} 积分且审查后余额需保留至少 200 积分。
|
⚠️ 积分不足,当前余额 {{ pointsBalance }},需要 {{ reviewPoints }} 积分且审查后余额需保留至少 200 积分。
|
||||||
@@ -574,11 +574,15 @@
|
|||||||
<!-- 审查结果 -->
|
<!-- 审查结果 -->
|
||||||
<view v-if="reviewResult">
|
<view v-if="reviewResult">
|
||||||
<view class="result-header">
|
<view class="result-header">
|
||||||
<h3>📋 审查结果 — {{ reviewFile.name }}</h3>
|
<h3>📋 {{ reviewStreaming ? 'AI 正在分析...' : '审查结果' }} — {{ reviewFile.name }}</h3>
|
||||||
<span class="result-status">✅ 审查完成</span>
|
<span class="result-status">{{ reviewStreaming ? '⏳ 分析中...' : '✅ 审查完成' }}</span>
|
||||||
</view>
|
</view>
|
||||||
<view class="result-body">
|
<view class="result-body" :style="reviewStreaming ? 'max-height:400px;overflow-y:auto;' : ''">
|
||||||
<div v-html="reviewResult"></div>
|
<div v-html="reviewResult"></div>
|
||||||
|
<div v-if="reviewStreaming" style="margin-top:8px;">
|
||||||
|
<span style="display:inline-block;width:8px;height:8px;background:#a92924;border-radius:50%;animation:pulse 1s infinite;"></span>
|
||||||
|
<span style="font-size:12px;color:#999;margin-left:6px;">AI 正在输出分析结果...</span>
|
||||||
|
</div>
|
||||||
</view>
|
</view>
|
||||||
<view class="result-footer">
|
<view class="result-footer">
|
||||||
<el-button v-if="reviewHtmlUrl" type="success" size="small" @click="openUrl(reviewHtmlUrl)">📄 查看完整 HTML 报告</el-button>
|
<el-button v-if="reviewHtmlUrl" type="success" size="small" @click="openUrl(reviewHtmlUrl)">📄 查看完整 HTML 报告</el-button>
|
||||||
@@ -603,12 +607,13 @@
|
|||||||
<view class="report-time">{{ r.time }} · 消耗 {{ Math.abs(r.amount) }} 积分</view>
|
<view class="report-time">{{ r.time }} · 消耗 {{ Math.abs(r.amount) }} 积分</view>
|
||||||
</view>
|
</view>
|
||||||
<el-tag v-if="r.status === 'uploaded'" size="mini" type="info">待审查</el-tag>
|
<el-tag v-if="r.status === 'uploaded'" size="mini" type="info">待审查</el-tag>
|
||||||
<el-tag v-else-if="r.status === 'reviewing'" size="mini" type="warning">审查中</el-tag>
|
<el-tag v-else-if="r.status === 'reviewing'" size="mini" type="warning">排队中</el-tag>
|
||||||
|
<el-tag v-else-if="r.status === 'processing'" size="mini" type="warning">审查中</el-tag>
|
||||||
<el-tag v-else-if="r.status === 'completed'" size="mini" type="success">已完成</el-tag>
|
<el-tag v-else-if="r.status === 'completed'" size="mini" type="success">已完成</el-tag>
|
||||||
<el-tag v-else-if="r.status === 'failed'" size="mini" type="danger">失败</el-tag>
|
<el-tag v-else-if="r.status === 'failed'" size="mini" type="danger">失败</el-tag>
|
||||||
<el-button v-if="r.htmlUrl" size="mini" type="text" @click="openUrl(r.htmlUrl)">查看报告</el-button>
|
<el-button v-if="r.htmlUrl" size="mini" type="text" @click="openUrl(r.htmlUrl)">查看报告</el-button>
|
||||||
<el-button v-if="r.fileUrl" size="mini" type="text" @click="previewFile(r)">查看文件</el-button>
|
<el-button v-if="r.fileUrl" size="mini" type="text" @click="previewFile(r)">查看文件</el-button>
|
||||||
<!-- <el-button v-if="r.status === 'completed'" size="mini" type="text" @click="viewReport(r)">查看摘要</el-button> -->
|
<el-button v-if="r.status === 'completed'" size="mini" type="text" @click="viewReport(r)">查看摘要</el-button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -872,6 +877,7 @@
|
|||||||
reviewHtmlUrl: '',
|
reviewHtmlUrl: '',
|
||||||
reviewPdfUrl: '',
|
reviewPdfUrl: '',
|
||||||
reviewContractInfo: {},
|
reviewContractInfo: {},
|
||||||
|
reviewStreaming: false,
|
||||||
reviewUploading: false,
|
reviewUploading: false,
|
||||||
reviewMode: 0,
|
reviewMode: 0,
|
||||||
reviewPoints: 100,
|
reviewPoints: 100,
|
||||||
@@ -882,34 +888,34 @@
|
|||||||
// 积分充值
|
// 积分充值
|
||||||
selectedRecharge: 1,
|
selectedRecharge: 1,
|
||||||
rechargePackages: [{
|
rechargePackages: [{
|
||||||
points: 500,
|
points: 10000,
|
||||||
price: '30元',
|
price: '10元',
|
||||||
bonus: ''
|
bonus: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
points: 1000,
|
points: 50000,
|
||||||
price: '50元',
|
price: '50元',
|
||||||
bonus: '送100积分'
|
bonus: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
points: 2000,
|
points: 100000,
|
||||||
price: '98元',
|
price: '100元',
|
||||||
bonus: '送300积分'
|
bonus: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
points: 5000,
|
points: 450000,
|
||||||
price: '198元',
|
price: '450元',
|
||||||
bonus: '送1000积分'
|
bonus: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
points: 10000,
|
points: 1000000,
|
||||||
price: '368元',
|
price: '1000元',
|
||||||
bonus: '送3000积分'
|
bonus: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
points: 20000,
|
points: 2000000,
|
||||||
price: '688元',
|
price: '2000元',
|
||||||
bonus: '送8000积分'
|
bonus: ''
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// 我的报告
|
// 我的报告
|
||||||
@@ -1671,6 +1677,14 @@
|
|||||||
openUrl(url) {
|
openUrl(url) {
|
||||||
if (url) window.open(url, '_blank');
|
if (url) window.open(url, '_blank');
|
||||||
},
|
},
|
||||||
|
fileToBase64(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result.split(',')[1]);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
},
|
||||||
formatReviewResult(text) {
|
formatReviewResult(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
let html = text;
|
let html = text;
|
||||||
@@ -1836,135 +1850,57 @@
|
|||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
|
// 前端 startReview() 完整改造
|
||||||
async startReview() {
|
async startReview() {
|
||||||
if (!this.reviewFileId) {
|
if (!this.reviewFileId) {
|
||||||
this.$message.warning('文件尚未上传完成,请稍候');
|
this.$message.warning('请先选择合同文件并等待上传完成');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointsNeeded = this.reviewPoints;
|
// 1. 检查积分
|
||||||
if (this.pointsBalance < pointsNeeded + 200) {
|
const balanceRes = await vk.callFunction({
|
||||||
this.$message.warning('积分不足,当前余额 ' + this.pointsBalance + ',需要 ' + pointsNeeded + ' 且审查后余额需保留至少 200 积分');
|
url: 'client/points/kh/getBalance'
|
||||||
return;
|
});
|
||||||
}
|
if (balanceRes.code !== 0 || !balanceRes.data || balanceRes.data.balance < 150) {
|
||||||
|
this.$message.warning('积分不足,请先充值');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.reviewLoading = true;
|
this.reviewLoading = true;
|
||||||
this.reviewProgress = 0;
|
|
||||||
|
|
||||||
// 模拟进度条
|
// 2. 发起审查(更新已有记录状态为 reviewing,用 selectReviewFile 时保存的 file_id)
|
||||||
const progressTimer = setInterval(() => {
|
const reviewRes = await vk.callFunction({
|
||||||
this.reviewProgress = Math.min(this.reviewProgress + 1, 90);
|
url: 'client/ai/pub/review',
|
||||||
}, 1500);
|
data: {
|
||||||
|
record_id: this.reviewRecordId,
|
||||||
|
file_id: this.reviewFileId,
|
||||||
|
file_name: this.reviewFile.name,
|
||||||
|
file_size: this.reviewFile.size,
|
||||||
|
uid: this.userInfo.uid
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
this.reviewLoading = false;
|
||||||
// 1. 发起审查(只更新状态,立即返回)
|
|
||||||
const res = await vk.callFunction({
|
|
||||||
url: 'client/ai/pub/review',
|
|
||||||
data: {
|
|
||||||
file_id: this.reviewFileId,
|
|
||||||
record_id: this.reviewRecordId,
|
|
||||||
file_name: this.reviewFile.name,
|
|
||||||
file_size: this.reviewFile.size
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.code !== 0) {
|
if (reviewRes.code !== 0) {
|
||||||
this.$message.error(res.msg || '审查发起失败');
|
this.$message.error(reviewRes.msg || '发起审查失败');
|
||||||
this.reviewLoading = false;
|
return;
|
||||||
clearInterval(progressTimer);
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordId = res.data.record_id;
|
// 3. 提示用户 + 跳转到我的报告
|
||||||
|
this.$alert(
|
||||||
|
'合同已提交审查,AI 正在分析中,预计 3-5 分钟完成。您可以在「我的报告」中查看审查结果。',
|
||||||
|
'审查已提交',
|
||||||
|
{
|
||||||
|
confirmButtonText: '查看我的报告',
|
||||||
|
callback: () => {
|
||||||
|
this.menuSelect('5');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 2. 轮询结果(每 5 秒查一次,最多 10 分钟)
|
// 重置上传区
|
||||||
let result = '';
|
this.resetReview();
|
||||||
let pointsUsed = 0;
|
|
||||||
let htmlUrl = '';
|
|
||||||
let contractInfo = {};
|
|
||||||
|
|
||||||
for (let i = 0; i < 60; i++) {
|
|
||||||
await new Promise(r => setTimeout(r, 10000));
|
|
||||||
|
|
||||||
const pollRes = await vk.callFunction({
|
|
||||||
url: 'client/ai/pub/getReviewResult',
|
|
||||||
data: { record_id: recordId }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pollRes.code === 0 && pollRes.data) {
|
|
||||||
if (pollRes.data.status === 'completed') {
|
|
||||||
result = pollRes.data.result;
|
|
||||||
pointsUsed = pollRes.data.points_used || 0;
|
|
||||||
htmlUrl = pollRes.data.html_url || '';
|
|
||||||
contractInfo = pollRes.data.contract_info || {};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (pollRes.data.status === 'failed') {
|
|
||||||
this.$message.error(pollRes.data.result || '审查失败');
|
|
||||||
this.reviewLoading = false;
|
|
||||||
clearInterval(progressTimer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearInterval(progressTimer);
|
|
||||||
this.reviewProgress = 100;
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
// 解析 JSON 结果
|
|
||||||
try {
|
|
||||||
const jsonMatch = result.match(/```json\s*([\s\S]*?)```/);
|
|
||||||
if (jsonMatch) {
|
|
||||||
const info = JSON.parse(jsonMatch[1].trim());
|
|
||||||
this.reviewContractInfo = info;
|
|
||||||
// 生成摘要文本用于页面显示
|
|
||||||
let summary = '<strong>合同名称:</strong>' + (info.contract_name || '-') + '<br>';
|
|
||||||
summary += '<strong>合同类型:</strong>' + (info.contract_type || '-') + '<br>';
|
|
||||||
summary += '<strong>综合风险:</strong><span style="color:#f56c6c;font-weight:700;">' + (info.risk_level || '-') + '</span><br>';
|
|
||||||
summary += '<strong>风险统计:</strong>高 ' + (info.risk_count && info.risk_count.high || 0) + ' / 中 ' + (info.risk_count && info.risk_count.medium || 0) + ' / 低 ' + (info.risk_count && info.risk_count.low || 0) + '<br>';
|
|
||||||
summary += '<strong>总体评估:</strong>' + (info.risk_summary || '-') + '<br>';
|
|
||||||
// 高风险摘要
|
|
||||||
const highRisks = (info.risks || []).filter(r => r.level === 'high');
|
|
||||||
if (highRisks.length > 0) {
|
|
||||||
summary += '<br><strong style="color:#f56c6c;">⚠️ 高风险摘要:</strong><br>';
|
|
||||||
highRisks.forEach(r => {
|
|
||||||
summary += '<div style="padding:8px 12px;margin:6px 0;background:#fff1f0;border-left:3px solid #f56c6c;border-radius:0 4px 4px 0;font-size:13px;">';
|
|
||||||
summary += '<strong>风险 #' + r.id + '</strong> ' + (r.location || '') + ':' + (r.problem || '');
|
|
||||||
summary += '</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.reviewResult = summary;
|
|
||||||
} else {
|
|
||||||
this.reviewResult = result;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.reviewResult = result;
|
|
||||||
}
|
|
||||||
this.reviewHtmlUrl = htmlUrl;
|
|
||||||
this.pointsBalance -= pointsUsed;
|
|
||||||
// 保存报告到本地
|
|
||||||
const reports = JSON.parse(localStorage.getItem('jjx_review_reports') || '[]');
|
|
||||||
reports.unshift({
|
|
||||||
fileName: this.reviewFile.name,
|
|
||||||
fileSize: this.reviewFile.size,
|
|
||||||
time: new Date().toLocaleString(),
|
|
||||||
points: pointsUsed,
|
|
||||||
result: result,
|
|
||||||
htmlUrl: htmlUrl
|
|
||||||
});
|
|
||||||
localStorage.setItem('jjx_review_reports', JSON.stringify(reports.slice(0, 50)));
|
|
||||||
this.getReportList();
|
|
||||||
this.$message.success('合同审查完成,消耗 ' + pointsUsed + ' 积分');
|
|
||||||
} else {
|
|
||||||
this.$message.warning('审查时间较长,请稍后在「我的报告」中查看结果');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
clearInterval(progressTimer);
|
|
||||||
this.$message.error('审查异常,请稍后重试');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.reviewLoading = false;
|
|
||||||
},
|
},
|
||||||
resetReview() {
|
resetReview() {
|
||||||
this.reviewFile = null;
|
this.reviewFile = null;
|
||||||
@@ -1978,12 +1914,12 @@
|
|||||||
async doRecharge() {
|
async doRecharge() {
|
||||||
const pkg = this.rechargePackages[this.selectedRecharge];
|
const pkg = this.rechargePackages[this.selectedRecharge];
|
||||||
const priceMap = {
|
const priceMap = {
|
||||||
'30元': 3000,
|
'10元': 1000,
|
||||||
'50元': 5000,
|
'50元': 5000,
|
||||||
'98元': 9800,
|
'100元': 10000,
|
||||||
'198元': 19800,
|
'450元': 45000,
|
||||||
'368元': 36800,
|
'1000元': 100000,
|
||||||
'688元': 1
|
'2000元': 200000
|
||||||
};
|
};
|
||||||
const price = priceMap[pkg.price] || 5000;
|
const price = priceMap[pkg.price] || 5000;
|
||||||
const pointsMatch = pkg.points;
|
const pointsMatch = pkg.points;
|
||||||
@@ -2869,6 +2805,10 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(1.2); }
|
||||||
|
}
|
||||||
.disclaimer-notice {
|
.disclaimer-notice {
|
||||||
display: flex; align-items: flex-start; gap: 8px;
|
display: flex; align-items: flex-start; gap: 8px;
|
||||||
background: #fff8e1; border: 1px solid #ffe082; border-radius: 8px;
|
background: #fff8e1; border: 1px solid #ffe082; border-radius: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"name": "http",
|
||||||
|
"type": "http",
|
||||||
|
"config": {
|
||||||
|
"path": "/review-stream",
|
||||||
|
"method": ["POST"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
// uniCloud HTTP 触发器:event 本身就是请求信息
|
||||||
|
// event.httpMethod = 'GET'/'POST'/'OPTIONS'
|
||||||
|
// event.body = 请求体
|
||||||
|
// event.headers = 请求头
|
||||||
|
// context.callbackWaitsForEmptyEventLoop = true 保持连接
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
'Access-Control-Max-Age': '86400'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理 CORS 预检
|
||||||
|
if (event.httpMethod === 'OPTIONS') {
|
||||||
|
return {
|
||||||
|
statusCode: 204,
|
||||||
|
headers: corsHeaders,
|
||||||
|
body: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置等待空事件循环(保持 SSE 连接)
|
||||||
|
context.callbackWaitsForEmptyEventLoop = false;
|
||||||
|
|
||||||
|
// 解析请求体
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
const raw = event.body || '';
|
||||||
|
if (event.isBase64Encoded) {
|
||||||
|
body = JSON.parse(Buffer.from(raw, 'base64').toString());
|
||||||
|
} else {
|
||||||
|
try { body = JSON.parse(raw); } catch (e) {
|
||||||
|
const params = new URLSearchParams(raw);
|
||||||
|
for (const [k, v] of params) body[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const { file_id, user_id, file_name, file_size, record_id } = body;
|
||||||
|
|
||||||
|
if (!file_id) {
|
||||||
|
return {
|
||||||
|
statusCode: 400,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ error: '缺少 file_id' })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
|
||||||
|
const BOT_ID = '7689821072762011698';
|
||||||
|
|
||||||
|
const objectString = JSON.stringify([
|
||||||
|
{ type: 'file', file_id: file_id },
|
||||||
|
{ type: 'text', text: '请审查这份合同' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 调用扣子(流式)
|
||||||
|
try {
|
||||||
|
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
dataType: 'text',
|
||||||
|
contentType: 'json',
|
||||||
|
timeout: 300000,
|
||||||
|
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 格式
|
||||||
|
const text = chatRes.data || '';
|
||||||
|
const sseBody = text.split('\n').map(line => {
|
||||||
|
if (line.startsWith('data:')) return line + '\n\n';
|
||||||
|
return '';
|
||||||
|
}).join('') + 'data: [DONE]\n\n';
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders,
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
},
|
||||||
|
body: sseBody
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
statusCode: 500,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ error: e.message })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "jjxReviewStream",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "合同审查流式输出云函数",
|
||||||
|
"main": "index.js"
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 定时任务:每分钟检查 reviewing 状态的记录,触发审查
|
||||||
|
const db = uniCloud.database();
|
||||||
|
const _ = db.command;
|
||||||
|
|
||||||
|
// 导入审查执行函数(从 router 的 service 目录)
|
||||||
|
// 注意:定时任务是独立云函数,不能直接 require router 的 service
|
||||||
|
// 所以把审查逻辑直接写在这里
|
||||||
|
|
||||||
|
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
|
||||||
|
const BOT_ID = '7689821072762011698';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新记录为完成
|
||||||
|
await db.collection('jjx-review-records').doc(record_id).update({
|
||||||
|
status: 'completed',
|
||||||
|
result: answer,
|
||||||
|
html_url: htmlUrl,
|
||||||
|
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 };
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// 本文件中的json内容将在云函数【运行】时作为参数传给云函数。
|
||||||
|
// 配置教程参考:https://uniapp.dcloud.net.cn/uniCloud/rundebug.html#runparam
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "jjxReviewTimer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "合同审查定时任务",
|
||||||
|
"main": "index.js",
|
||||||
|
"cloudfunction-config": {
|
||||||
|
"memorySize": 256,
|
||||||
|
"timeout": 600,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"name": "myTrigger",
|
||||||
|
"type": "timer",
|
||||||
|
"config": "0 */1 * * * * *"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"path": "",
|
||||||
|
"runtime": "Nodejs16"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = {
|
module.exports = {
|
||||||
main: async (event) => {
|
main: async (event) => {
|
||||||
let { data = {}, userInfo } = event;
|
let { data = {}, userInfo } = event;
|
||||||
@@ -17,13 +18,16 @@ module.exports = {
|
|||||||
{ type: 'text', text: '请审查这份合同' }
|
{ type: 'text', text: '请审查这份合同' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 调用 Coze 接口(流式模式,等待完整响应,和 Demo 一致)
|
// 调用 Coze 接口(流式模式)
|
||||||
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
|
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
dataType: 'text',
|
dataType: 'stream', // 关键:改成 stream
|
||||||
contentType: 'json',
|
contentType: 'json',
|
||||||
timeout: 300000, // 5分钟超时
|
timeout: 300000, // 5分钟超时
|
||||||
headers: { 'Authorization': 'Bearer ' + COZE_TOKEN },
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + COZE_TOKEN,
|
||||||
|
'Accept': 'text/event-stream'
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
bot_id: BOT_ID,
|
bot_id: BOT_ID,
|
||||||
user_id: user_id || 'anonymous',
|
user_id: user_id || 'anonymous',
|
||||||
@@ -37,92 +41,168 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 从 SSE 流中提取回答
|
// 从流中逐步解析
|
||||||
|
const stream = chatRes.data || chatRes.res;
|
||||||
|
let buffer = '';
|
||||||
let answer = '';
|
let answer = '';
|
||||||
let conversationId = '';
|
let conversationId = '';
|
||||||
const text = chatRes.data || '';
|
let lastUpdateLen = 0;
|
||||||
const lines = text.split('\n');
|
|
||||||
for (const line of lines) {
|
// 返回 Promise,等待流结束
|
||||||
if (!line.startsWith('data:')) continue;
|
return await new Promise((resolve, reject) => {
|
||||||
const d = line.slice(5).trim();
|
|
||||||
if (d === '[DONE]' || d === '"[DONE]"') continue;
|
stream.on('data', (chunk) => {
|
||||||
try {
|
buffer += chunk.toString();
|
||||||
const p = JSON.parse(d);
|
const blocks = buffer.split('\n\n');
|
||||||
if (p.conversation_id) conversationId = p.conversation_id;
|
buffer = blocks.pop();
|
||||||
if (p.type === 'answer' && p.content) answer += p.content;
|
|
||||||
if (p.type === 'tool_response' && p.content) {
|
for (const block of blocks) {
|
||||||
try {
|
const lines = block.split('\n');
|
||||||
const toolData = JSON.parse(p.content);
|
let eventName = '';
|
||||||
if (toolData.url) answer += '\n\n📎 报告下载:' + toolData.url;
|
let dataStr = '';
|
||||||
} catch (e) {
|
|
||||||
const urlMatch = p.content.match(/https?:\/\/[^\s\)\]\"\<]+\.pdf[^\s\)\]\"\<]*/i);
|
for (const line of lines) {
|
||||||
if (urlMatch) answer += '\n\n📎 报告下载:' + urlMatch[0];
|
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();
|
stream.on('end', async () => {
|
||||||
await db.collection('jjx-points-log').add({
|
if (!answer) {
|
||||||
user_id: user_id,
|
answer = '审查未能获取结果,请稍后重试';
|
||||||
type: 'spend',
|
}
|
||||||
amount: -pointsUsed,
|
|
||||||
balance_after: updatedPoints.data[0] ? updatedPoints.data[0].balance : 0,
|
try {
|
||||||
reason: '合同审查 — ' + (file_name || ''),
|
// 计算消耗积分
|
||||||
biz_type: 'contract_review',
|
const pointsUsed = Math.max(150, Math.ceil((file_size || 0) / (100 * 1024)) * 150);
|
||||||
biz_id: record_id,
|
|
||||||
_add_time: new Date()
|
// 扣积分
|
||||||
|
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 报告下载链接
|
stream.on('error', async (err) => {
|
||||||
let htmlUrl = '';
|
await db.collection('jjx-review-records').doc(record_id).update({
|
||||||
try {
|
status: 'failed',
|
||||||
const linkMatch = answer.match(/\[([^\]]*\.html)\]\((https?:\/\/[^\)]+)\)/);
|
result: '审查失败: ' + err.message,
|
||||||
if (linkMatch) {
|
error: err.message,
|
||||||
htmlUrl = linkMatch[2];
|
update_time: new Date()
|
||||||
} else {
|
});
|
||||||
const urlMatch = answer.match(/https?:\/\/[^\s\)\]]+\.html[^\s\)\]]*/);
|
reject(err);
|
||||||
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) {
|
} catch (e) {
|
||||||
// 审查失败,更新记录
|
// 审查失败,更新记录
|
||||||
await db.collection('jjx-review-records').doc(record_id).update({
|
await db.collection('jjx-review-records').doc(record_id).update({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// client/ai/pub/review.js
|
||||||
// Markdown 转 HTML
|
// Markdown 转 HTML
|
||||||
function mdToHtml(md) {
|
function mdToHtml(md) {
|
||||||
if (!md) return '';
|
if (!md) return '';
|
||||||
@@ -242,22 +243,7 @@ module.exports = {
|
|||||||
update_time: new Date()
|
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 {
|
return {
|
||||||
code: 0, msg: 'ok',
|
code: 0, msg: 'ok',
|
||||||
data: { record_id: recordId, status: 'reviewing' }
|
data: { record_id: recordId, status: 'reviewing' }
|
||||||
|
|||||||
@@ -61,10 +61,11 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"bsonType": "string",
|
"bsonType": "string",
|
||||||
"title": "审查状态",
|
"title": "审查状态",
|
||||||
"description": "uploaded=待审查 reviewing=审查中 completed=已完成 failed=失败",
|
"description": "uploaded=待审查 reviewing=等待处理 processing=审查中 completed=已完成 failed=失败",
|
||||||
"enum": [
|
"enum": [
|
||||||
{ "text": "待审查", "value": "uploaded" },
|
{ "text": "待审查", "value": "uploaded" },
|
||||||
{ "text": "审查中", "value": "reviewing" },
|
{ "text": "等待处理", "value": "reviewing" },
|
||||||
|
{ "text": "审查中", "value": "processing" },
|
||||||
{ "text": "已完成", "value": "completed" },
|
{ "text": "已完成", "value": "completed" },
|
||||||
{ "text": "失败", "value": "failed" }
|
{ "text": "失败", "value": "failed" }
|
||||||
]
|
]
|
||||||
@@ -77,7 +78,12 @@
|
|||||||
"points_used": {
|
"points_used": {
|
||||||
"bsonType": "number",
|
"bsonType": "number",
|
||||||
"title": "消耗积分",
|
"title": "消耗积分",
|
||||||
"description": "本次审查消耗的积分数量"
|
"description": "本次审查消耗的积分数量(token_count / 100)"
|
||||||
|
},
|
||||||
|
"token_count": {
|
||||||
|
"bsonType": "number",
|
||||||
|
"title": "Token消耗",
|
||||||
|
"description": "扣子返回的 token 总消耗"
|
||||||
},
|
},
|
||||||
"_add_time": {
|
"_add_time": {
|
||||||
"bsonType": "date",
|
"bsonType": "date",
|
||||||
|
|||||||
Reference in New Issue
Block a user