From 4700ea4d984b54a2d0ecb1970516d2884e16c996 Mon Sep 17 00:00:00 2001 From: ingkele <598792849@qq.com> Date: Wed, 14 Oct 2026 21:27:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=88=E5=90=8C=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E6=96=B9=E6=A1=88=EF=BC=88=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=A7=A6=E5=8F=91=E3=80=81=E9=9D=9E=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E8=BD=AE=E8=AF=A2=E6=89=A3=E5=AD=90API=E3=80=81token?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E3=80=81HTML=E6=8A=A5=E5=91=8A=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/contract/index.vue | 252 +++++++----------- .../jjxReviewStream/config.json | 12 + .../cloudfunctions/jjxReviewStream/index.js | 106 ++++++++ .../jjxReviewStream/package.json | 6 + .../cloudfunctions/jjxReviewTimer/index.js | 239 +++++++++++++++++ .../jjxReviewTimer/jjxReviewTimer.param.json | 4 + .../jjxReviewTimer/package.json | 19 ++ .../service/client/ai/pub/doReviewTimer.js | 53 ++++ .../service/client/ai/pub/jjxDoReview.js | 240 +++++++++++------ .../router/service/client/ai/pub/review.js | 18 +- .../database/jjx-review-records.schema.json | 12 +- 11 files changed, 706 insertions(+), 255 deletions(-) create mode 100644 uniCloud-tcb/cloudfunctions/jjxReviewStream/config.json create mode 100644 uniCloud-tcb/cloudfunctions/jjxReviewStream/index.js create mode 100644 uniCloud-tcb/cloudfunctions/jjxReviewStream/package.json create mode 100644 uniCloud-tcb/cloudfunctions/jjxReviewTimer/index.js create mode 100644 uniCloud-tcb/cloudfunctions/jjxReviewTimer/jjxReviewTimer.param.json create mode 100644 uniCloud-tcb/cloudfunctions/jjxReviewTimer/package.json create mode 100644 uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/doReviewTimer.js 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 }} 积分 + -

📋 审查结果 — {{ reviewFile.name }}

- ✅ 审查完成 +

📋 {{ reviewStreaming ? 'AI 正在分析...' : '审查结果' }} — {{ reviewFile.name }}

+ {{ reviewStreaming ? '⏳ 分析中...' : '✅ 审查完成' }}
- +
+
+ + AI 正在输出分析结果... +
📄 查看完整 HTML 报告 @@ -603,12 +607,13 @@ {{ r.time }} · 消耗 {{ Math.abs(r.amount) }} 积分 待审查 - 审查中 + 排队中 + 审查中 已完成 失败 查看报告 查看文件 - + 查看摘要
@@ -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",