diff --git a/pages/contract/index.vue b/pages/contract/index.vue
index 51bba61..e8bd225 100644
--- a/pages/contract/index.vue
+++ b/pages/contract/index.vue
@@ -543,7 +543,7 @@
🔍
标准审查
风险条款识别 · 合规性检查
- {{ reviewPoints }} 积分
+
-
+
+
+
+ AI 正在输出分析结果...
+
待审查
- 审查中
+ 排队中
+ 审查中
已完成
失败
查看报告
查看文件
-
+ 查看摘要
@@ -872,6 +877,7 @@
reviewHtmlUrl: '',
reviewPdfUrl: '',
reviewContractInfo: {},
+ reviewStreaming: false,
reviewUploading: false,
reviewMode: 0,
reviewPoints: 100,
@@ -882,34 +888,34 @@
// 积分充值
selectedRecharge: 1,
rechargePackages: [{
- points: 500,
- price: '30元',
+ points: 10000,
+ price: '10元',
bonus: ''
},
{
- points: 1000,
+ points: 50000,
price: '50元',
- bonus: '送100积分'
+ bonus: ''
},
{
- points: 2000,
- price: '98元',
- bonus: '送300积分'
+ points: 100000,
+ price: '100元',
+ bonus: ''
},
{
- points: 5000,
- price: '198元',
- bonus: '送1000积分'
+ points: 450000,
+ price: '450元',
+ bonus: ''
},
{
- points: 10000,
- price: '368元',
- bonus: '送3000积分'
+ points: 1000000,
+ price: '1000元',
+ bonus: ''
},
{
- points: 20000,
- price: '688元',
- bonus: '送8000积分'
+ points: 2000000,
+ price: '2000元',
+ bonus: ''
}
],
// 我的报告
@@ -1671,6 +1677,14 @@
openUrl(url) {
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) {
if (!text) return '';
let html = text;
@@ -1836,135 +1850,57 @@
};
input.click();
},
+ // 前端 startReview() 完整改造
async startReview() {
- if (!this.reviewFileId) {
- this.$message.warning('文件尚未上传完成,请稍候');
- return;
- }
-
- const pointsNeeded = this.reviewPoints;
- if (this.pointsBalance < pointsNeeded + 200) {
- this.$message.warning('积分不足,当前余额 ' + this.pointsBalance + ',需要 ' + pointsNeeded + ' 且审查后余额需保留至少 200 积分');
- return;
- }
-
- this.reviewLoading = true;
- this.reviewProgress = 0;
-
- // 模拟进度条
- const progressTimer = setInterval(() => {
- this.reviewProgress = Math.min(this.reviewProgress + 1, 90);
- }, 1500);
-
- try {
- // 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) {
- this.$message.error(res.msg || '审查发起失败');
- this.reviewLoading = false;
- clearInterval(progressTimer);
- return;
- }
-
- const recordId = res.data.record_id;
-
- // 2. 轮询结果(每 5 秒查一次,最多 10 分钟)
- let result = '';
- 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 = '合同名称:' + (info.contract_name || '-') + '
';
- summary += '合同类型:' + (info.contract_type || '-') + '
';
- summary += '综合风险:' + (info.risk_level || '-') + '
';
- summary += '风险统计:高 ' + (info.risk_count && info.risk_count.high || 0) + ' / 中 ' + (info.risk_count && info.risk_count.medium || 0) + ' / 低 ' + (info.risk_count && info.risk_count.low || 0) + '
';
- summary += '总体评估:' + (info.risk_summary || '-') + '
';
- // 高风险摘要
- const highRisks = (info.risks || []).filter(r => r.level === 'high');
- if (highRisks.length > 0) {
- summary += '
⚠️ 高风险摘要:
';
- highRisks.forEach(r => {
- summary += '
';
- summary += '风险 #' + r.id + ' ' + (r.location || '') + ':' + (r.problem || '');
- summary += '
';
- });
- }
- 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;
+ if (!this.reviewFileId) {
+ this.$message.warning('请先选择合同文件并等待上传完成');
+ return;
+ }
+
+ // 1. 检查积分
+ const balanceRes = await vk.callFunction({
+ url: 'client/points/kh/getBalance'
+ });
+ if (balanceRes.code !== 0 || !balanceRes.data || balanceRes.data.balance < 150) {
+ this.$message.warning('积分不足,请先充值');
+ return;
+ }
+
+ this.reviewLoading = true;
+
+ // 2. 发起审查(更新已有记录状态为 reviewing,用 selectReviewFile 时保存的 file_id)
+ const reviewRes = await vk.callFunction({
+ url: 'client/ai/pub/review',
+ data: {
+ record_id: this.reviewRecordId,
+ file_id: this.reviewFileId,
+ file_name: this.reviewFile.name,
+ file_size: this.reviewFile.size,
+ uid: this.userInfo.uid
+ }
+ });
+
+ this.reviewLoading = false;
+
+ if (reviewRes.code !== 0) {
+ this.$message.error(reviewRes.msg || '发起审查失败');
+ return;
+ }
+
+ // 3. 提示用户 + 跳转到我的报告
+ this.$alert(
+ '合同已提交审查,AI 正在分析中,预计 3-5 分钟完成。您可以在「我的报告」中查看审查结果。',
+ '审查已提交',
+ {
+ confirmButtonText: '查看我的报告',
+ callback: () => {
+ this.menuSelect('5');
+ }
+ }
+ );
+
+ // 重置上传区
+ this.resetReview();
},
resetReview() {
this.reviewFile = null;
@@ -1978,12 +1914,12 @@
async doRecharge() {
const pkg = this.rechargePackages[this.selectedRecharge];
const priceMap = {
- '30元': 3000,
+ '10元': 1000,
'50元': 5000,
- '98元': 9800,
- '198元': 19800,
- '368元': 36800,
- '688元': 1
+ '100元': 10000,
+ '450元': 45000,
+ '1000元': 100000,
+ '2000元': 200000
};
const price = priceMap[pkg.price] || 5000;
const pointsMatch = pkg.points;
@@ -2869,6 +2805,10 @@
gap: 8px;
}
+ @keyframes pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.5; transform: scale(1.2); }
+ }
.disclaimer-notice {
display: flex; align-items: flex-start; gap: 8px;
background: #fff8e1; border: 1px solid #ffe082; border-radius: 8px;
diff --git a/uniCloud-tcb/cloudfunctions/jjxReviewStream/config.json b/uniCloud-tcb/cloudfunctions/jjxReviewStream/config.json
new file mode 100644
index 0000000..26bcc55
--- /dev/null
+++ b/uniCloud-tcb/cloudfunctions/jjxReviewStream/config.json
@@ -0,0 +1,12 @@
+{
+ "triggers": [
+ {
+ "name": "http",
+ "type": "http",
+ "config": {
+ "path": "/review-stream",
+ "method": ["POST"]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/uniCloud-tcb/cloudfunctions/jjxReviewStream/index.js b/uniCloud-tcb/cloudfunctions/jjxReviewStream/index.js
new file mode 100644
index 0000000..6abcd12
--- /dev/null
+++ b/uniCloud-tcb/cloudfunctions/jjxReviewStream/index.js
@@ -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 })
+ };
+ }
+};
\ No newline at end of file
diff --git a/uniCloud-tcb/cloudfunctions/jjxReviewStream/package.json b/uniCloud-tcb/cloudfunctions/jjxReviewStream/package.json
new file mode 100644
index 0000000..85ab71b
--- /dev/null
+++ b/uniCloud-tcb/cloudfunctions/jjxReviewStream/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "jjxReviewStream",
+ "version": "1.0.0",
+ "description": "合同审查流式输出云函数",
+ "main": "index.js"
+}
\ No newline at end of file
diff --git a/uniCloud-tcb/cloudfunctions/jjxReviewTimer/index.js b/uniCloud-tcb/cloudfunctions/jjxReviewTimer/index.js
new file mode 100644
index 0000000..1034e80
--- /dev/null
+++ b/uniCloud-tcb/cloudfunctions/jjxReviewTimer/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(' {
+ // 先把卡在 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 };
+};
\ No newline at end of file
diff --git a/uniCloud-tcb/cloudfunctions/jjxReviewTimer/jjxReviewTimer.param.json b/uniCloud-tcb/cloudfunctions/jjxReviewTimer/jjxReviewTimer.param.json
new file mode 100644
index 0000000..0f56b5c
--- /dev/null
+++ b/uniCloud-tcb/cloudfunctions/jjxReviewTimer/jjxReviewTimer.param.json
@@ -0,0 +1,4 @@
+// 本文件中的json内容将在云函数【运行】时作为参数传给云函数。
+// 配置教程参考:https://uniapp.dcloud.net.cn/uniCloud/rundebug.html#runparam
+{
+}
\ No newline at end of file
diff --git a/uniCloud-tcb/cloudfunctions/jjxReviewTimer/package.json b/uniCloud-tcb/cloudfunctions/jjxReviewTimer/package.json
new file mode 100644
index 0000000..3ba759e
--- /dev/null
+++ b/uniCloud-tcb/cloudfunctions/jjxReviewTimer/package.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/doReviewTimer.js b/uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/doReviewTimer.js
new file mode 100644
index 0000000..397658b
--- /dev/null
+++ b/uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/doReviewTimer.js
@@ -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 };
+ }
+ }
+};
\ No newline at end of file
diff --git a/uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/jjxDoReview.js b/uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/jjxDoReview.js
index 4bc9ee4..86b3b98 100644
--- a/uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/jjxDoReview.js
+++ b/uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/jjxDoReview.js
@@ -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(包含 或 )
+ let htmlContent = answer;
+ const htmlMatch = answer.match(/```html\s*([\s\S]*?)```/);
+ if (htmlMatch && htmlMatch[1]) {
+ htmlContent = htmlMatch[1].trim();
+ }
+
+ if (htmlContent.includes(' {
+ 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({
diff --git a/uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/review.js b/uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/review.js
index 3513643..ced0ff8 100644
--- a/uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/review.js
+++ b/uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/review.js
@@ -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' }
diff --git a/uniCloud-tcb/database/jjx-review-records.schema.json b/uniCloud-tcb/database/jjx-review-records.schema.json
index 2f6a2e9..75abfe3 100644
--- a/uniCloud-tcb/database/jjx-review-records.schema.json
+++ b/uniCloud-tcb/database/jjx-review-records.schema.json
@@ -61,10 +61,11 @@
"status": {
"bsonType": "string",
"title": "审查状态",
- "description": "uploaded=待审查 reviewing=审查中 completed=已完成 failed=失败",
+ "description": "uploaded=待审查 reviewing=等待处理 processing=审查中 completed=已完成 failed=失败",
"enum": [
{ "text": "待审查", "value": "uploaded" },
- { "text": "审查中", "value": "reviewing" },
+ { "text": "等待处理", "value": "reviewing" },
+ { "text": "审查中", "value": "processing" },
{ "text": "已完成", "value": "completed" },
{ "text": "失败", "value": "failed" }
]
@@ -77,7 +78,12 @@
"points_used": {
"bsonType": "number",
"title": "消耗积分",
- "description": "本次审查消耗的积分数量"
+ "description": "本次审查消耗的积分数量(token_count / 100)"
+ },
+ "token_count": {
+ "bsonType": "number",
+ "title": "Token消耗",
+ "description": "扣子返回的 token 总消耗"
},
"_add_time": {
"bsonType": "date",