Compare commits

..

13 Commits

Author SHA1 Message Date
b2894lxlx 683a9411fd feat: 签约提示/赠送次数/自动划付提醒,自营支付成功弹窗,隐藏微信登录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 03:28:31 +08:00
ngele 85cddfbf8d fix: 积分明细云函数联表加username字段(与jjx-admin同步) 2026-10-23 21:41:43 +08:00
ngele 08b2888704 feat: 同步后台积分明细云函数(admin/points/sys/getList) 2026-10-23 21:20:20 +08:00
ngele 3b8f6c5718 fix: 积分充值订单类型名对齐索引20,修复资金流水显示「类型20」 2026-10-23 20:58:34 +08:00
ngele a227912705 fix: 报告页uid获取增加storage兜底(与wap端同步) 2026-10-23 19:23:21 +08:00
ngele 95024bb2a6 feat: AI审查报告支持下载Word + 我的报告支持重新审查
- 云函数jjxReviewTimer完成审查后自动将HTML报告转换为Word文档并上传
- 新增buildDocx.js纯正则解析HTML报告生成A4公文版式docx(三级数据提取兼容结构漂移)
- 兼容智能体插件直接返回docx链接(jjx-docx-link标记协议)
- router/jjxDoReview同步支持Word生成(try降级不影响主流程)
- 前端报告列表新增下载Word/重新审查按钮
- 修复上传/查询取真实uid(vk.getVuex而非页面本地data)
- login.js清理调试日志、.gitignore忽略node_modules与lock文件
2026-10-23 19:07:05 +08:00
ngele d0fb8802e7 chore: 更新 DCloud AppID 为 __UNI__CBB811F 2026-10-23 15:39:26 +08:00
ngele 16290ae5ac feat: 我的报告操作按钮文案调整,在线查看报告补 result 判断 2026-10-23 15:39:25 +08:00
ngele b680246a56 feat: 补齐 AI 智能服务模块(AI客服/合同审查/我的报告/积分管理)
从 jjx-web-sign-only 增量移植,jjx-web 原有逻辑零改动:
- 左侧菜单新增「AI 智能服务」分组
- 四个功能面板:AI 客服聊天、合同审查(上传+积分预估)、我的报告、积分管理
- data 新增 25 个字段、methods 新增 19 个方法、样式新增 467 行
- onShow 按 menuIndex 加载积分余额/报告/明细
- menuSelect 增加 e>=3 分支,AI 菜单不再触发合同列表查询
- 未带入 sign-only 专属 isSignOnly 参数
2026-10-19 20:07:20 +08:00
ngele a7d0e82f61 fix: 修复 AI 客服回复内容重复(区分 SSE delta/completed 事件,completed 仅作兜底) 2026-10-19 19:31:53 +08:00
b2894lxlx c6fea1cc0d fix: 调整支付申请与报价查看权限
去掉货到付款和发货凭证,尾款或全款时提示交易已完结;已购单据报价次数也可查看买家信息。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 14:20:42 +08:00
b2894lxlx 429513ad8d fix: 登录前补齐空的 dcloud_appid,避免被判成用户不存在
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 14:19:50 +08:00
ngele 631e3caa4c feat: 新增合同审查定时任务与积分体系,重构支付模块与余额流水查询 2026-10-14 21:55:23 +08:00
60 changed files with 6158 additions and 1137 deletions
+3 -72
View File
@@ -4,76 +4,7 @@
/uni_modules/
/.idea
/uniCloud-aliyun/cloudfunctions/router/node_modules/
/uniCloud-tcb/cloudfunctions/**/node_modules/
/uniCloud-tcb/cloudfunctions/**/package-lock.json
/pnpm-lock.yaml
/金久信源码/金久信数据库/fadada-verify-record.json
/金久信源码/金久信数据库/jjx-account.json
/金久信源码/金久信数据库/jjx-ad.json
/金久信源码/金久信数据库/jjx-album.json
/金久信源码/金久信数据库/jjx-article.json
/金久信源码/金久信数据库/jjx-article-category.json
/金久信源码/金久信数据库/jjx-article-collect.json
/金久信源码/金久信数据库/jjx-article-download.json
/金久信源码/金久信数据库/jjx-balance.json
/金久信源码/金久信数据库/jjx-balance-log.json
/金久信源码/金久信数据库/jjx-business-card.json
/金久信源码/金久信数据库/jjx-business-contact.json
/金久信源码/金久信数据库/jjx-cart.json
/金久信源码/金久信数据库/jjx-certificate.json
/金久信源码/金久信数据库/jjx-company-manage-change-log.json
/金久信源码/金久信数据库/jjx-contract.json
/金久信源码/金久信数据库/jjx-contract-template.json
/金久信源码/金久信数据库/jjx-coop-config.json
/金久信源码/金久信数据库/jjx-dict.json
/金久信源码/金久信数据库/jjx-enquiry-attributes.json
/金久信源码/金久信数据库/jjx-enquiry-category.json
/金久信源码/金久信数据库/jjx-enquiry-package.json
/金久信源码/金久信数据库/jjx-enquiry-quote.json
/金久信源码/金久信数据库/jjx-enquiry-sheet.json
/金久信源码/金久信数据库/jjx-enquiry-template.json
/金久信源码/金久信数据库/jjx-ensure-config.json
/金久信源码/金久信数据库/jjx-factory.json
/金久信源码/金久信数据库/jjx-goods.json
/金久信源码/金久信数据库/jjx-goods-category.json
/金久信源码/金久信数据库/jjx-goods-cooperate.json
/金久信源码/金久信数据库/jjx-industry.json
/金久信源码/金久信数据库/jjx-message.json
/金久信源码/金久信数据库/jjx-message-config.json
/金久信源码/金久信数据库/jjx-old.json
/金久信源码/金久信数据库/jjx-old-category.json
/金久信源码/金久信数据库/jjx-old-quote.json
/金久信源码/金久信数据库/jjx-order.json
/金久信源码/金久信数据库/jjx-order-goods.json
/金久信源码/金久信数据库/jjx-order-pay-list.json
/金久信源码/金久信数据库/jjx-order-selfProduct.json
/金久信源码/金久信数据库/jjx-other-article.json
/金久信源码/金久信数据库/jjx-other-article-category.json
/金久信源码/金久信数据库/jjx-process-category.json
/金久信源码/金久信数据库/jjx-process-quote.json
/金久信源码/金久信数据库/jjx-process-sheet.json
/金久信源码/金久信数据库/jjx-quote-num-config.json
/金久信源码/金久信数据库/jjx-qyb.json
/金久信源码/金久信数据库/jjx-self-support.json
/金久信源码/金久信数据库/jjx-self-support-quote-list.json
/金久信源码/金久信数据库/jjx-sign-pay_list.json
/金久信源码/金久信数据库/jjx-site-config.json
/金久信源码/金久信数据库/jjx-spot-ad.json
/金久信源码/金久信数据库/jjx-spot-amount.json
/金久信源码/金久信数据库/jjx-spot-category.json
/金久信源码/金久信数据库/jjx-spot-goods.json
/金久信源码/金久信数据库/jjx-steal-factory.json
/金久信源码/金久信数据库/jjx-user-address.json
/金久信源码/金久信数据库/jjx-user-article.json
/金久信源码/金久信数据库/jjx-user-contract.json
/金久信源码/金久信数据库/jjx-user-level.json
/金久信源码/金久信数据库/jjx-user-quote-num.json
/金久信源码/金久信数据库/opendb-admin-log.json
/金久信源码/金久信数据库/opendb-admin-menus.json
/金久信源码/金久信数据库/opendb-app-list.json
/金久信源码/金久信数据库/opendb-verify-codes.json
/金久信源码/金久信数据库/uni-id-log.json
/金久信源码/金久信数据库/uni-id-permissions.json
/金久信源码/金久信数据库/uni-id-roles.json
/金久信源码/金久信数据库/uni-id-users.json
/金久信源码/金久信数据库/vk-files.json
/金久信源码/金久信数据库/vk-files-categories.json
/金久信源码/金久信数据库/vk-global-data.json
/金久信源码/
+9
View File
@@ -160,6 +160,15 @@ export default {
}).then(() => {
vk.userCenter.logout({
success: (data) => {
try {
if (typeof sessionStorage !== 'undefined') {
Object.keys(sessionStorage).forEach((key) => {
if (key.indexOf('notaryAutoTransferTipShown_') === 0) {
sessionStorage.removeItem(key)
}
})
}
} catch (e) {}
// 退出登录成功后的逻辑
uni.reLaunch({
url: '/'
+2 -2
View File
@@ -26,8 +26,8 @@
<meta name="description" content="金久信签约宝专注线上电子签约,金久信签约宝会员服务.致力于为您提供一站式金久信签约宝会员服务.">
<!--preload-links-->
<!--app-context-->
<!-- 配置H5的 web图标static/logo.png -->
<link rel="icon" href="./static/logo.png"/>
<!-- 配置H5的 web图标 -->
<link rel="icon" type="image/png" href="./static/favicon.png"/>
<script type="text/javascript" src="http://qzonestyle.gtimg.cn/qzone/openapi/qc_loader.js" data-appid="101415280"
data-redirecturi="http://127.0.0.1:8080/"
charset="utf-8"></script>
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name" : "jinjiuxin-mobile",
"appid" : "__UNI__8BFD66E",
"appid" : "__UNI__CBB811F",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
"sassImplementationName": "node-sass",
"sassImplementationName" : "node-sass",
"app-plus" : {
"usingComponents" : true,
"nvueCompiler" : "uni-app",
+2 -3
View File
@@ -92,13 +92,12 @@
</a>
</div>
<!-- 暂时隐藏微信登录
<div class=" threeBorder"><span>第三方登录</span></div>
<div class="input-groupwpg clear">
<a href="javascript:;" @click="toWechat" class="dsf-wetChat" title="微信"><i></i></a>
<!-- <a href="javascript:;" @click="toQQ" class="dsf-qq" title="QQ"><i></i></a>-->
<!-- <a href="" class="dsf-dx" title="短信"><i></i></a>-->
</div>
-->
</div>
</div>
</div>
+2 -3
View File
@@ -98,13 +98,12 @@
<navigator url="/pages/auth/login/login">已有账号?<span
style="color: #a92924">去登录</span></navigator>
</div>
<!-- 暂时隐藏微信登录
<div class=" threeBorder"><span>第三方登录</span></div>
<div class="input-groupwpg clear">
<a href="javascript:;" @click="toWechat" class="dsf-wetChat" title="微信"><i></i></a>
<!-- <a href="javascript:;" @click="toQQ" class="dsf-qq" title="QQ"><i></i></a>-->
<!-- <a href="" class="dsf-dx" title="短信"><i></i></a>-->
</div>
-->
</div>
</div>
</div>
+1290 -21
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -629,7 +629,7 @@ export default {
},
openQuoteServiceFeeDialog() {
const rate = this.sheet.notaryServiceRate || 0.1
return this.$confirm(`提示着您成功签约交易,平台将收取总交易金额 ${rate}%服务费,如您存在利用系统漏洞恶意逃避支付服务费的情况,平台有将您的失信行为进行公布,情况严重的平台予以劝退。
return this.$confirm(`若您成功签约交易,平台将收取总交易金额 ${rate}%服务费,如您存在利用系统漏洞恶意逃避支付服务费的情况,平台有将您的失信行为进行公布,情况严重的平台予以劝退。
并有权依法追回平台服务费。您承诺此次交易为合法经营,如违反相关法律法规您将承担全部任与平台无关。您已经完全理解并知悉,您不同意请点击'取消',取消报价,您同意以上约定请点击'同意并知悉'。`, '重要提示', {
confirmButtonText: '同意并知悉',
cancelButtonText: '取消',
+26
View File
@@ -111,6 +111,7 @@ export default {
},
enquiryId: null,
reloaded: false,
sheetQuoteNum: 0,
// 表单相关开始-----------------------------------------------------------
form1: {
// 表单请求数据,此处可以设置默认值
@@ -175,6 +176,7 @@ export default {
this.enquiryId = enquiryId
this.form1.data.sheetId = enquiryId
this.userInfo = vk.getVuex('$user.userInfo') || {userLevel: 0}
this.refreshUserQuotePrivilege()
this.getEnquiryDetail()
},
computed: {
@@ -184,6 +186,7 @@ export default {
canViewBuyerInfo() {
const remainQuoteNum = Number(this.userInfo && this.userInfo.remainQuoteNum) || 0
const sheetQuoteNum = Number(this.sheetQuoteNum) || 0
const quoteExpiredAt = this.userInfo && this.userInfo.quoteExpiredAt
const monthlyQuoteActive = !!(quoteExpiredAt && dayjs(quoteExpiredAt).isAfter(dayjs()))
return this.isLoggedIn && (
@@ -191,6 +194,7 @@ export default {
this.sheet.hasQuote ||
Number(this.userInfo.userLevel) > 0 ||
remainQuoteNum > 0 ||
sheetQuoteNum > 0 ||
monthlyQuoteActive
)
},
@@ -245,6 +249,27 @@ export default {
}
},
async refreshUserQuotePrivilege() {
if (!this.isLoggedIn) return
try {
const {userInfo} = await vk.userCenter.getCurrentUserInfo()
this.userInfo = userInfo || {userLevel: 0}
let sheetQuoteNum = 0
if (this.enquiryId) {
const res = await vk.callFunction({
url: 'client/user/kh/getUserQuoteNumList',
data: {
type: 'enquiry',
sheetId: this.enquiryId,
},
})
if (res && res.data && res.data.num) sheetQuoteNum = Number(res.data.num) || 0
}
this.sheetQuoteNum = sheetQuoteNum
} catch (e) {
// ignore
}
},
// 提交报价单
async submit() {
@@ -308,6 +333,7 @@ export default {
}
},
onShow() {
this.refreshUserQuotePrivilege()
this.reloaded = false
setTimeout(() => {
this.reloaded = true
+2 -7
View File
@@ -227,13 +227,8 @@
</el-radio-group>
</view>
</view>
<view class="text-25 text-main mb-20 flex justify-start items-start">
<p>重要提示:</p>
<view class="flex flex-col justify-start items-start">
<p>1.系统不支持个人用户操作,请使用企业或个体户进行交易或选择自主交易;</p>
<p class="my-15">2.企业用户需平台内进行电子签约,您也可线下双方补充纸质合同:</p>
<p>3.购销双方签约后需向平台银联监管账户支付全部货款您选择即为确认并符合以上条件。</p>
</view>
<view class="text-25 text-main mb-20">
重要提示:在自主交易模式下,本平台仅承担信息交互服务职能,仅确保入驻本平台的交易双方已完成实名认证;对于交易的真实性及交易过程中的任一环节,本平台均不承担责任,请用户完成风险评估与风险尽职调查后,审慎开展交易。
</view>
<view class="form-title-wrap">
+41 -15
View File
@@ -116,7 +116,6 @@
<div class="mod-inner2Listcenter clearfix sliper" style="margin-top: 0">
<ul class="clearfix flex justify-start items-start flex-wrap">
<li v-for="(item, index) in enquirySheetData" class="inli-mask" :key="index"
style="height: 200px"
@click="vk.navigateTo('/pages/enquiry/detail?id=' + item._id);">
<a href="javascript:void(0)" style="display: block;" rel="nofollow">
<div class="detail">
@@ -127,7 +126,7 @@
<div class="text-30 py-10 text-black line-1">
<span>{{ item.title.split('-')[0] }}</span>
</div>
<div class="text-30 py-10 text-black line-1">
<div class="text-30 py-10 text-black line-1" v-if="item.title.split('-')[1]">
<span>{{ item.title.split('-')[1] }}</span>
</div>
<div class="text-30 py-10 text-black line-1">
@@ -135,7 +134,7 @@
item.productList.map(product => (product.procureNumber ? product.procureNumber : '') + (product.procureUnit ? product.procureUnit : ''))[0]
}}</span>
</div>
<div class="text-25 mt-25">
<div class="text-25 mt-25 line-1">
<span>{{ item.fullAddress }}</span>
</div>
<div class="detail-num flex flex-col justify-start items-start">
@@ -575,7 +574,7 @@
<div slot="header" class="clearfix pointer">
<span @click="navTo(`/pages/otherArticle/list?id=${item.id}`)">{{ item.title }}</span>
</div>
<div class="news-list" style="height: 140px">
<div class="news-list news-list--zx">
<el-link @click="navTo(`/pages/otherArticle/detail?id=${article._id}`)"
v-for="(article, articleIndex) in item.list" :key="articleIndex"
:underline="false">
@@ -757,21 +756,31 @@ export default {
vk.navigateTo(path);
},
async checkExpiringNotaryPayList() {
if (!vk.getVuex('$user.userInfo._id')) return
const userId = vk.getVuex('$user.userInfo._id')
if (!userId) return
const tipKey = `notaryAutoTransferTipShown_${userId}`
// 同一登录会话只提示一次(退出登录会清除)
try {
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(tipKey) === '1') return
} catch (e) {}
try {
const res = await vk.callFunction({
url: 'client/order/kh/getExpiringNotaryPayList',
loading: false,
})
const count = res.data && res.data.count ? res.data.count : 0
if (!count) return
this.$confirm(`您有${count}笔付款申请将在24小时内自动划付,请及时处理。`, '付款申请即将到期', {
confirmButtonText: '立即处理',
cancelButtonText: '稍后处理',
showCancelButton: true,
const content = res.data && res.data.content ? res.data.content : ''
if (!count || !content) return
try {
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(tipKey, '1')
} catch (e) {}
this.$alert(content, '重要提示', {
confirmButtonText: '悉知同意',
dangerouslyUseHTMLString: false,
type: 'warning',
}).then(() => {
vk.navigateTo({url: '/pages/order/order?menuIndex=1'})
closeOnClickModal: false,
closeOnPressEscape: false,
showClose: false,
}).catch(() => {})
} catch (err) {
console.error('checkExpiringNotaryPayList error:', err)
@@ -1031,9 +1040,20 @@ export default {
margin: 0 auto;
.inli-mask {
padding: 10px;
height: 400rpx;
flex: 0 0 232px
padding: 10px 10px 16px;
min-height: 200px;
height: auto;
box-sizing: border-box;
flex: 0 0 232px;
overflow: visible;
.detail-num {
margin-top: 8px;
}
.time {
margin-top: 4px;
}
}
}
@@ -1269,6 +1289,12 @@ export default {
flex-direction: column;
align-items: self-start;
line-height: 1.8em;
&--zx {
min-height: 140px;
padding-bottom: 16px;
box-sizing: border-box;
}
}
.news-card {
+5
View File
@@ -325,6 +325,11 @@ export default {
vk.redirectTo({
url: '/pages/contract/index?tab=sign',
})
} else if (this.info.type === 1 || this.info.type === 14) {
// 金久信自营:返回订单页后展示提交成功弹窗
vk.redirectTo({
url: '/pages/order/order?menuIndex=1&selfSupportPaySuccess=1',
})
} else {
vk.redirectTo({
url: '/pages/order/order?menuIndex=1',
+25 -71
View File
@@ -31,33 +31,9 @@
<el-form-item label="备注">
<el-input v-model="payInfo.remark"/>
</el-form-item>
<template v-if="[0, 1].includes(orderInfo.orderStatus)">
<el-row :gutter="10">
<el-col :span="6">
<el-form-item label="是否发货">
<el-radio-group v-model="payInfo.isExpress"
:disabled="['货到付款', '全款'].includes(payInfo.type)">
<el-radio :label="true">是</el-radio>
<el-radio :label="false">否</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<!-- <el-col :span="12">-->
<!-- <el-form-item label="物流公司" required>-->
<!-- <el-input v-model="payInfo.expressName" placeholder="请输入物流公司"/>-->
<!-- </el-form-item>-->
<!-- <el-form-item label="物流单号" required>-->
<!-- <el-input v-model="payInfo.expressNo" placeholder="请输入物流单号"/>-->
<!-- </el-form-item>-->
<!-- </el-col>-->
</el-row>
<el-form-item label="凭证" v-if="!payInfo.isExpress">
<vk-data-upload v-model="payInfo.photos"></vk-data-upload>
</el-form-item>
<el-form-item label="凭证" v-else required>
<vk-data-upload v-model="payInfo.expressPhoto"></vk-data-upload>
</el-form-item>
</template>
<el-form-item label="凭证">
<vk-data-upload v-model="payInfo.photos"></vk-data-upload>
</el-form-item>
<el-form-item label="附件">
<vk-data-upload v-model="payInfo.ext"></vk-data-upload>
</el-form-item>
@@ -143,51 +119,29 @@ export default {
type: 'error',
message: '请输入支付金额'
})
if (this.payInfo.isExpress && this.payInfo.expressPhoto.length === 0) return this.$message({
type: 'error',
message: '请上传凭证'
await vk.callFunction({
url: 'client/order/kh/makePay',
title: '请求中...',
data: {payInfo: this.payInfo}
})
// if (this.payInfo.isExpress && !this.payInfo.expressName) return this.$message({
// type: 'error',
// message: '请输入物流公司'
// })
// if (this.payInfo.isExpress && !this.payInfo.expressNo) return this.$message({
// type: 'error',
// message: '请输入物流单号'
// })
if (this.payInfo.isExpress) {
this.$confirm('发货状态一旦确定无法修改,请谨慎操作', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await vk.callFunction({
url: 'client/order/kh/makePay',
title: '请求中...',
data: {payInfo: this.payInfo}
})
this.$message({
type: 'success',
message: '支付单创建成功'
})
this.close()
})
} else {
await vk.callFunction({
url: 'client/order/kh/makePay',
title: '请求中...',
data: {payInfo: this.payInfo}
})
this.$message({
type: 'success',
message: '支付单创建成功'
})
this.close()
}
this.$message({
type: 'success',
message: '支付单创建成功'
})
this.close()
},
changeType() {
this.payInfo.isExpress = ['货到付款', '全款'].includes(this.payInfo.type)
console.log(this.payInfo.isExpress, this.payInfo.type)
if (['尾款', '全款'].includes(this.payInfo.type)) {
this.$confirm(
'选择尾款或全款支付申请,均代表本次交易已完结,对应本次交易最终货款的结算完成。提交申请后,系统无法再次发起付款申请;若发生误申请情形,仅可通过交易双方协商线下渠道协商办理支付,请您谨慎选择。',
'重要提示',
{
confirmButtonText: '确认为最终结算',
showCancelButton: false,
type: 'warning'
}
)
}
},
isNotaryPaidOrder() {
return Number(this.orderInfo.signMethod) === 0
@@ -196,8 +150,8 @@ export default {
},
isPayTypeDisabled(item) {
if (this.isNotaryPaidOrder()) return false
return (!['预付款', '货到付款', '全款'].includes(item) && this.orderInfo.payStatus === 0)
|| (['预付款', '货到付款', '全款'].includes(item) && this.orderInfo.payStatus > 0)
return (!['预付款', '全款'].includes(item) && this.orderInfo.payStatus === 0)
|| (['预付款', '全款'].includes(item) && this.orderInfo.payStatus > 0)
}
},
mounted() {
+14 -9
View File
@@ -72,22 +72,27 @@
</div>
</template>
</el-descriptions-item>
<el-descriptions-item label="交货凭证">
<el-image v-for="(photo, photoIndex) in item.expressPhoto" :key="photoIndex" :src="photo"
style="width: 80px; height: 80px" fit="cover"
:preview-src-list="item.expressPhoto"/>
</el-descriptions-item>
<el-descriptions-item label="备注">
<text>{{ item.remark ? item.remark : '无' }}</text>
</el-descriptions-item>
<el-descriptions-item label="买家备注">
<text>{{ item.buyerRemark ? item.buyerRemark : '无' }}</text>
</el-descriptions-item>
<el-descriptions-item label="图片">
<el-descriptions-item label="凭证">
<template v-if="item.photos && item.photos.length > 0">
<el-image v-for="(photo, photoIndex) in item.photos" :key="photoIndex" :src="photo"
style="width: 80px; height: 80px" fit="cover"
:preview-src-list="item.photos"/>
<el-image v-for="(photo, photoIndex) in item.photos" :key="photoIndex"
:src="typeof photo === 'string' ? photo : photo.url"
style="width: 80px; height: 80px; margin-right: 8px;" fit="cover"
:preview-src-list="item.photos.map(p => typeof p === 'string' ? p : p.url)"/>
</template>
<span v-else>无</span>
</el-descriptions-item>
<el-descriptions-item label="附件">
<template v-if="item.ext && item.ext.length > 0">
<el-image v-for="(file, fileIndex) in item.ext" :key="fileIndex"
:src="typeof file === 'string' ? file : file.url"
style="width: 80px; height: 80px; margin-right: 8px;" fit="cover"
:preview-src-list="item.ext.map(p => typeof p === 'string' ? p : p.url)"/>
</template>
<span v-else>无</span>
</el-descriptions-item>
+12 -1
View File
@@ -427,7 +427,7 @@ export default {
keyword: '',
orderPayList: [],
payInfo: {},
payTypeList: ['预付款', '进度款', '尾款', '货到付款', '全款'],
payTypeList: ['预付款', '进度款', '尾款', '全款'],
topExpiredAt: '',
userInfo: null,
rate: 0,
@@ -460,6 +460,17 @@ export default {
this.userInfo = data.userInfo
}
})
if (options.selfSupportPaySuccess === '1' || options.selfSupportPaySuccess === 1) {
this.$nextTick(() => {
this.$alert('您已成功提交订单,请等待信息提示或平台服务人员通知。', '提示', {
confirmButtonText: '确定',
showClose: false,
closeOnClickModal: false,
closeOnPressEscape: false,
type: 'success'
}).catch(() => {})
})
}
},
methods: {
dayjs,
+35 -18
View File
@@ -1,23 +1,23 @@
<template>
<el-card shadow="never" class="balance-log">
<div slot="header" class="flex justify-between items-center">
<span>余额记录</span>
<div class="flex justify-end items-center">
<div class="query-date mr-20">
<span class="query-date-label">查询日期:</span>
<el-date-picker
v-model="queryDate"
type="date"
size="small"
value-format="yyyyMMdd"
format="yyyy-MM-dd"
:clearable="false"
:editable="false"
placeholder="选择日期"
@change="onQueryDateChange">
</el-date-picker>
</div>
<div class="balance-text mr-20">当前余额:<span class="amount">{{ balanceLoading ? '加载中...' : formatBalance(balance) }}</span></div>
<div slot="header" class="header-bar">
<span class="header-title">余额记录</span>
<div class="query-date">
<span class="query-date-label">查询日期:</span>
<el-date-picker
v-model="queryDate"
type="date"
size="small"
value-format="yyyyMMdd"
format="yyyy-MM-dd"
:clearable="false"
:editable="false"
placeholder="选择日期"
@change="onQueryDateChange">
</el-date-picker>
</div>
<div class="balance-text">当前余额:<span class="amount">{{ balanceLoading ? '加载中...' : formatBalance(balance) }}</span></div>
<div class="header-right">
<el-button type="primary" size="small" :loading="withdrawLoading" @click="openWithdrawDialog">提现</el-button>
</div>
</div>
@@ -328,18 +328,35 @@ export default {
<style lang="scss" scoped>
.balance-log {
.header-bar {
display: flex;
align-items: center;
gap: 24px;
}
.header-title {
flex-shrink: 0;
}
.query-date {
display: flex;
align-items: center;
margin-left: 12px;
}
.query-date-label {
color: #666;
white-space: nowrap;
margin-right: 8px;
}
.balance-text {
color: #666;
white-space: nowrap;
}
.header-right {
margin-left: auto;
}
.amount,
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+2 -2
View File
@@ -26,8 +26,8 @@
<%= htmlWebpackPlugin.options.title %>
</title>
<link rel="stylesheet" href="<%= BASE_URL %>static/index.<%= VUE_APP_INDEX_CSS_HASH %>.css" />
<!-- 配置H5的 web图标static/logo.png -->
<link rel="icon" href="<%= BASE_URL %>static/logo.png">
<!-- 配置H5的 web图标 -->
<link rel="icon" type="image/png" href="<%= BASE_URL %>static/favicon.png">
</head>
<body>
<!-- 该文件为 H5 平台的模板 HTML,并非应用入口。 -->
@@ -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,922 @@
'use strict';
/**
* 合同审查报告 → Word(.docx) 生成器
*
* 数据来源:智能体在 HTML 报告末尾输出的结构化 JSON
* <script type="application/json" id="jjx-report-data">{...}</script>
*
* 排版目标:正式法律文书风格,A4 纵向,可直接打印装订
*/
const {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
WidthType,
AlignmentType,
BorderStyle,
ShadingType,
TableLayoutType,
Footer,
Header,
PageNumber,
VerticalAlign,
ExternalHyperlink
} = require('docx');
const FONT = 'Microsoft YaHei';
const FONT_SONG = 'SimSun';
const C = {
primary: '1A5276',
danger: 'E74C3C',
success: '27AE60',
warning: 'F39C12',
text: '222222',
gray: '777777',
lightBg: 'F5F7F9',
pinkBg: 'FDF2F2',
border: 'D0D7DE'
};
const PAGE_BORDER = {
top: { style: BorderStyle.SINGLE, size: 4, color: C.border },
bottom: { style: BorderStyle.SINGLE, size: 4, color: C.border },
left: { style: BorderStyle.SINGLE, size: 4, color: C.border },
right: { style: BorderStyle.SINGLE, size: 4, color: C.border },
insideHorizontal: { style: BorderStyle.SINGLE, size: 4, color: C.border },
insideVertical: { style: BorderStyle.SINGLE, size: 4, color: C.border }
};
// ---------- 基础元件 ----------
function run(text, o) {
o = o || {};
const opt = {
text: String(text === null || text === undefined ? '' : text),
font: o.font || FONT,
size: o.size || 21,
bold: !!o.bold,
italics: !!o.italics,
color: o.color || C.text
};
if (o.underline) opt.underline = {};
return new TextRun(opt);
}
function para(children, o) {
o = o || {};
const opt = {
children: Array.isArray(children) ? children : [children],
spacing: {
before: o.before === undefined ? 60 : o.before,
after: o.after === undefined ? 60 : o.after,
line: o.line || 320
}
};
if (o.alignment) opt.alignment = o.alignment;
if (o.indent) opt.indent = o.indent;
if (o.border) opt.border = o.border;
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
return new Paragraph(opt);
}
function cell(children, o) {
o = o || {};
const opt = {
children: Array.isArray(children) ? children : [children],
verticalAlign: VerticalAlign.CENTER,
margins: { top: 60, bottom: 60, left: 110, right: 110 }
};
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
if (o.width) opt.width = { size: o.width, type: WidthType.PERCENTAGE };
if (o.columnSpan) opt.columnSpan = o.columnSpan;
return new TableCell(opt);
}
function table(rows, widths) {
return new Table({
rows: rows,
width: { size: 100, type: WidthType.PERCENTAGE },
columnWidths: widths,
layout: TableLayoutType.AUTOFIT,
borders: PAGE_BORDER
});
}
function sectionTitle(no, text) {
return para([run(no + '、' + text, { size: 28, bold: true, color: C.primary })], {
before: 320,
after: 140,
border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: C.primary, space: 4 } }
});
}
function pick(obj, keys, dft) {
if (!obj) return dft;
for (let i = 0; i < keys.length; i++) {
const v = obj[keys[i]];
if (v !== undefined && v !== null && v !== '') return v;
}
return dft;
}
function asArray(v) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
// ---------- 风险条目 ----------
function riskBlock(item, color, levelText) {
const clause = pick(item, ['clause', 'no', '条款', 'clauseNo'], '');
const issue = pick(item, ['issue', 'risk', 'problem', '描述', '问题'], '');
const suggest = pick(item, ['suggestion', 'fix', 'advice', '建议', '修改建议'], '');
const basis = pick(item, ['legal_basis', 'legalBasis', 'basis', '法律依据'], '');
const blocks = [];
blocks.push(para([
run('[' + levelText + ']', { bold: true, color: color, size: 21 }),
run(clause ? ' 第 ' + clause + ' 条' : ' 合同条款', { bold: true, color: color, size: 21 })
], { before: 140, after: 40 }));
blocks.push(para([
run('问题:', { bold: true, size: 21 }),
run(issue, { size: 21 })
], { after: 40, indent: { left: 240 } }));
if (suggest) {
blocks.push(para([
run('建议:', { bold: true, size: 21 }),
run(suggest, { size: 21 })
], { after: 40, indent: { left: 240 } }));
}
if (basis) {
blocks.push(para([
run('依据:', { bold: true, size: 21 }),
run(basis, { size: 21, color: C.gray })
], { after: 80, indent: { left: 240 } }));
}
return blocks;
}
function riskSection(items, color, levelText, emptyText) {
const list = asArray(items);
if (!list.length) {
return [para([run(emptyText || '无', { size: 21, color: C.gray })], { after: 80 })];
}
let out = [];
list.forEach(function (it) {
out = out.concat(riskBlock(it, color, levelText));
});
return out;
}
// ---------- 主函数 ----------
async function buildDocx(input) {
const data = input.data || {};
const fileName = input.fileName || '合同审查报告';
const contractName = pick(data, ['contract_name', 'contractName', '协议名称', 'name'], fileName.replace(/\.[^.]+$/, ''));
const reportTitle = pick(data, ['report_title', 'reportTitle', '报告标题'], '合同审查报告');
const reviewDate = pick(data, ['review_date', 'reviewDate', '审核日期'], new Date().toLocaleDateString('zh-CN'));
const summary = pick(data, ['summary', '统计'], {}) || {};
const conclusion = pick(data, ['conclusion', '审核结论', 'summary_text'], '');
const highRisks = pick(data, ['high_risks', 'highRisks', '高风险'], []);
const mediumRisks = pick(data, ['medium_risks', 'mediumRisks', '中风险'], []);
const lowRisks = pick(data, ['low_risks', 'lowRisks', '低风险'], []);
const suggestions = pick(data, ['suggestions', '优化建议', 'advice_table'], []);
const templates = pick(data, ['clause_templates', 'clauseTemplates', '补充条款'], []);
const legalBases = pick(data, ['legal_bases', 'legalBases', '法律依据'], []);
const cases = pick(data, ['cases', '案例'], []);
const disclaimer = pick(data, ['disclaimer', '免责声明'], '');
const nCompliant = pick(summary, ['compliant', '合规'], 0);
const nPartial = pick(summary, ['partial', '部分合规'], 0);
const nHigh = pick(summary, ['high', '高风险'], asArray(highRisks).length);
const nMedium = pick(summary, ['medium', '中风险'], asArray(mediumRisks).length);
const children = [];
// ===== 抬头 =====
children.push(para([run(reportTitle, { size: 44, bold: true, color: C.primary })], {
alignment: AlignmentType.CENTER,
before: 200,
after: 120
}));
children.push(para([run(contractName, { size: 26, color: C.gray })], {
alignment: AlignmentType.CENTER,
after: 60
}));
children.push(para([run('审核日期:' + reviewDate + ' 出具机构:金久信签约宝', { size: 20, color: C.gray })], {
alignment: AlignmentType.CENTER,
after: 240
}));
// ===== 目录 =====
children.push(sectionTitle('一', '报告目录'));
const tocItems = [
'二、审核结论',
'三、需处理问题(高风险 / 中风险)',
'四、优化建议汇总表',
'五、补充条款模板',
'六、法律依据',
'七、相关案例',
'八、免责声明'
];
tocItems.forEach(function (item) {
children.push(para([run(item, { size: 21 })], { after: 30, indent: { left: 240 } }));
});
// ===== 审核结论 =====
children.push(sectionTitle('二', '审核结论'));
const statRow = new TableRow({
children: [
cell([para([run(String(nCompliant), { size: 40, bold: true, color: C.success })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('合规条款', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'F0F9EB', width: 25 }),
cell([para([run(String(nPartial), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('部分合规', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FDF6EC', width: 25 }),
cell([para([run(String(nHigh), { size: 40, bold: true, color: C.danger })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('高风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FEF0F0', width: 25 }),
cell([para([run(String(nMedium), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('中风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FDF6EC', width: 25 })
]
});
children.push(table([statRow], [2000, 2000, 2000, 2000]));
if (conclusion) {
children.push(para([run('审核结论', { size: 21, bold: true, color: C.primary })], { before: 200, after: 80 }));
String(conclusion).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 21 })], { after: 60, indent: { left: 240 } }));
}
});
}
// ===== 需处理问题 =====
children.push(sectionTitle('三', '需处理问题'));
children.push(para([run('(一)高风险问题', { size: 24, bold: true, color: C.danger })], { before: 140, after: 80 }));
riskSection(highRisks, C.danger, '高风险', '本次审查未发现高风险问题。').forEach(function (p) { children.push(p); });
children.push(para([run('(二)中风险问题', { size: 24, bold: true, color: C.warning })], { before: 200, after: 80 }));
riskSection(mediumRisks, C.warning, '中风险', '本次审查未发现中风险问题。').forEach(function (p) { children.push(p); });
if (asArray(lowRisks).length) {
children.push(para([run('(三)低风险问题', { size: 24, bold: true, color: C.gray })], { before: 200, after: 80 }));
riskSection(lowRisks, C.gray, '低风险', '无').forEach(function (p) { children.push(p); });
}
// ===== 优化建议汇总表 =====
children.push(sectionTitle('四', '优化建议汇总表'));
const sugList = asArray(suggestions);
if (!sugList.length) {
children.push(para([run('无', { size: 21, color: C.gray })]));
} else {
const headRow = new TableRow({
tableHeader: true,
children: [
cell([para([run('优先级', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 12 }),
cell([para([run('条款编号', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 15 }),
cell([para([run('问题描述', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 38 }),
cell([para([run('建议方案', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 35 })
]
});
const rows = [headRow];
sugList.forEach(function (s, i) {
const lv = String(pick(s, ['priority', 'level', '优先级'], 'M')).toUpperCase();
const lvBg = lv === 'H' ? C.danger : (lv === 'L' ? '999999' : C.warning);
rows.push(new TableRow({
children: [
cell([para([run(lv, { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: lvBg, width: 12 }),
cell([para([run(pick(s, ['clause', 'no', '条款编号'], '-'), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 15 }),
cell([para([run(pick(s, ['issue', 'problem', '问题描述', '描述'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 38 }),
cell([para([run(pick(s, ['solution', 'suggestion', '建议方案', '建议'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 35 })
]
}));
});
children.push(table(rows, [1200, 1500, 3800, 3500]));
}
// ===== 补充条款模板 =====
children.push(sectionTitle('五', '补充条款模板'));
const tplList = asArray(templates);
if (!tplList.length) {
children.push(para([run('无', { size: 21, color: C.gray })]));
} else {
tplList.forEach(function (t, i) {
const title = pick(t, ['title', 'name', '条款名称'], '补充条款 ' + (i + 1));
const content = pick(t, ['content', 'text', '条文', '内容'], '');
children.push(para([run(String(i + 1) + '. ' + title, { size: 21, bold: true, color: C.primary })], { before: 140, after: 60 }));
String(content).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 20, font: FONT_SONG })], {
after: 50,
indent: { left: 240 },
shading: C.lightBg,
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.primary, space: 6 } }
}));
}
});
});
}
// ===== 法律依据 =====
children.push(sectionTitle('六', '法律依据'));
const lawList = asArray(legalBases);
if (!lawList.length) {
children.push(para([run('本报告未引用具体法条,相关意见为实务建议,具体法律适用需律师人工核实。', { size: 20, color: C.gray })]));
} else {
lawList.forEach(function (l, i) {
const law = pick(l, ['law', 'name', '法律名称'], '');
const article = pick(l, ['article', 'no', '条款编号'], '');
const content = pick(l, ['content', 'text', '引用内容', '内容'], '');
children.push(para([
run(String(i + 1) + '. ', { size: 20, bold: true }),
run(law + (article ? ' ' + article : ''), { size: 20, bold: true, color: C.primary }),
run(content ? ':' + content : '', { size: 20 })
], { after: 60, indent: { left: 120 } }));
});
}
// ===== 相关案例 =====
children.push(sectionTitle('七', '相关案例'));
const caseList = asArray(cases);
if (!caseList.length) {
children.push(para([run('暂未检索到公开案例,建议人工核实。', { size: 20, color: C.gray })]));
} else {
const caseHead = new TableRow({
tableHeader: true,
children: [
cell([para([run('序号', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
cell([para([run('案件名称·案由', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 26 }),
cell([para([run('法院·机构', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 14 }),
cell([para([run('年份', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
cell([para([run('判决要点', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 28 }),
cell([para([run('与风险点关联', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 16 })
]
});
const caseRows = [caseHead];
caseList.forEach(function (c, i) {
const name = pick(c, ['name', 'title', '案件名称'], '-');
const url = pick(c, ['url', 'link', '链接'], '');
const court = pick(c, ['court', 'org', '法院'], '-');
const year = pick(c, ['year', '年份'], '-');
const result = pick(c, ['result', 'point', '判决要点', '要点'], '-');
const relation = pick(c, ['relation', '关联', '与本次风险点关联'], '-');
const nameCellChildren = [para([run(name, { size: 19, bold: true })], { after: url ? 20 : 0 })];
if (url) {
const host = url.replace(/^https?:\/\//i, '').split('/')[0];
nameCellChildren.push(para([
new ExternalHyperlink({
link: url,
children: [run('来源:' + host + ' ↗', { size: 16, color: '2E74B5', underline: true })]
})
], {}));
}
caseRows.push(new TableRow({
children: [
cell([para([run(String(i + 1), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
cell(nameCellChildren, { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 26 }),
cell([para([run(court, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 14 }),
cell([para([run(year, { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
cell([para([run(result, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 28 }),
cell([para([run(relation, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 16 })
]
}));
});
children.push(table(caseRows, [700, 2300, 1300, 700, 2500, 1500]));
}
// ===== 免责声明 =====
children.push(sectionTitle('八', '免责声明'));
const disText = disclaimer || '本报告由人工智能根据用户上传的合同文本自动生成,仅供内部风险排查参考,不构成正式法律意见,亦不能替代执业律师的专业审查。涉及重大交易、争议解决或诉讼事项,请务必委托执业律师复核。报告中的法律法规引用以官方公布文本为准,案例信息来源于公开检索结果,具体适用请人工核实。';
String(disText).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 19, color: C.gray })], {
after: 50,
shading: C.pinkBg,
indent: { left: 120, right: 120 },
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.danger, space: 6 } }
}));
}
});
// ===== 文档 =====
const doc = new Document({
creator: '金久信签约宝',
title: contractName + ' - 合同审查报告',
description: 'AI 合同审查报告',
styles: {
default: {
document: {
run: { font: FONT, size: 21, color: C.text }
}
}
},
sections: [{
properties: {
page: {
margin: { top: 1134, right: 1134, bottom: 1134, left: 1134 }
}
},
headers: {
default: new Header({
children: [
para([
run('金久信签约宝 · AI 合同审查报告', { size: 18, color: C.gray }),
run(' ' + contractName, { size: 18, color: C.gray })
], { alignment: AlignmentType.RIGHT, after: 0 })
]
})
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: '第 ', font: FONT, size: 18, color: C.gray }),
new TextRun({ children: [PageNumber.CURRENT], font: FONT, size: 18, color: C.gray }),
new TextRun({ text: ' 页 / 共 ', font: FONT, size: 18, color: C.gray }),
new TextRun({ children: [PageNumber.TOTAL_PAGES], font: FONT, size: 18, color: C.gray }),
new TextRun({ text: ' 页', font: FONT, size: 18, color: C.gray })
]
})
]
})
},
children: children
}]
});
return await Packer.toBuffer(doc);
}
// ---------- HTML 解析(生产数据兜底通道) ----------
// 智能体实际返回的是纯 HTML 报告,未必带 jjx-report-data 数据块,
// 这里按区块结构把 HTML 还原成结构化数据,无需修改智能体提示词。
function decodeEntities(s) {
return String(s || '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&#(\d+);/g, function (m, d) { return String.fromCharCode(Number(d)); })
.replace(/&#x([0-9a-fA-F]+);/g, function (m, h) { return String.fromCharCode(parseInt(h, 16)); });
}
function stripTags(html) {
return decodeEntities(
String(html || '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h\d|section)>/gi, '\n')
.replace(/<li[^>]*>/gi, '\n')
.replace(/<[^>]+>/g, '')
)
.replace(/[ \t\u00a0]+/g, ' ')
.replace(/\n[ ]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function pickDiv(html, cls) {
const re = new RegExp('<div[^>]*class="[^"]*\\b' + cls + '\\b[^"]*"[^>]*>([\\s\\S]*?)<\\/div>', 'i');
const m = String(html || '').match(re);
return m ? stripTags(m[1]) : '';
}
// 按 <h2> 把报告切成区块,返回 { 区块名: 区块内HTML }
// 兼容:智能体偶尔用 <h3> 代替 <h2> 做区块标题,h2 不足 2 个时降级按 h3 切
function splitByH2(html) {
const map = {};
const h2Count = (html.match(/<h2[^>]*>/gi) || []).length;
const re = h2Count >= 2
? /<h2[^>]*>([\s\S]*?)<\/h2>/gi
: /<h3[^>]*>([\s\S]*?)<\/h3>/gi;
const marks = [];
let m;
while ((m = re.exec(html))) {
marks.push({ name: stripTags(m[1]).replace(/\s+/g, ''), start: m.index, contentStart: re.lastIndex });
}
for (let i = 0; i < marks.length; i++) {
const end = i + 1 < marks.length ? marks[i + 1].start : html.length;
map[marks[i].name] = html.slice(marks[i].contentStart, end);
}
return map;
}
function findSection(map, keywords) {
const keys = Object.keys(map);
for (let i = 0; i < keys.length; i++) {
for (let j = 0; j < keywords.length; j++) {
if (keys[i].indexOf(keywords[j]) >= 0) return map[keys[i]];
}
}
return '';
}
function parseTable(tableHtml) {
const rows = [];
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let m;
while ((m = trRe.exec(tableHtml))) {
const cells = [];
const tdRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
let c;
while ((c = tdRe.exec(m[1]))) cells.push(stripTags(c[1]));
if (cells.length) rows.push(cells);
}
return rows;
}
function extractTables(html) {
const tables = [];
const re = /<table[^>]*>([\s\S]*?)<\/table>/gi;
let m;
while ((m = re.exec(html))) tables.push(parseTable(m[1]));
return tables;
}
// 案例表:需要在行内同时取出单元格文本和 a[href](按行取,避免跨行错位)
function parseRowsWithLinks(tableHtml) {
const rows = [];
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let m;
while ((m = trRe.exec(tableHtml))) {
const tr = m[1];
const cells = [];
let url = '';
const tdRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
let c;
while ((c = tdRe.exec(tr))) {
cells.push(stripTags(c[1]));
if (!url) {
const am = c[1].match(/<a[^>]*href="([^"]+)"/i);
if (am) url = am[1];
}
}
if (cells.length) rows.push({ cells: cells, url: url });
}
return rows;
}
function shortClause(text) {
const t = String(text || '').trim();
if (!t) return '-';
if (/^[0-9.]+$/.test(t)) return t;
return t.length > 16 ? t.slice(0, 16) + '…' : t;
}
function levelOf(text, tagCls) {
if (/高/.test(text)) return 'high';
if (/中/.test(text)) return 'medium';
if (/低/.test(text)) return 'low';
if (tagCls) return tagCls.toLowerCase();
return '';
}
function collectRiskItems(body, level, out) {
const chunks = String(body || '').split(/<div[^>]*class="[^"]*\brisk-item\b[^"]*"[^>]*>/i).slice(1);
chunks.forEach(function (chunk) {
// 级别标签两种写法:<span class="risk-tag high"> 或 <span class="tag h">
const tagMatch = chunk.match(/<span[^>]*class="[^"]*risk-tag\s+(high|medium|low)[^"]*"[^>]*>/i)
|| chunk.match(/<span[^>]*class="[^"]*tag\s+(high|medium|low|h|m|l)\b[^"]*"[^>]*>/i);
const tagRaw = tagMatch ? tagMatch[1].toLowerCase() : '';
const tagLv = tagRaw === 'h' ? 'high' : tagRaw === 'm' ? 'medium' : tagRaw === 'l' ? 'low' : tagRaw;
// 优先级:所在区块标题 > 条目自身标签 > 默认 medium
const lv = level || tagLv || 'medium';
// 字段名两种写法:risk-clause/risk-desc/risk-fix 或 clause/desc/fix
let clauseText = pickDiv(chunk, 'risk-clause') || pickDiv(chunk, 'clause');
clauseText = clauseText.replace(/^[HML]\s*/, '').trim();
const clauseNum = clauseText.match(/条款\s*([0-9.]+)/);
const clause = shortClause(clauseNum ? clauseNum[1] : clauseText);
const issue = pickDiv(chunk, 'risk-desc') || pickDiv(chunk, 'desc');
let fix = (pickDiv(chunk, 'risk-fix') || pickDiv(chunk, 'fix')).replace(/^\s*(修复建议|建议)[::]\s*/, '');
if (!clauseText && !issue) return;
const item = {
level: lv,
clause: clause || '-',
issue: issue || clauseText || '-',
suggestion: fix || ''
};
// 条款标题里的补充信息(如“交付清单、全部附件空白”)拼进问题描述,避免丢信息
if (clauseNum && clauseText) {
const rest = clauseText.replace(/条款\s*[0-9.]+\s*/, '').trim();
if (rest && item.issue.indexOf(rest) < 0) item.issue = rest + ':' + item.issue;
}
if (!out[lv]) out[lv] = [];
out[lv].push(item);
});
}
function parseRisks(sectionHtml, hint) {
const out = { high: [], medium: [], low: [] };
const parts = String(sectionHtml || '').split(/<h3[^>]*>/i);
if (parts.length > 1) {
parts.slice(1).forEach(function (part) {
const headEnd = part.search(/<\/h3>/i);
const headText = headEnd >= 0 ? stripTags(part.slice(0, headEnd)) : '';
const body = headEnd >= 0 ? part.slice(headEnd + 5) : part;
const lv = levelOf(headText, '') || hint || '';
collectRiskItems(body, lv, out);
});
} else {
collectRiskItems(sectionHtml, hint || '', out);
}
return out;
}
/**
* 从智能体生成的真实 HTML 报告里还原结构化数据
*/
function parseHtmlReport(html) {
if (!html || typeof html !== 'string') return null;
if (html.indexOf('<') < 0) return null;
const raw = html
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '');
const data = {};
// 标题 / 合同名 / 日期
const h1 = raw.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
const reportTitle = h1 ? stripTags(h1[1]) : '';
const nameM = raw.match(/合同名称[::]\s*([^<\n]+)/);
const dateM = raw.match(/审核日期[::]\s*([^<\n]+)/);
const titleM = raw.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
data.contract_name = nameM ? nameM[1].trim()
: (reportTitle || (titleM ? stripTags(titleM[1]) : '')).replace(/审查报告$/, '').trim();
data.report_title = reportTitle || '';
data.review_date = dateM ? dateM[1].trim() : '';
// 统计卡片:按 num + label 配对抓取
const summary = {};
function applyCard(numRaw, labelRaw) {
const num = stripTags(numRaw).trim();
const label = stripTags(labelRaw).replace(/\s+/g, '');
if (!label) return;
const val = /^[0-9]+$/.test(num) ? Number(num) : num;
if (label.indexOf('部分') >= 0) summary.partial = val;
else if (label.indexOf('高') >= 0) summary.high = val;
else if (label.indexOf('中') >= 0) summary.medium = val;
else if (label.indexOf('低') >= 0) summary.low = val;
else if (label.indexOf('合规') >= 0) summary.compliant = val;
}
// 写法一:label 有 class="label"
const cardRe = /<div[^>]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<div[^>]*class="[^"]*\blabel\b[^"]*"[^>]*>([\s\S]*?)<\/div>/gi;
let cm;
while ((cm = cardRe.exec(raw))) applyCard(cm[1], cm[2]);
// 写法二:label 是紧跟的无 class <div>
if (!Object.keys(summary).length) {
const cardRe2 = /<div[^>]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<div[^>]*>([\s\S]*?)<\/div>/gi;
while ((cm = cardRe2.exec(raw))) applyCard(cm[1], cm[2]);
}
data.summary = summary;
const sections = splitByH2(raw);
// 审核结论
const conclusionHtml = findSection(sections, ['审核结论']);
data.conclusion = stripTags(conclusionHtml).replace(/\n{2,}/g, '\n');
// 需处理问题:兼容两种结构
// ① 单一「需处理问题」总区块(内含高风险/中风险小节)
// ② 「高风险问题」「中风险问题」各自独立成区块
const riskKeywords = ['需处理问题', '风险问题', '问题清单', '高风险', '中风险', '低风险'];
const risks = { high: [], medium: [], low: [] };
Object.keys(sections).forEach(function (key) {
const hit = riskKeywords.some(function (k) { return key.indexOf(k) >= 0; });
if (!hit) return;
const part = parseRisks(sections[key], levelOf(key, ''));
['high', 'medium', 'low'].forEach(function (lv) {
risks[lv] = risks[lv].concat(part[lv] || []);
});
});
data.high_risks = risks.high;
data.medium_risks = risks.medium;
data.low_risks = risks.low;
// 优化建议汇总表
const sugHtml = findSection(sections, ['优化建议', '建议汇总']);
const sugTables = extractTables(sugHtml);
const suggestions = [];
if (sugTables.length) {
sugTables[0].forEach(function (row, i) {
if (i === 0) return;
if (/优先级|条款编号/.test(row.join(''))) return;
suggestions.push({
priority: (row[0] || '').replace(/\s+/g, '').slice(0, 1).toUpperCase() || 'M',
clause: row[1] || '-',
issue: row[2] || '-',
solution: row[3] || '-'
});
});
}
data.suggestions = suggestions;
// 补充条款模板(部分报告没有该区块)
// 三种写法:<pre> 代码块 / <div class="clause-template"> 模板块 / 整段纯文本
const tplHtml = findSection(sections, ['补充条款']);
const templates = [];
if (tplHtml) {
const pres = tplHtml.match(/<pre[^>]*>([\s\S]*?)<\/pre>/gi) || [];
pres.forEach(function (p, i) {
templates.push({ title: '补充条款 ' + (i + 1), content: stripTags(p) });
});
if (!templates.length) {
// 模板块:class="clause-template",标题取内部 <strong>【...】</strong>
const tplDivs = tplHtml.match(/<div[^>]*class="[^"]*\bclause-template\b[^"]*"[^>]*>([\s\S]*?)<\/div>/gi) || [];
tplDivs.forEach(function (d, i) {
const text = stripTags(d).trim();
if (!text) return;
const tm = d.match(/<strong[^>]*>([\s\S]*?)<\/strong>/i);
const title = tm ? stripTags(tm[1]).replace(/^【|】$/g, '').trim() : '';
templates.push({ title: title || '补充条款 ' + (i + 1), content: text.replace(/^(【[^】]*】)/, '').trim() });
});
}
if (!templates.length) {
const text = stripTags(tplHtml);
if (text) templates.push({ title: '补充条款', content: text });
}
}
data.clause_templates = templates;
// 法律依据
const lawHtml = findSection(sections, ['法律依据', '法律法规']);
const legalBases = [];
const liRe = /<li[^>]*>([\s\S]*?)<\/li>/gi;
let lm;
while ((lm = liRe.exec(lawHtml))) {
const text = stripTags(lm[1]);
if (!text) continue;
const seg = text.match(/^(.+?第[0-9]+条[之]*[一二三四五六七八九十]*)[::]\s*([\s\S]*)$/);
if (seg) legalBases.push({ law: seg[1], article: '', content: seg[2] });
else legalBases.push({ law: '', article: '', content: text });
}
if (!legalBases.length) {
const text = stripTags(lawHtml);
if (text) {
text.split(/\n+/).forEach(function (line) {
line = line.trim();
if (!line) return;
const seg = line.match(/^(.+?第[0-9]+条[之]*[一二三四五六七八九十]*)[::]\s*([\s\S]*)$/);
if (seg) legalBases.push({ law: seg[1], article: '', content: seg[2] });
else legalBases.push({ law: '', article: '', content: line });
});
}
}
data.legal_bases = legalBases;
// 案例
const caseHtml = findSection(sections, ['案例', '判例']);
const caseTableMatch = caseHtml.match(/<table[^>]*>([\s\S]*?)<\/table>/i);
const cases = [];
if (caseTableMatch) {
const rows = parseRowsWithLinks(caseTableMatch[1]);
rows.forEach(function (r) {
const row = r.cells;
if (!row.length) return;
if (/案件名称/.test(row.join('')) || /^序号$/.test(row[0])) return; // 表头
const hasLinkCol = row.length >= 7;
cases.push({
name: row[hasLinkCol ? 1 : 0] || '-',
url: r.url || '',
court: row[hasLinkCol ? 3 : 2] || '-',
year: row[hasLinkCol ? 4 : 3] || '-',
result: row[hasLinkCol ? 5 : 4] || '-',
relation: row[hasLinkCol ? 6 : 5] || '-'
});
});
}
data.cases = cases;
// 免责声明(优先按 h2 区块取,取不到再从全局找 class="disclaimer")
const disHtml = findSection(sections, ['免责声明', '免责']);
let disclaimer = pickDiv(disHtml, 'disclaimer') || stripTags(disHtml);
if (!disclaimer) {
const dm = raw.match(/<div[^>]*class="[^"]*\bdisclaimer\b[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
if (dm) disclaimer = stripTags(dm[1]);
}
data.disclaimer = disclaimer;
// 生成时间(页脚)
const footM = raw.match(/生成时间[::]\s*([^<\n]+)/);
if (footM && !data.review_date) data.review_date = footM[1].trim();
// 完全没解析出内容则视为失败
if (!data.conclusion && !data.high_risks.length && !data.suggestions.length) return null;
return data;
}
/**
* 从智能体回复里提取「插件生成的 Word 文件链接」
* 只认显式标记,避免误抓案例正文里的 .docx 链接
* 1. <a id="jjx-docx-link" href="https://...">下载 Word</a>
* 2. <!-- jjx-docx: https://... -->
*/
function extractDocxLink(answer) {
if (!answer) return '';
const a = answer.match(/<a[^>]*id=["']jjx-docx-link["'][^>]*href=["']([^"']+)["']/i);
if (a && a[1]) return a[1];
const b = answer.match(/<!--\s*jjx-docx\s*[::]\s*(https?:\/\/[^\s>]+)\s*-->/i);
if (b && b[1]) return b[1];
return '';
}
/**
* 判断智能体回复里是否有一份「可以转成 Word 的报告」
*
* 返回 true 的两种情况:
* 1. 智能体插件已经生成了 Word 并给了链接 → 直接复用,不用本地转
* 2. 回复里有完整的 HTML 报告(有 <html> 开头且有 </html> 结尾)
*
* 返回 false 的典型情况:
* - 「很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败…」这类道歉/澄清文本
* - HTML 被 max token 截断,缺 </html> —— 强行转会生成一份残缺的 Word,宁可不转
*/
function isDocxConvertible(answer) {
if (!answer || typeof answer !== 'string') return false;
if (extractDocxLink(answer)) return true;
if (!/<!DOCTYPE\s+html|<html[\s>]/i.test(answer)) return false;
return /<\/html>/i.test(answer);
}
/**
* 从智能体返回的 HTML 中提取结构化报告数据
* 优先级:<script id="jjx-report-data"> → ```json 代码块 → HTML 结构解析
*/
function extractReportData(answer) {
if (!answer) return null;
const m = answer.match(/<script[^>]*id=["']jjx-report-data["'][^>]*>([\s\S]*?)<\/script>/i);
if (m && m[1]) {
try {
return JSON.parse(m[1].trim());
} catch (e) {
console.log('[Docx] jjx-report-data JSON 解析失败,改用 HTML 解析:', e.message);
}
}
const blocks = answer.match(/```json\s*([\s\S]*?)```/g) || [];
for (let i = blocks.length - 1; i >= 0; i--) {
try {
const raw = blocks[i].replace(/```json\s*/, '').replace(/```$/, '').trim();
const obj = JSON.parse(raw);
if (obj && (obj.high_risks || obj.highRisks || obj.summary)) return obj;
} catch (e) { /* 继续找 */ }
}
// 兜底:直接解析 HTML 报告结构
try {
const parsed = parseHtmlReport(answer);
if (parsed) {
console.log('[Docx] 已从 HTML 报告解析结构化数据');
return parsed;
}
// 诊断:打印实际 HTML 结构关键特征,定位解析失败原因
const h2s = (answer.match(/<h2[^>]*>([\s\S]*?)<\/h2>/gi) || [])
.map(function (h) { return h.replace(/<[^>]+>/g, '').replace(/\s+/g, '').slice(0, 20); });
console.log('[Docx] HTML 解析为空。诊断 → h2区块[' + h2s.length + ']:',
h2s.length ? h2s.join(' | ') : '无h2标签',
'| 有table:', /<table/i.test(answer),
'| 有risk-item:', /risk-item/i.test(answer),
'| 有审核结论字样:', /审核结论|审查结论/.test(answer),
'| 长度:', answer.length);
} catch (e) {
console.log('[Docx] HTML 报告解析失败:', e.message);
}
return null;
}
module.exports = {
buildDocx,
extractReportData,
parseHtmlReport,
extractDocxLink,
isDocxConvertible
};
@@ -0,0 +1,282 @@
'use strict';
// 定时任务:每分钟检查 reviewing 状态的记录,触发审查
const db = uniCloud.database();
const _ = db.command;
// 导入审查执行函数(从 router 的 service 目录)
// 注意:定时任务是独立云函数,不能直接 require router 的 service
// 所以把审查逻辑直接写在这里
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
const BOT_ID = '7689821072762011698';
const { buildDocx, extractReportData, extractDocxLink, isDocxConvertible } = require('./buildDocx.js');
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);
}
// 生成 Word 报告:优先用智能体插件生成的链接,否则本地结构化重建
let docxUrl = extractDocxLink(answer);
let docxFileId = '';
if (docxUrl) {
console.log('[Docx] 采用智能体插件生成的 Word 链接:', docxUrl);
} else if (!isDocxConvertible(answer)) {
// 智能体没返回完整报告(如「文件解析失败」的道歉文本,或 HTML 被截断),
// 不生成 Word,避免产出一份残缺文档
console.log('[Docx] 无完整 HTML 报告,跳过 Word 生成');
} else {
try {
const reportData = extractReportData(answer);
if (!reportData) {
console.log('[Docx] 既无插件链接也无结构化数据,跳过 Word 生成');
} else {
const docxBuffer = await buildDocx({
data: reportData,
fileName: file_name || '合同审查报告'
});
const baseName = (file_name || 'report').replace(/\.[^.]+$/, '');
const docxPath = 'review-reports/' + Date.now() + '_' + baseName + '_审查报告.docx';
const docxRes = await uniCloud.uploadFile({
cloudPath: docxPath,
fileContent: docxBuffer
});
if (docxRes.fileID) {
docxFileId = docxRes.fileID;
const docxUrlRes = await uniCloud.getTempFileURL({ fileList: [docxRes.fileID] });
if (docxUrlRes.fileList && docxUrlRes.fileList[0]) {
docxUrl = docxUrlRes.fileList[0].tempFileURL;
}
console.log('[Docx] 本地 Word 报告生成成功:', docxFileId);
}
}
} catch (e) {
console.log('[Docx] Word 报告生成失败(不影响审查结果):', e.message);
}
}
// 更新记录为完成
await db.collection('jjx-review-records').doc(record_id).update({
status: 'completed',
result: answer,
html_url: htmlUrl,
docx_url: docxUrl,
docx_file_id: docxFileId,
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,336 @@
#!/usr/bin/env node
/**
* 本地自测脚本:智能体返回的 HTML 审查报告 → Word 文档
* 纯本地运行,不依赖 uniCloud,不联网,不动数据库。
*
* 用法(在本目录下执行):
* node local-test.js # 用内置样例跑一遍,验证环境
* node local-test.js report.html # 转换指定 HTML(输出到同目录)
* node local-test.js report.html out.docx # 指定输出路径
* node local-test.js dir/ # 批量转换目录下所有 .html/.txt
* node local-test.js report.html --json # 只打印解析结果,不生成 Word
* node local-test.js report.html --open # 生成后自动用 Word 打开
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { exec } = require('child_process');
const { buildDocx, parseHtmlReport, extractDocxLink, isDocxConvertible } = require('./buildDocx.js');
// ==================== 内置样例(覆盖全部区块,用于环境自检) ====================
const SAMPLE_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>样例合同审查报告</title></head>
<body>
<div class="header">
<h1>技术开发与服务合同审查报告</h1>
<p>合同名称:金久信钢城平台AI功能接入开发及运维服务合同</p>
<p>审核日期:2026年10月23日</p>
</div>
<div class="card-wrap">
<div class="stat-card green"><div class="num">0</div><div class="label">合规</div></div>
<div class="stat-card orange"><div class="num">C</div><div class="label">部分合规</div></div>
<div class="stat-card red"><div class="num">2</div><div class="label">高风险</div></div>
<div class="stat-card yellow"><div class="num">1</div><div class="label">中风险</div></div>
</div>
<div class="section">
<h2>审核结论</h2>
<p>本合同核心架构完整,但存在多处核心条款空白,整体风险等级为中高风险,需补充完善后方可签署。</p>
</div>
<div class="section">
<h2>需处理问题</h2>
<h3>🔴 高风险问题</h3>
<div class="risk-item high">
<div class="risk-clause"><span class="risk-tag high">H</span>条款2.3 交付清单、全部附件空白</div>
<div class="risk-desc">合同第2.3条交付清单表格完全空白,未明确交付物名称与交付时间,极易引发履约争议。</div>
<div class="risk-fix">修复建议:详细填写交付清单,明确每项交付物的名称、数量、交付时间。</div>
</div>
<div class="risk-item high">
<div class="risk-clause"><span class="risk-tag high">H</span>合同首部多项核心字段空白</div>
<div class="risk-desc">合同名称、签订地点、合同金额等核心字段均为空白,属于典型的空白合同。</div>
<div class="risk-fix">修复建议:将所有空白字段填写完整,无需填写的标注"无"。</div>
</div>
<h3>🟠 中风险问题</h3>
<div class="risk-item medium">
<div class="risk-clause"><span class="risk-tag medium">M</span>条款9 违约责任约定过于笼统</div>
<div class="risk-desc">违约责任仅约定逾期违约金,未约定交付不合格、侵犯知识产权等情形。</div>
<div class="risk-fix">修复建议:补充交付不合格、侵权、擅自转包的违约责任。</div>
</div>
</div>
<div class="section">
<h2>优化建议汇总表</h2>
<table>
<thead><tr><th>优先级</th><th>条款编号</th><th>问题描述</th><th>建议方案</th></tr></thead>
<tbody>
<tr><td>H</td><td>2.3</td><td>交付清单空白</td><td>详细填写交付清单</td></tr>
<tr><td>H</td><td>合同首部</td><td>核心字段空白</td><td>填写所有空白字段</td></tr>
<tr><td>M</td><td>9</td><td>违约责任笼统</td><td>补充违约情形</td></tr>
</tbody>
</table>
</div>
<div class="section">
<h2>补充条款模板</h2>
<div class="clause-template"><h3>转包限制条款</h3>
<pre>未经甲方书面同意,乙方不得将本合同项下的任何工作转包或分包给第三方,否则视为乙方根本违约。</pre></div>
</div>
<div class="section">
<h2>法律依据</h2>
<ul>
<li>《民法典》第510条:合同生效后,当事人就质量、价款或者报酬等内容没有约定或者约定不明确的,可以协议补充。</li>
<li>《民法典》第577条:当事人一方不履行合同义务或者履行合同义务不符合约定的,应当承担违约责任。</li>
</ul>
</div>
<div class="section">
<h2>相关司法案例</h2>
<table>
<thead><tr><th>序号</th><th>案件名称·案由</th><th>资料链接</th><th>法院·机构</th><th>裁判年份</th><th>判决要点</th><th>关联风险点</th></tr></thead>
<tbody>
<tr><td>1</td><td>空白合同效力认定纠纷</td>
<td><a class="case-link" href="https://example.com/case-1" target="_blank">打开裁判页面 ↗</a></td>
<td>最高人民法院</td><td>2019年</td><td>交付空白合同视为概括授权</td><td>空白合同字段风险</td></tr>
<tr><td>2</td><td>技术开发合同交付约定不明纠纷</td>
<td><a class="case-link" href="https://example.com/case-2" target="_blank">打开裁判页面 ↗</a></td>
<td>某中级人民法院</td><td>2021年</td><td>约定不明按交易习惯确定</td><td>交付方式不明风险</td></tr>
</tbody>
</table>
</div>
<div class="disclaimer">本报告仅基于您提供的合同文本出具,不构成任何法律意见。</div>
<div class="footer">金久信签约宝 · AI合同审查服务 © 2026 | 生成时间:2026年10月23日</div>
</body>
</html>`;
// ==================== 用例:智能体返回「文件读不了」的道歉文本 ====================
// 这种回复里没有报告,线上应当跳过 Word 生成,而不是产出一份空文档
const APOLOGY_TEXT = '很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败,暂时无法对该文件开展审查工作。建议您选择以下任一方式解决: 1. 将该DOC文件转换为DOCX、PDF、TXT等兼容性更好的格式后重新上传; 2. 直接粘贴合同的完整文本内容发送给我,我会立即为您生成专业的合同审查报告。';
// ==================== 工具函数 ====================
/** 清洗智能体回复:去掉 markdown 包裹、前后说明文字,只留 HTML 本体 */
function cleanAnswer(text) {
let s = String(text || '');
const fence = s.match(/```(?:html)?\s*([\s\S]*?)```/i);
if (fence && fence[1] && /<(!DOCTYPE|html)/i.test(fence[1])) s = fence[1];
const start = s.search(/<!DOCTYPE\s+html|<html[\s>]/i);
if (start > 0) s = s.slice(start);
const end = s.toLowerCase().lastIndexOf('</html>');
if (end > 0) s = s.slice(0, end + 7);
return s.trim();
}
function pad(s, n) {
const str = String(s);
// 中文按 2 宽度算
let w = 0;
for (const ch of str) w += /[\u4e00-\u9fa5\uff00-\uffef]/.test(ch) ? 2 : 1;
return str + ' '.repeat(Math.max(0, n - w));
}
function line(label, value) {
console.log(' ' + pad(label, 14) + ': ' + value);
}
/** 打印解析结果摘要 */
function summarize(data) {
if (!data) {
console.log(' ✗ 解析失败:未提取到任何有效内容(结论/风险/建议全空)');
return false;
}
const s = data.summary || {};
line('报告标题', data.report_title || '(空)');
line('合同名称', data.contract_name || '(空)');
line('审核日期', data.review_date || '(空)');
line('统计卡片', `合规 ${s.compliant || 0} / 部分合规 ${s.partial || 0} / 高风险 ${s.high || 0} / 中风险 ${s.medium || 0}`);
line('风险项', `高 ${(data.high_risks || []).length} / 中 ${(data.medium_risks || []).length} / 低 ${(data.low_risks || []).length}`);
line('优化建议', (data.suggestions || []).length + ' 条');
line('补充条款', (data.clause_templates || []).length + ' 条');
line('法律依据', (data.legal_bases || []).length + ' 条');
line('案例', (data.cases || []).length + ' 个');
line('免责声明', data.disclaimer ? `已提取(${data.disclaimer.length} 字)` : '(空)');
const cases = data.cases || [];
if (cases.length) {
console.log(' --- 案例链接核对 ---');
cases.forEach((c, i) => {
const url = c.url ? c.url : '(无链接)';
console.log(` ${i + 1}. ${c.name || '-'}`);
console.log(` 法院/年份: ${c.court || '-'} / ${c.year || '-'}`);
console.log(` 链接: ${url}`);
});
}
const risks = [...(data.high_risks || []), ...(data.medium_risks || []), ...(data.low_risks || [])];
if (risks.length) {
console.log(' --- 风险条款号 ---');
risks.forEach((r, i) => {
console.log(` ${i + 1}. [${r.level || '-'}] ${r.clause || '-'} — ${(r.issue || '').slice(0, 40)}`);
});
}
return true;
}
/** 转换单个 HTML 文件/字符串 */
async function convert(label, html, outPath) {
console.log('');
console.log('════════════════════════════════════════');
console.log('▶ ' + label);
console.log('════════════════════════════════════════');
const cleaned = cleanAnswer(html);
if (!cleaned) {
console.log(' ✗ 内容为空');
return 'fail';
}
// 通道判断(与线上逻辑一致)
const pluginLink = extractDocxLink(cleaned);
if (pluginLink) {
console.log(' ⚑ 检测到智能体插件返回的 Word 链接:');
console.log(' ' + pluginLink);
console.log(' (线上会直接使用该链接,不本地生成)');
if (!/<!DOCTYPE\s+html|<html[\s>]/i.test(cleaned)) {
console.log(' ⊘ 回复里没有 HTML 报告,线上直接复用插件链接,不做本地转换');
return 'skip';
}
}
console.log(' 原文长度: ' + cleaned.length + ' 字符');
// 关键拦截:智能体有时不返回报告,只回一段道歉/澄清文字
// (例如「很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败…」)
// 线上此时不生成 Word,这里保持一致,避免产出一份残缺文档。
if (!isDocxConvertible(cleaned)) {
console.log(' ⊘ 这不是一份完整报告,线上会跳过 Word 生成(不产出空文档)');
console.log(' 开头内容:' + cleaned.slice(0, 70).replace(/\s+/g, ' ') + '…');
return 'skip';
}
const data = parseHtmlReport(cleaned);
const ok = summarize(data);
if (!ok) return 'fail';
if (FLAGS.jsonOnly) {
console.log(' (--json 模式,未生成 Word)');
return 'skip';
}
const t0 = Date.now();
const buffer = await buildDocx({ data: data, fileName: data.contract_name || '合同审查报告' });
const ms = Date.now() - t0;
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, buffer);
console.log(' ✓ Word 已生成');
line('输出文件', outPath);
line('文件大小', (buffer.length / 1024).toFixed(1) + ' KB');
line('耗时', ms + ' ms');
if (FLAGS.open) {
exec(`start "" "${outPath}"`, { shell: 'cmd.exe' });
console.log(' → 已调用系统默认程序打开');
}
return 'done';
}
// ==================== 主流程 ====================
const argv = process.argv.slice(2);
const FLAGS = {
jsonOnly: argv.includes('--json'),
open: argv.includes('--open'),
help: argv.includes('-h') || argv.includes('--help')
};
const positional = argv.filter((a) => !a.startsWith('-'));
(async function main() {
if (FLAGS.help) {
console.log(`
本地自测:HTML 审查报告 → Word
node local-test.js 用内置样例自检
node local-test.js report.html 转换指定文件(输出到同目录)
node local-test.js report.html out.docx 指定输出路径
node local-test.js someDir/ 批量转换目录下 .html / .txt
node local-test.js report.html --json 只看解析结果
node local-test.js report.html --open 生成后自动打开
`);
return;
}
let ok = 0;
let skip = 0;
let fail = 0;
const tally = (r) => {
if (r === 'done') ok++;
else if (r === 'skip') skip++;
else fail++;
};
try {
if (!positional.length) {
// 三个内置用例,覆盖「能转」和「不该转」两类情况
tally(await convert(
'用例1:正常 HTML 报告(应生成 Word)',
SAMPLE_HTML,
path.join(os.tmpdir(), 'jjx_本地自测_审查报告.docx')
));
tally(await convert(
'用例2:智能体只回「文件解析失败」道歉文本(应跳过)',
APOLOGY_TEXT,
path.join(os.tmpdir(), 'jjx_用例2_不应生成.docx')
));
tally(await convert(
'用例3:HTML 被截断、缺 </html>(应跳过,不产出残缺文档)',
SAMPLE_HTML.slice(0, 1800),
path.join(os.tmpdir(), 'jjx_用例3_不应生成.docx')
));
} else {
const target = positional[0];
const stat = fs.statSync(target);
if (stat.isDirectory()) {
const files = fs.readdirSync(target)
.filter((f) => /\.(html?|txt)$/i.test(f))
.map((f) => path.join(target, f));
if (!files.length) {
console.log('目录下没有 .html / .txt 文件:' + target);
return;
}
console.log(`批量转换:共 ${files.length} 个文件`);
for (const f of files) {
const out = path.join(target, path.basename(f).replace(/\.[^.]+$/, '') + '_审查报告.docx');
try {
tally(await convert(path.basename(f), fs.readFileSync(f, 'utf8'), out));
} catch (e) {
console.log(' ✗ 异常:' + e.message);
fail++;
}
}
} else {
const out = positional[1] || path.join(
path.dirname(target),
path.basename(target).replace(/\.[^.]+$/, '') + '_审查报告.docx'
);
tally(await convert(path.basename(target), fs.readFileSync(target, 'utf8'), out));
}
}
} catch (e) {
console.log('');
console.log('✗ 运行失败:' + e.message);
if (e.code === 'ENOENT') console.log(' 提示:文件路径不存在,注意用引号包裹含空格的路径');
if (e.code === 'MODULE_NOT_FOUND') console.log(' 提示:先执行 npm install 安装 docx 依赖');
process.exitCode = 1;
return;
}
console.log('');
console.log('════════════════════════════════════════');
console.log(`完成:生成 ${ok} 个,跳过 ${skip} 个,失败 ${fail} 个`);
if (fail > 0) console.log('提示:解析失败通常是 HTML 结构与预期不符,把该文件发我看看。');
console.log('════════════════════════════════════════');
})();
@@ -0,0 +1,22 @@
{
"name": "jjxReviewTimer",
"version": "1.0.0",
"description": "合同审查定时任务",
"main": "index.js",
"dependencies": {
"docx": "^8.5.0"
},
"cloudfunction-config": {
"memorySize": 256,
"timeout": 600,
"triggers": [
{
"name": "myTrigger",
"type": "timer",
"config": "0 */1 * * * * *"
}
],
"path": "",
"runtime": "Nodejs16"
}
}
@@ -0,0 +1,49 @@
/**
* 登录前修复 dcloud_appid 为空数组的用户
* uni-id 只放行「字段不存在」或「数组包含当前 appid」的账号,空数组会被当成用户不存在
*/
const LOGIN_APP_IDS = ["__UNI__EB80D26", "__UNI__8BFD66E", "__UNI__0F0703D"];
function normalizeAccount(value) {
if (typeof value === "number") return String(value).trim();
if (typeof value === "string") return value.trim();
return "";
}
module.exports = [{
id: "fixEmptyDcloudAppid",
regExp: [
"^user/pub/login(.*)"
],
description: "登录前把 dcloud_appid 为空数组的用户补上三个应用的 appid",
index: 201,
mode: "onActionExecuting",
enable: true,
main: async function(event) {
let { data = {}, util } = event;
let { db, _ } = util;
const username = normalizeAccount(data.username);
const mobile = normalizeAccount(data.mobile);
const email = normalizeAccount(data.email);
const whereList = [];
if (username) {
whereList.push({ username }, { mobile: username }, { email: username });
}
if (mobile && mobile !== username) whereList.push({ mobile });
if (email && email !== username) whereList.push({ email });
if (whereList.length === 0) return { code: 0 };
const whereJson = whereList.length === 1 ? whereList[0] : _.or(whereList);
const userRes = await db.collection("uni-id-users").where(whereJson).field({
dcloud_appid: true
}).limit(10).get();
const users = (userRes.data || []).filter(user => Array.isArray(user.dcloud_appid) && user.dcloud_appid.length === 0);
for (let i = 0; i < users.length; i++) {
await db.collection("uni-id-users").doc(users[i]._id).update({
dcloud_appid: LOGIN_APP_IDS
});
}
return { code: 0 };
}
}]
+23 -22
View File
@@ -35,63 +35,64 @@
},
"node_modules/charenc": {
"version": "0.0.2",
"license": "BSD-3-Clause",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
"engines": {
"node": "*"
}
},
"node_modules/crypt": {
"version": "0.0.2",
"license": "BSD-3-Clause",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
"engines": {
"node": "*"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"license": "MIT"
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained."
},
"node_modules/dayjs": {
"version": "1.11.10",
"license": "MIT"
},
"node_modules/debug": {
"version": "3.2.7",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="
},
"node_modules/is-buffer": {
"version": "1.1.6",
"license": "MIT"
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"node_modules/md5": {
"version": "2.3.0",
"license": "BSD-3-Clause",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"dependencies": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
},
"node_modules/node-ensure": {
"version": "0.0.0",
"license": "MIT"
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw=="
},
"node_modules/pdf-parse": {
"version": "1.1.1",
"license": "MIT",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz",
"integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==",
"dependencies": {
"debug": "^3.1.0",
"node-ensure": "^0.0.0"
},
"engines": {
"node": ">=6.8.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/uni-config-center": {
@@ -23,7 +23,7 @@
"concurrency": 1,
"memorySize": 512,
"path": "/http/router",
"timeout": 60,
"timeout": 600,
"triggers": [],
"keepRunningAfterReturn": false
},
@@ -8,17 +8,15 @@ module.exports = {
* @param {String} accNo 账号
* @param {String} reqDate 请求日期,格式:yyyyMMdd
* @param {String} reqJournalNo 请求流水号
* @param {String} logId 可选,流水ID,传入后会回写状态
*/
main: async (event) => {
let { data = {}, util } = event;
let { vk } = util;
let { accNo, reqDate, reqJournalNo, logId } = data;
let { accNo, reqDate, reqJournalNo } = data;
accNo = String(accNo || '').trim();
reqDate = String(reqDate || '').trim();
reqJournalNo = String(reqJournalNo || '').trim();
logId = String(logId || '').trim();
if (!accNo) {
return {
@@ -48,83 +46,43 @@ module.exports = {
};
}
let res = await balanceUtil.requestActionLog({
vk,
accNo,
reqDate,
reqJournalNo
let res = await vk.request({
url: 'http://jjx-union-pay.gxwebsoft.com/api/union-pay-jjx/getActionLog',
method: 'POST',
dataType: 'json',
header: {
'content-type': 'application/json; charset=utf-8'
},
data: {
accNo,
reqDate,
reqJournalNo
},
timeout: 60000
});
if (res && typeof res === 'object' && typeof res.code === 'undefined') {
res.code = 0;
}
const status = balanceUtil.getActionStatus(res);
const statusText = balanceUtil.getActionStatusTextByStatus(status);
if (status !== null) {
let logInfo = null;
if (logId) {
logInfo = await vk.baseDao.findByWhereJson({
dbName: 'jjx-balance-log',
whereJson: {
_id: logId
}
});
}
if (!logInfo) {
logInfo = await vk.baseDao.findByWhereJson({
dbName: 'jjx-balance-log',
whereJson: {
srcReqId: reqJournalNo,
merchantId: accNo
}
});
}
if (logInfo && logInfo._id) {
logId = logInfo._id;
const prevStatus = Number(logInfo.status);
const nextStatus = Number(status);
const updateData = {
status: nextStatus
};
// 新提现单(balanceDeducted === false):成功时再扣减余额缓存
// 旧提现单无该字段:创建时已扣减,这里只更新状态
const needDeductOnSuccess = logInfo.balanceDeducted === false
&& prevStatus === 1
&& nextStatus === 2;
if (needDeductOnSuccess) {
const amount = balanceUtil.normalizeAmount(logInfo.amount);
await balanceUtil.applyMerchantBalanceDelta({
vk,
merchantId: accNo,
delta: -amount,
lastLogId: logId
});
updateData.balanceDeducted = true;
}
await vk.baseDao.updateById({
dbName: 'jjx-balance-log',
id: logId,
dataJson: updateData
});
}
}
const responseObjects = balanceUtil.getRemoteResponseObjects(res);
const list = balanceUtil.pickFirstDefined([
Array.isArray(res.list) ? res.list : undefined,
Array.isArray(res.rows) ? res.rows : undefined,
Array.isArray(res.data) ? res.data : undefined,
Array.isArray(res.data && res.data.list) ? res.data.list : undefined,
Array.isArray(res.data && res.data.rows) ? res.data.rows : undefined,
Array.isArray(res.info && res.info.list) ? res.info.list : undefined,
Array.isArray(res.info && res.info.rows) ? res.info.rows : undefined,
Array.isArray(res.content && res.content.list) ? res.content.list : undefined,
Array.isArray(res.content && res.content.rows) ? res.content.rows : undefined
]) || [];
if (responseObjects.data && typeof responseObjects.data === 'object' && !Array.isArray(responseObjects.data)) {
responseObjects.data.status = status;
responseObjects.data.statusText = statusText;
responseObjects.data.logId = logId;
responseObjects.data.list = list;
} else if (res && typeof res === 'object') {
res.data = {
status,
statusText,
logId
list
};
}
@@ -1,5 +1,5 @@
'use strict';
const { BALANCE_DOC_ID, getBalanceInfoList, getAvailableWithdrawBalance, normalizeAmount } = require('./util.js');
const { BALANCE_DOC_ID, getBalanceInfoList } = require('./util.js');
module.exports = {
/**
@@ -25,24 +25,6 @@ module.exports = {
}
const balanceInfoList = getBalanceInfoList(info.balanceInfo || {});
const enrichedList = [];
for (let i = 0; i < balanceInfoList.length; i++) {
const item = balanceInfoList[i];
const {
pendingAmount,
availableBalance
} = await getAvailableWithdrawBalance({
vk,
merchantId: item.accNo,
merchantBalance: item.balance
});
enrichedList.push({
...item,
balance: normalizeAmount(item.balance),
pendingAmount,
availableBalance
});
}
return {
code: 0,
@@ -50,7 +32,7 @@ module.exports = {
data: {
balance: Number(Number(info.balance || 0).toFixed(2)),
updateTime: info.updateTime || info._update_time_str || info._add_time_str || '',
balanceInfoList: enrichedList
balanceInfoList
}
};
}
@@ -213,64 +213,6 @@ function getActionStatusTextByStatus(status) {
return statusMap[Number(status)] || '';
}
async function applyMerchantBalanceDelta({
vk,
transaction,
merchantId,
delta,
lastLogId = '',
updateTime = dayjs().format('YYYY-MM-DD HH:mm:ss')
}) {
const accNo = String(merchantId || '').trim();
if (!accNo) {
throw new Error('提现账号不能为空');
}
const changeAmount = normalizeAmount(delta);
const balanceRecord = await ensureBalanceRecord({ transaction, vk });
const balanceInfoMap = buildBalanceInfoMap(balanceRecord.balanceInfo || {});
const currentMerchantInfo = balanceInfoMap[accNo] || {
accNo,
merchantName: getMerchantLabel(accNo),
balance: 0,
updateTime: ''
};
const currentMerchantBalance = normalizeAmount(currentMerchantInfo.balance || 0);
const nextMerchantBalance = normalizeAmount(currentMerchantBalance + changeAmount);
if (nextMerchantBalance < 0) {
throw new Error('余额不足');
}
const nextBalanceInfoMap = {
...balanceInfoMap,
[accNo]: {
accNo,
merchantName: currentMerchantInfo.merchantName || getMerchantLabel(accNo),
balance: nextMerchantBalance,
updateTime
}
};
const nextTotalBalance = normalizeAmount(Number(balanceRecord.balance || 0) + changeAmount);
const dataJson = {
balance: nextTotalBalance,
balanceInfo: nextBalanceInfoMap,
updateTime
};
if (lastLogId) {
dataJson.lastLogId = lastLogId;
}
await vk.baseDao.updateById({
db: transaction,
dbName: 'jjx-balance',
id: balanceRecord._id,
dataJson
});
return {
balance: nextTotalBalance,
merchantBalance: nextMerchantBalance,
updateTime,
balanceInfoMap: nextBalanceInfoMap
};
}
async function syncMerchantBalance({
vk,
accNo,
@@ -381,49 +323,6 @@ async function checkBalanceAvailable({ transaction, vk, changeAmount, excludeLog
};
}
/**
* 统计指定商户处理中的提现金额(status=1)
*/
async function getPendingWithdrawAmount({ transaction, vk, merchantId }) {
const accNo = String(merchantId || '').trim();
if (!accNo) {
return 0;
}
const res = await vk.baseDao.select({
db: transaction,
dbName: 'jjx-balance-log',
whereJson: {
merchantId: accNo,
type: 'expense',
source: 'withdraw',
status: 1
},
pageIndex: 1,
pageSize: 1000,
getCount: false
});
const rows = (res && res.rows) || [];
return normalizeAmount(rows.reduce((sum, item) => sum + Number(item.amount || 0), 0));
}
/**
* 可提现余额 = 当前余额 - 处理中提现金额
*/
async function getAvailableWithdrawBalance({ transaction, vk, merchantId, merchantBalance }) {
const pendingAmount = await getPendingWithdrawAmount({
transaction,
vk,
merchantId
});
const balance = normalizeAmount(merchantBalance);
const availableBalance = normalizeAmount(Math.max(0, balance - pendingAmount));
return {
balance,
pendingAmount,
availableBalance
};
}
async function updateBalanceWithDelta({ transaction, vk, delta, lastLogId = '' }) {
const balanceInfo = await ensureBalanceRecord({ transaction, vk });
const nextBalance = Number((Number(balanceInfo.balance || 0) + Number(delta || 0)).toFixed(2));
@@ -473,13 +372,10 @@ module.exports = {
parseOrderSet,
getActionStatus,
getActionStatusTextByStatus,
applyMerchantBalanceDelta,
syncMerchantBalance,
ensureBalanceRecord,
recalculateBalance,
checkBalanceAvailable,
getPendingWithdrawAmount,
getAvailableWithdrawBalance,
updateBalanceWithDelta,
rollbackTransaction
};
@@ -1,4 +1,5 @@
'use strict';
const dayjs = require('dayjs');
const balanceUtil = require('./util.js');
const merchantMap = balanceUtil.MERCHANT_MAP;
@@ -33,28 +34,11 @@ module.exports = {
updateTime: ''
};
const merchantBalance = balanceUtil.normalizeAmount(merchantBalanceInfo.balance || 0);
const {
pendingAmount,
availableBalance
} = await balanceUtil.getAvailableWithdrawBalance({
transaction,
vk,
merchantId,
merchantBalance
});
if (availableBalance <= 0) {
throw new Error(
pendingAmount > 0
? `可提现余额不足(处理中提现¥${pendingAmount.toFixed(2)}已占用)`
: '当前企业余额小于等于0,无法提现'
);
if (merchantBalance <= 0) {
throw new Error('当前企业余额小于等于0,无法提现');
}
if (amount > availableBalance) {
throw new Error(
pendingAmount > 0
? `提现金额不能大于可提现余额¥${availableBalance.toFixed(2)}(已排除处理中提现¥${pendingAmount.toFixed(2)})`
: '提现金额不能大于当前企业余额'
);
if (amount > merchantBalance) {
throw new Error('提现金额不能大于当前企业余额');
}
const description = String(data.description || '').trim() || `余额提现-${merchantMap[merchantId]}`;
@@ -96,28 +80,52 @@ module.exports = {
merchantId,
merchantName: merchantMap[merchantId],
srcReqId: requestRes.srcReqId,
status: 1,
// 新逻辑:处理中不扣余额,成功后再扣;用于兼容旧数据(旧单默认已扣)
balanceDeducted: false
status: 1
};
const changeAmount = balanceUtil.buildBalanceChange(logData);
await vk.baseDao.add({
await balanceUtil.checkBalanceAvailable({
transaction,
vk,
changeAmount
});
const addRes = await vk.baseDao.add({
db: transaction,
dbName: 'jjx-balance-log',
dataJson: logData
});
// 处理中提现不立刻扣减余额缓存,避免与远端余额同步互相覆盖;
// 可提现额度通过「余额 - 处理中提现」计算,防止重复提现超总额。
const updateTime = dayjs().format('YYYY-MM-DD HH:mm:ss');
const nextMerchantBalance = Number((merchantBalance + changeAmount).toFixed(2));
const nextBalance = Number((Number(balanceRecord.balance || 0) + changeAmount).toFixed(2));
await vk.baseDao.updateById({
db: transaction,
dbName: 'jjx-balance',
id: balanceRecord._id,
dataJson: {
balance: nextBalance,
lastLogId: addRes.id,
updateTime,
balanceInfo: {
...balanceInfoMap,
[merchantId]: {
accNo: merchantId,
merchantName: merchantMap[merchantId],
balance: nextMerchantBalance,
updateTime
}
}
}
});
await transaction.commit();
return {
code: 0,
msg: '',
data: {
balance: merchantBalance,
pendingAmount: balanceUtil.normalizeAmount(pendingAmount + amount),
availableBalance: balanceUtil.normalizeAmount(Math.max(0, availableBalance - amount)),
merchantBalance
balance: nextBalance,
merchantBalance: nextMerchantBalance
}
};
} catch (err) {
@@ -0,0 +1,38 @@
'use strict';
module.exports = {
/**
* 积分流水列表(所有用户)
* @url admin/points/sys/getList 前端调用的url参数地址
* data 请求参数说明
* @param {Number} pageIndex 当前页码
* @param {Number} pageSize 每页显示数量
* @param {Object} formData 查询条件数据源
* @param {Array} columns 查询条件规则
*/
main: async (event) => {
let { data = {}, util } = event;
let { vk, _ } = util;
let res = { code: 0, msg: '' };
// 业务逻辑开始-----------------------------------------------------------
res = await vk.baseDao.getTableData({
dbName: "jjx-points-log",
data,
whereJson: {},
sortArr: [{ name: "_add_time", type: "desc" }],
foreignDB: [{
dbName: "uni-id-users",
localKey: "user_id",
foreignKey: "_id",
as: "userInfo",
limit: 1,
fieldJson: {
nickname: true,
username: true,
mobile: true
}
}]
});
// 业务逻辑结束-----------------------------------------------------------
return res;
}
};
@@ -95,14 +95,14 @@ module.exports = {
const num = Number(value);
return Number.isNaN(num) ? defaultValue : num;
};
if (data.formData && data.formData.isSignOnly && res.rows && res.rows.length > 0) {
if (res.rows && res.rows.length > 0) {
const userIds = res.rows.map(item => item._id).filter(Boolean);
const qybStatMap = {};
userIds.forEach(userId => {
qybStatMap[userId] = {
qybBuyNum: 0,
qybUsedNum: 0,
qybGiftNum: 0,
qybPackageGiftNum: 0,
qybTotalGetNum: 0
};
});
@@ -174,14 +174,18 @@ module.exports = {
}
Object.keys(qybStatMap).forEach(userId => {
qybStatMap[userId].qybGiftNum = Math.max(qybStatMap[userId].qybTotalGetNum - qybStatMap[userId].qybBuyNum, 0);
qybStatMap[userId].qybPackageGiftNum = Math.max(qybStatMap[userId].qybTotalGetNum - qybStatMap[userId].qybBuyNum, 0);
});
res.rows.forEach(item => {
const qybStat = qybStatMap[item._id] || {};
const adminGiftNum = toNum(item.qybTotalGiftNum, 0);
// 用户表上的 qybGiftNum 为后台赠送累加值;套餐优惠政策赠送从订单统计
const storedGiftNum = toNum(item.qybGiftNum, 0);
item.qybBuyNum = qybStat.qybBuyNum || 0;
item.qybUsedNum = qybStat.qybUsedNum || 0;
item.qybNum = toNum(item.qybNum, 0);
item.qybGiftNum = qybStat.qybGiftNum || 0;
item.qybGiftNum = (qybStat.qybPackageGiftNum || 0) + storedGiftNum;
item.qybTotalGiftNum = adminGiftNum || storedGiftNum;
});
}
}
@@ -0,0 +1,46 @@
'use strict';
module.exports = {
/**
* 后台赠送签约宝次数
* @url admin/system/user/sys/giftQybNum
* @param {String} user_id 用户ID
* @param {Number} giftNum 赠送次数
*/
main: async (event) => {
let { data = {}, util } = event;
let { vk, _ } = util;
let res = { code: 0, msg: '' };
const { user_id, giftNum } = data;
const num = Number(giftNum);
if (!user_id) {
return { code: -1, msg: '用户ID不能为空' };
}
if (!Number.isInteger(num) || num <= 0) {
return { code: -1, msg: '赠送次数必须为正整数' };
}
const user = await vk.baseDao.findById({
dbName: 'uni-id-users',
id: user_id
});
if (!user) {
return { code: -1, msg: '用户不存在' };
}
// 剩余签约次数、优惠政策赠送次数、累计赠送次数同步累加
await vk.baseDao.updateById({
dbName: 'uni-id-users',
id: user_id,
dataJson: {
qybNum: _.inc(num),
qybGiftNum: _.inc(num),
qybTotalGiftNum: _.inc(num)
}
});
res.msg = '赠送成功';
return res;
}
};
@@ -0,0 +1,922 @@
'use strict';
/**
* 合同审查报告 → Word(.docx) 生成器
*
* 数据来源:智能体在 HTML 报告末尾输出的结构化 JSON
* <script type="application/json" id="jjx-report-data">{...}</script>
*
* 排版目标:正式法律文书风格,A4 纵向,可直接打印装订
*/
const {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
WidthType,
AlignmentType,
BorderStyle,
ShadingType,
TableLayoutType,
Footer,
Header,
PageNumber,
VerticalAlign,
ExternalHyperlink
} = require('docx');
const FONT = 'Microsoft YaHei';
const FONT_SONG = 'SimSun';
const C = {
primary: '1A5276',
danger: 'E74C3C',
success: '27AE60',
warning: 'F39C12',
text: '222222',
gray: '777777',
lightBg: 'F5F7F9',
pinkBg: 'FDF2F2',
border: 'D0D7DE'
};
const PAGE_BORDER = {
top: { style: BorderStyle.SINGLE, size: 4, color: C.border },
bottom: { style: BorderStyle.SINGLE, size: 4, color: C.border },
left: { style: BorderStyle.SINGLE, size: 4, color: C.border },
right: { style: BorderStyle.SINGLE, size: 4, color: C.border },
insideHorizontal: { style: BorderStyle.SINGLE, size: 4, color: C.border },
insideVertical: { style: BorderStyle.SINGLE, size: 4, color: C.border }
};
// ---------- 基础元件 ----------
function run(text, o) {
o = o || {};
const opt = {
text: String(text === null || text === undefined ? '' : text),
font: o.font || FONT,
size: o.size || 21,
bold: !!o.bold,
italics: !!o.italics,
color: o.color || C.text
};
if (o.underline) opt.underline = {};
return new TextRun(opt);
}
function para(children, o) {
o = o || {};
const opt = {
children: Array.isArray(children) ? children : [children],
spacing: {
before: o.before === undefined ? 60 : o.before,
after: o.after === undefined ? 60 : o.after,
line: o.line || 320
}
};
if (o.alignment) opt.alignment = o.alignment;
if (o.indent) opt.indent = o.indent;
if (o.border) opt.border = o.border;
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
return new Paragraph(opt);
}
function cell(children, o) {
o = o || {};
const opt = {
children: Array.isArray(children) ? children : [children],
verticalAlign: VerticalAlign.CENTER,
margins: { top: 60, bottom: 60, left: 110, right: 110 }
};
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
if (o.width) opt.width = { size: o.width, type: WidthType.PERCENTAGE };
if (o.columnSpan) opt.columnSpan = o.columnSpan;
return new TableCell(opt);
}
function table(rows, widths) {
return new Table({
rows: rows,
width: { size: 100, type: WidthType.PERCENTAGE },
columnWidths: widths,
layout: TableLayoutType.AUTOFIT,
borders: PAGE_BORDER
});
}
function sectionTitle(no, text) {
return para([run(no + '、' + text, { size: 28, bold: true, color: C.primary })], {
before: 320,
after: 140,
border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: C.primary, space: 4 } }
});
}
function pick(obj, keys, dft) {
if (!obj) return dft;
for (let i = 0; i < keys.length; i++) {
const v = obj[keys[i]];
if (v !== undefined && v !== null && v !== '') return v;
}
return dft;
}
function asArray(v) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
// ---------- 风险条目 ----------
function riskBlock(item, color, levelText) {
const clause = pick(item, ['clause', 'no', '条款', 'clauseNo'], '');
const issue = pick(item, ['issue', 'risk', 'problem', '描述', '问题'], '');
const suggest = pick(item, ['suggestion', 'fix', 'advice', '建议', '修改建议'], '');
const basis = pick(item, ['legal_basis', 'legalBasis', 'basis', '法律依据'], '');
const blocks = [];
blocks.push(para([
run('[' + levelText + ']', { bold: true, color: color, size: 21 }),
run(clause ? ' 第 ' + clause + ' 条' : ' 合同条款', { bold: true, color: color, size: 21 })
], { before: 140, after: 40 }));
blocks.push(para([
run('问题:', { bold: true, size: 21 }),
run(issue, { size: 21 })
], { after: 40, indent: { left: 240 } }));
if (suggest) {
blocks.push(para([
run('建议:', { bold: true, size: 21 }),
run(suggest, { size: 21 })
], { after: 40, indent: { left: 240 } }));
}
if (basis) {
blocks.push(para([
run('依据:', { bold: true, size: 21 }),
run(basis, { size: 21, color: C.gray })
], { after: 80, indent: { left: 240 } }));
}
return blocks;
}
function riskSection(items, color, levelText, emptyText) {
const list = asArray(items);
if (!list.length) {
return [para([run(emptyText || '无', { size: 21, color: C.gray })], { after: 80 })];
}
let out = [];
list.forEach(function (it) {
out = out.concat(riskBlock(it, color, levelText));
});
return out;
}
// ---------- 主函数 ----------
async function buildDocx(input) {
const data = input.data || {};
const fileName = input.fileName || '合同审查报告';
const contractName = pick(data, ['contract_name', 'contractName', '协议名称', 'name'], fileName.replace(/\.[^.]+$/, ''));
const reportTitle = pick(data, ['report_title', 'reportTitle', '报告标题'], '合同审查报告');
const reviewDate = pick(data, ['review_date', 'reviewDate', '审核日期'], new Date().toLocaleDateString('zh-CN'));
const summary = pick(data, ['summary', '统计'], {}) || {};
const conclusion = pick(data, ['conclusion', '审核结论', 'summary_text'], '');
const highRisks = pick(data, ['high_risks', 'highRisks', '高风险'], []);
const mediumRisks = pick(data, ['medium_risks', 'mediumRisks', '中风险'], []);
const lowRisks = pick(data, ['low_risks', 'lowRisks', '低风险'], []);
const suggestions = pick(data, ['suggestions', '优化建议', 'advice_table'], []);
const templates = pick(data, ['clause_templates', 'clauseTemplates', '补充条款'], []);
const legalBases = pick(data, ['legal_bases', 'legalBases', '法律依据'], []);
const cases = pick(data, ['cases', '案例'], []);
const disclaimer = pick(data, ['disclaimer', '免责声明'], '');
const nCompliant = pick(summary, ['compliant', '合规'], 0);
const nPartial = pick(summary, ['partial', '部分合规'], 0);
const nHigh = pick(summary, ['high', '高风险'], asArray(highRisks).length);
const nMedium = pick(summary, ['medium', '中风险'], asArray(mediumRisks).length);
const children = [];
// ===== 抬头 =====
children.push(para([run(reportTitle, { size: 44, bold: true, color: C.primary })], {
alignment: AlignmentType.CENTER,
before: 200,
after: 120
}));
children.push(para([run(contractName, { size: 26, color: C.gray })], {
alignment: AlignmentType.CENTER,
after: 60
}));
children.push(para([run('审核日期:' + reviewDate + ' 出具机构:金久信签约宝', { size: 20, color: C.gray })], {
alignment: AlignmentType.CENTER,
after: 240
}));
// ===== 目录 =====
children.push(sectionTitle('一', '报告目录'));
const tocItems = [
'二、审核结论',
'三、需处理问题(高风险 / 中风险)',
'四、优化建议汇总表',
'五、补充条款模板',
'六、法律依据',
'七、相关案例',
'八、免责声明'
];
tocItems.forEach(function (item) {
children.push(para([run(item, { size: 21 })], { after: 30, indent: { left: 240 } }));
});
// ===== 审核结论 =====
children.push(sectionTitle('二', '审核结论'));
const statRow = new TableRow({
children: [
cell([para([run(String(nCompliant), { size: 40, bold: true, color: C.success })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('合规条款', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'F0F9EB', width: 25 }),
cell([para([run(String(nPartial), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('部分合规', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FDF6EC', width: 25 }),
cell([para([run(String(nHigh), { size: 40, bold: true, color: C.danger })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('高风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FEF0F0', width: 25 }),
cell([para([run(String(nMedium), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('中风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FDF6EC', width: 25 })
]
});
children.push(table([statRow], [2000, 2000, 2000, 2000]));
if (conclusion) {
children.push(para([run('审核结论', { size: 21, bold: true, color: C.primary })], { before: 200, after: 80 }));
String(conclusion).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 21 })], { after: 60, indent: { left: 240 } }));
}
});
}
// ===== 需处理问题 =====
children.push(sectionTitle('三', '需处理问题'));
children.push(para([run('(一)高风险问题', { size: 24, bold: true, color: C.danger })], { before: 140, after: 80 }));
riskSection(highRisks, C.danger, '高风险', '本次审查未发现高风险问题。').forEach(function (p) { children.push(p); });
children.push(para([run('(二)中风险问题', { size: 24, bold: true, color: C.warning })], { before: 200, after: 80 }));
riskSection(mediumRisks, C.warning, '中风险', '本次审查未发现中风险问题。').forEach(function (p) { children.push(p); });
if (asArray(lowRisks).length) {
children.push(para([run('(三)低风险问题', { size: 24, bold: true, color: C.gray })], { before: 200, after: 80 }));
riskSection(lowRisks, C.gray, '低风险', '无').forEach(function (p) { children.push(p); });
}
// ===== 优化建议汇总表 =====
children.push(sectionTitle('四', '优化建议汇总表'));
const sugList = asArray(suggestions);
if (!sugList.length) {
children.push(para([run('无', { size: 21, color: C.gray })]));
} else {
const headRow = new TableRow({
tableHeader: true,
children: [
cell([para([run('优先级', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 12 }),
cell([para([run('条款编号', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 15 }),
cell([para([run('问题描述', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 38 }),
cell([para([run('建议方案', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 35 })
]
});
const rows = [headRow];
sugList.forEach(function (s, i) {
const lv = String(pick(s, ['priority', 'level', '优先级'], 'M')).toUpperCase();
const lvBg = lv === 'H' ? C.danger : (lv === 'L' ? '999999' : C.warning);
rows.push(new TableRow({
children: [
cell([para([run(lv, { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: lvBg, width: 12 }),
cell([para([run(pick(s, ['clause', 'no', '条款编号'], '-'), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 15 }),
cell([para([run(pick(s, ['issue', 'problem', '问题描述', '描述'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 38 }),
cell([para([run(pick(s, ['solution', 'suggestion', '建议方案', '建议'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 35 })
]
}));
});
children.push(table(rows, [1200, 1500, 3800, 3500]));
}
// ===== 补充条款模板 =====
children.push(sectionTitle('五', '补充条款模板'));
const tplList = asArray(templates);
if (!tplList.length) {
children.push(para([run('无', { size: 21, color: C.gray })]));
} else {
tplList.forEach(function (t, i) {
const title = pick(t, ['title', 'name', '条款名称'], '补充条款 ' + (i + 1));
const content = pick(t, ['content', 'text', '条文', '内容'], '');
children.push(para([run(String(i + 1) + '. ' + title, { size: 21, bold: true, color: C.primary })], { before: 140, after: 60 }));
String(content).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 20, font: FONT_SONG })], {
after: 50,
indent: { left: 240 },
shading: C.lightBg,
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.primary, space: 6 } }
}));
}
});
});
}
// ===== 法律依据 =====
children.push(sectionTitle('六', '法律依据'));
const lawList = asArray(legalBases);
if (!lawList.length) {
children.push(para([run('本报告未引用具体法条,相关意见为实务建议,具体法律适用需律师人工核实。', { size: 20, color: C.gray })]));
} else {
lawList.forEach(function (l, i) {
const law = pick(l, ['law', 'name', '法律名称'], '');
const article = pick(l, ['article', 'no', '条款编号'], '');
const content = pick(l, ['content', 'text', '引用内容', '内容'], '');
children.push(para([
run(String(i + 1) + '. ', { size: 20, bold: true }),
run(law + (article ? ' ' + article : ''), { size: 20, bold: true, color: C.primary }),
run(content ? ':' + content : '', { size: 20 })
], { after: 60, indent: { left: 120 } }));
});
}
// ===== 相关案例 =====
children.push(sectionTitle('七', '相关案例'));
const caseList = asArray(cases);
if (!caseList.length) {
children.push(para([run('暂未检索到公开案例,建议人工核实。', { size: 20, color: C.gray })]));
} else {
const caseHead = new TableRow({
tableHeader: true,
children: [
cell([para([run('序号', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
cell([para([run('案件名称·案由', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 26 }),
cell([para([run('法院·机构', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 14 }),
cell([para([run('年份', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
cell([para([run('判决要点', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 28 }),
cell([para([run('与风险点关联', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 16 })
]
});
const caseRows = [caseHead];
caseList.forEach(function (c, i) {
const name = pick(c, ['name', 'title', '案件名称'], '-');
const url = pick(c, ['url', 'link', '链接'], '');
const court = pick(c, ['court', 'org', '法院'], '-');
const year = pick(c, ['year', '年份'], '-');
const result = pick(c, ['result', 'point', '判决要点', '要点'], '-');
const relation = pick(c, ['relation', '关联', '与本次风险点关联'], '-');
const nameCellChildren = [para([run(name, { size: 19, bold: true })], { after: url ? 20 : 0 })];
if (url) {
const host = url.replace(/^https?:\/\//i, '').split('/')[0];
nameCellChildren.push(para([
new ExternalHyperlink({
link: url,
children: [run('来源:' + host + ' ↗', { size: 16, color: '2E74B5', underline: true })]
})
], {}));
}
caseRows.push(new TableRow({
children: [
cell([para([run(String(i + 1), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
cell(nameCellChildren, { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 26 }),
cell([para([run(court, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 14 }),
cell([para([run(year, { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
cell([para([run(result, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 28 }),
cell([para([run(relation, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 16 })
]
}));
});
children.push(table(caseRows, [700, 2300, 1300, 700, 2500, 1500]));
}
// ===== 免责声明 =====
children.push(sectionTitle('八', '免责声明'));
const disText = disclaimer || '本报告由人工智能根据用户上传的合同文本自动生成,仅供内部风险排查参考,不构成正式法律意见,亦不能替代执业律师的专业审查。涉及重大交易、争议解决或诉讼事项,请务必委托执业律师复核。报告中的法律法规引用以官方公布文本为准,案例信息来源于公开检索结果,具体适用请人工核实。';
String(disText).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 19, color: C.gray })], {
after: 50,
shading: C.pinkBg,
indent: { left: 120, right: 120 },
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.danger, space: 6 } }
}));
}
});
// ===== 文档 =====
const doc = new Document({
creator: '金久信签约宝',
title: contractName + ' - 合同审查报告',
description: 'AI 合同审查报告',
styles: {
default: {
document: {
run: { font: FONT, size: 21, color: C.text }
}
}
},
sections: [{
properties: {
page: {
margin: { top: 1134, right: 1134, bottom: 1134, left: 1134 }
}
},
headers: {
default: new Header({
children: [
para([
run('金久信签约宝 · AI 合同审查报告', { size: 18, color: C.gray }),
run(' ' + contractName, { size: 18, color: C.gray })
], { alignment: AlignmentType.RIGHT, after: 0 })
]
})
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: '第 ', font: FONT, size: 18, color: C.gray }),
new TextRun({ children: [PageNumber.CURRENT], font: FONT, size: 18, color: C.gray }),
new TextRun({ text: ' 页 / 共 ', font: FONT, size: 18, color: C.gray }),
new TextRun({ children: [PageNumber.TOTAL_PAGES], font: FONT, size: 18, color: C.gray }),
new TextRun({ text: ' 页', font: FONT, size: 18, color: C.gray })
]
})
]
})
},
children: children
}]
});
return await Packer.toBuffer(doc);
}
// ---------- HTML 解析(生产数据兜底通道) ----------
// 智能体实际返回的是纯 HTML 报告,未必带 jjx-report-data 数据块,
// 这里按区块结构把 HTML 还原成结构化数据,无需修改智能体提示词。
function decodeEntities(s) {
return String(s || '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&#(\d+);/g, function (m, d) { return String.fromCharCode(Number(d)); })
.replace(/&#x([0-9a-fA-F]+);/g, function (m, h) { return String.fromCharCode(parseInt(h, 16)); });
}
function stripTags(html) {
return decodeEntities(
String(html || '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h\d|section)>/gi, '\n')
.replace(/<li[^>]*>/gi, '\n')
.replace(/<[^>]+>/g, '')
)
.replace(/[ \t\u00a0]+/g, ' ')
.replace(/\n[ ]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function pickDiv(html, cls) {
const re = new RegExp('<div[^>]*class="[^"]*\\b' + cls + '\\b[^"]*"[^>]*>([\\s\\S]*?)<\\/div>', 'i');
const m = String(html || '').match(re);
return m ? stripTags(m[1]) : '';
}
// 按 <h2> 把报告切成区块,返回 { 区块名: 区块内HTML }
// 兼容:智能体偶尔用 <h3> 代替 <h2> 做区块标题,h2 不足 2 个时降级按 h3 切
function splitByH2(html) {
const map = {};
const h2Count = (html.match(/<h2[^>]*>/gi) || []).length;
const re = h2Count >= 2
? /<h2[^>]*>([\s\S]*?)<\/h2>/gi
: /<h3[^>]*>([\s\S]*?)<\/h3>/gi;
const marks = [];
let m;
while ((m = re.exec(html))) {
marks.push({ name: stripTags(m[1]).replace(/\s+/g, ''), start: m.index, contentStart: re.lastIndex });
}
for (let i = 0; i < marks.length; i++) {
const end = i + 1 < marks.length ? marks[i + 1].start : html.length;
map[marks[i].name] = html.slice(marks[i].contentStart, end);
}
return map;
}
function findSection(map, keywords) {
const keys = Object.keys(map);
for (let i = 0; i < keys.length; i++) {
for (let j = 0; j < keywords.length; j++) {
if (keys[i].indexOf(keywords[j]) >= 0) return map[keys[i]];
}
}
return '';
}
function parseTable(tableHtml) {
const rows = [];
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let m;
while ((m = trRe.exec(tableHtml))) {
const cells = [];
const tdRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
let c;
while ((c = tdRe.exec(m[1]))) cells.push(stripTags(c[1]));
if (cells.length) rows.push(cells);
}
return rows;
}
function extractTables(html) {
const tables = [];
const re = /<table[^>]*>([\s\S]*?)<\/table>/gi;
let m;
while ((m = re.exec(html))) tables.push(parseTable(m[1]));
return tables;
}
// 案例表:需要在行内同时取出单元格文本和 a[href](按行取,避免跨行错位)
function parseRowsWithLinks(tableHtml) {
const rows = [];
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let m;
while ((m = trRe.exec(tableHtml))) {
const tr = m[1];
const cells = [];
let url = '';
const tdRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
let c;
while ((c = tdRe.exec(tr))) {
cells.push(stripTags(c[1]));
if (!url) {
const am = c[1].match(/<a[^>]*href="([^"]+)"/i);
if (am) url = am[1];
}
}
if (cells.length) rows.push({ cells: cells, url: url });
}
return rows;
}
function shortClause(text) {
const t = String(text || '').trim();
if (!t) return '-';
if (/^[0-9.]+$/.test(t)) return t;
return t.length > 16 ? t.slice(0, 16) + '…' : t;
}
function levelOf(text, tagCls) {
if (/高/.test(text)) return 'high';
if (/中/.test(text)) return 'medium';
if (/低/.test(text)) return 'low';
if (tagCls) return tagCls.toLowerCase();
return '';
}
function collectRiskItems(body, level, out) {
const chunks = String(body || '').split(/<div[^>]*class="[^"]*\brisk-item\b[^"]*"[^>]*>/i).slice(1);
chunks.forEach(function (chunk) {
// 级别标签两种写法:<span class="risk-tag high"> 或 <span class="tag h">
const tagMatch = chunk.match(/<span[^>]*class="[^"]*risk-tag\s+(high|medium|low)[^"]*"[^>]*>/i)
|| chunk.match(/<span[^>]*class="[^"]*tag\s+(high|medium|low|h|m|l)\b[^"]*"[^>]*>/i);
const tagRaw = tagMatch ? tagMatch[1].toLowerCase() : '';
const tagLv = tagRaw === 'h' ? 'high' : tagRaw === 'm' ? 'medium' : tagRaw === 'l' ? 'low' : tagRaw;
// 优先级:所在区块标题 > 条目自身标签 > 默认 medium
const lv = level || tagLv || 'medium';
// 字段名两种写法:risk-clause/risk-desc/risk-fix 或 clause/desc/fix
let clauseText = pickDiv(chunk, 'risk-clause') || pickDiv(chunk, 'clause');
clauseText = clauseText.replace(/^[HML]\s*/, '').trim();
const clauseNum = clauseText.match(/条款\s*([0-9.]+)/);
const clause = shortClause(clauseNum ? clauseNum[1] : clauseText);
const issue = pickDiv(chunk, 'risk-desc') || pickDiv(chunk, 'desc');
let fix = (pickDiv(chunk, 'risk-fix') || pickDiv(chunk, 'fix')).replace(/^\s*(修复建议|建议)[::]\s*/, '');
if (!clauseText && !issue) return;
const item = {
level: lv,
clause: clause || '-',
issue: issue || clauseText || '-',
suggestion: fix || ''
};
// 条款标题里的补充信息(如“交付清单、全部附件空白”)拼进问题描述,避免丢信息
if (clauseNum && clauseText) {
const rest = clauseText.replace(/条款\s*[0-9.]+\s*/, '').trim();
if (rest && item.issue.indexOf(rest) < 0) item.issue = rest + ':' + item.issue;
}
if (!out[lv]) out[lv] = [];
out[lv].push(item);
});
}
function parseRisks(sectionHtml, hint) {
const out = { high: [], medium: [], low: [] };
const parts = String(sectionHtml || '').split(/<h3[^>]*>/i);
if (parts.length > 1) {
parts.slice(1).forEach(function (part) {
const headEnd = part.search(/<\/h3>/i);
const headText = headEnd >= 0 ? stripTags(part.slice(0, headEnd)) : '';
const body = headEnd >= 0 ? part.slice(headEnd + 5) : part;
const lv = levelOf(headText, '') || hint || '';
collectRiskItems(body, lv, out);
});
} else {
collectRiskItems(sectionHtml, hint || '', out);
}
return out;
}
/**
* 从智能体生成的真实 HTML 报告里还原结构化数据
*/
function parseHtmlReport(html) {
if (!html || typeof html !== 'string') return null;
if (html.indexOf('<') < 0) return null;
const raw = html
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '');
const data = {};
// 标题 / 合同名 / 日期
const h1 = raw.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
const reportTitle = h1 ? stripTags(h1[1]) : '';
const nameM = raw.match(/合同名称[::]\s*([^<\n]+)/);
const dateM = raw.match(/审核日期[::]\s*([^<\n]+)/);
const titleM = raw.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
data.contract_name = nameM ? nameM[1].trim()
: (reportTitle || (titleM ? stripTags(titleM[1]) : '')).replace(/审查报告$/, '').trim();
data.report_title = reportTitle || '';
data.review_date = dateM ? dateM[1].trim() : '';
// 统计卡片:按 num + label 配对抓取
const summary = {};
function applyCard(numRaw, labelRaw) {
const num = stripTags(numRaw).trim();
const label = stripTags(labelRaw).replace(/\s+/g, '');
if (!label) return;
const val = /^[0-9]+$/.test(num) ? Number(num) : num;
if (label.indexOf('部分') >= 0) summary.partial = val;
else if (label.indexOf('高') >= 0) summary.high = val;
else if (label.indexOf('中') >= 0) summary.medium = val;
else if (label.indexOf('低') >= 0) summary.low = val;
else if (label.indexOf('合规') >= 0) summary.compliant = val;
}
// 写法一:label 有 class="label"
const cardRe = /<div[^>]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<div[^>]*class="[^"]*\blabel\b[^"]*"[^>]*>([\s\S]*?)<\/div>/gi;
let cm;
while ((cm = cardRe.exec(raw))) applyCard(cm[1], cm[2]);
// 写法二:label 是紧跟的无 class <div>
if (!Object.keys(summary).length) {
const cardRe2 = /<div[^>]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<div[^>]*>([\s\S]*?)<\/div>/gi;
while ((cm = cardRe2.exec(raw))) applyCard(cm[1], cm[2]);
}
data.summary = summary;
const sections = splitByH2(raw);
// 审核结论
const conclusionHtml = findSection(sections, ['审核结论']);
data.conclusion = stripTags(conclusionHtml).replace(/\n{2,}/g, '\n');
// 需处理问题:兼容两种结构
// ① 单一「需处理问题」总区块(内含高风险/中风险小节)
// ② 「高风险问题」「中风险问题」各自独立成区块
const riskKeywords = ['需处理问题', '风险问题', '问题清单', '高风险', '中风险', '低风险'];
const risks = { high: [], medium: [], low: [] };
Object.keys(sections).forEach(function (key) {
const hit = riskKeywords.some(function (k) { return key.indexOf(k) >= 0; });
if (!hit) return;
const part = parseRisks(sections[key], levelOf(key, ''));
['high', 'medium', 'low'].forEach(function (lv) {
risks[lv] = risks[lv].concat(part[lv] || []);
});
});
data.high_risks = risks.high;
data.medium_risks = risks.medium;
data.low_risks = risks.low;
// 优化建议汇总表
const sugHtml = findSection(sections, ['优化建议', '建议汇总']);
const sugTables = extractTables(sugHtml);
const suggestions = [];
if (sugTables.length) {
sugTables[0].forEach(function (row, i) {
if (i === 0) return;
if (/优先级|条款编号/.test(row.join(''))) return;
suggestions.push({
priority: (row[0] || '').replace(/\s+/g, '').slice(0, 1).toUpperCase() || 'M',
clause: row[1] || '-',
issue: row[2] || '-',
solution: row[3] || '-'
});
});
}
data.suggestions = suggestions;
// 补充条款模板(部分报告没有该区块)
// 三种写法:<pre> 代码块 / <div class="clause-template"> 模板块 / 整段纯文本
const tplHtml = findSection(sections, ['补充条款']);
const templates = [];
if (tplHtml) {
const pres = tplHtml.match(/<pre[^>]*>([\s\S]*?)<\/pre>/gi) || [];
pres.forEach(function (p, i) {
templates.push({ title: '补充条款 ' + (i + 1), content: stripTags(p) });
});
if (!templates.length) {
// 模板块:class="clause-template",标题取内部 <strong>【...】</strong>
const tplDivs = tplHtml.match(/<div[^>]*class="[^"]*\bclause-template\b[^"]*"[^>]*>([\s\S]*?)<\/div>/gi) || [];
tplDivs.forEach(function (d, i) {
const text = stripTags(d).trim();
if (!text) return;
const tm = d.match(/<strong[^>]*>([\s\S]*?)<\/strong>/i);
const title = tm ? stripTags(tm[1]).replace(/^【|】$/g, '').trim() : '';
templates.push({ title: title || '补充条款 ' + (i + 1), content: text.replace(/^(【[^】]*】)/, '').trim() });
});
}
if (!templates.length) {
const text = stripTags(tplHtml);
if (text) templates.push({ title: '补充条款', content: text });
}
}
data.clause_templates = templates;
// 法律依据
const lawHtml = findSection(sections, ['法律依据', '法律法规']);
const legalBases = [];
const liRe = /<li[^>]*>([\s\S]*?)<\/li>/gi;
let lm;
while ((lm = liRe.exec(lawHtml))) {
const text = stripTags(lm[1]);
if (!text) continue;
const seg = text.match(/^(.+?第[0-9]+条[之]*[一二三四五六七八九十]*)[::]\s*([\s\S]*)$/);
if (seg) legalBases.push({ law: seg[1], article: '', content: seg[2] });
else legalBases.push({ law: '', article: '', content: text });
}
if (!legalBases.length) {
const text = stripTags(lawHtml);
if (text) {
text.split(/\n+/).forEach(function (line) {
line = line.trim();
if (!line) return;
const seg = line.match(/^(.+?第[0-9]+条[之]*[一二三四五六七八九十]*)[::]\s*([\s\S]*)$/);
if (seg) legalBases.push({ law: seg[1], article: '', content: seg[2] });
else legalBases.push({ law: '', article: '', content: line });
});
}
}
data.legal_bases = legalBases;
// 案例
const caseHtml = findSection(sections, ['案例', '判例']);
const caseTableMatch = caseHtml.match(/<table[^>]*>([\s\S]*?)<\/table>/i);
const cases = [];
if (caseTableMatch) {
const rows = parseRowsWithLinks(caseTableMatch[1]);
rows.forEach(function (r) {
const row = r.cells;
if (!row.length) return;
if (/案件名称/.test(row.join('')) || /^序号$/.test(row[0])) return; // 表头
const hasLinkCol = row.length >= 7;
cases.push({
name: row[hasLinkCol ? 1 : 0] || '-',
url: r.url || '',
court: row[hasLinkCol ? 3 : 2] || '-',
year: row[hasLinkCol ? 4 : 3] || '-',
result: row[hasLinkCol ? 5 : 4] || '-',
relation: row[hasLinkCol ? 6 : 5] || '-'
});
});
}
data.cases = cases;
// 免责声明(优先按 h2 区块取,取不到再从全局找 class="disclaimer")
const disHtml = findSection(sections, ['免责声明', '免责']);
let disclaimer = pickDiv(disHtml, 'disclaimer') || stripTags(disHtml);
if (!disclaimer) {
const dm = raw.match(/<div[^>]*class="[^"]*\bdisclaimer\b[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
if (dm) disclaimer = stripTags(dm[1]);
}
data.disclaimer = disclaimer;
// 生成时间(页脚)
const footM = raw.match(/生成时间[::]\s*([^<\n]+)/);
if (footM && !data.review_date) data.review_date = footM[1].trim();
// 完全没解析出内容则视为失败
if (!data.conclusion && !data.high_risks.length && !data.suggestions.length) return null;
return data;
}
/**
* 从智能体回复里提取「插件生成的 Word 文件链接」
* 只认显式标记,避免误抓案例正文里的 .docx 链接
* 1. <a id="jjx-docx-link" href="https://...">下载 Word</a>
* 2. <!-- jjx-docx: https://... -->
*/
function extractDocxLink(answer) {
if (!answer) return '';
const a = answer.match(/<a[^>]*id=["']jjx-docx-link["'][^>]*href=["']([^"']+)["']/i);
if (a && a[1]) return a[1];
const b = answer.match(/<!--\s*jjx-docx\s*[::]\s*(https?:\/\/[^\s>]+)\s*-->/i);
if (b && b[1]) return b[1];
return '';
}
/**
* 判断智能体回复里是否有一份「可以转成 Word 的报告」
*
* 返回 true 的两种情况:
* 1. 智能体插件已经生成了 Word 并给了链接 → 直接复用,不用本地转
* 2. 回复里有完整的 HTML 报告(有 <html> 开头且有 </html> 结尾)
*
* 返回 false 的典型情况:
* - 「很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败…」这类道歉/澄清文本
* - HTML 被 max token 截断,缺 </html> —— 强行转会生成一份残缺的 Word,宁可不转
*/
function isDocxConvertible(answer) {
if (!answer || typeof answer !== 'string') return false;
if (extractDocxLink(answer)) return true;
if (!/<!DOCTYPE\s+html|<html[\s>]/i.test(answer)) return false;
return /<\/html>/i.test(answer);
}
/**
* 从智能体返回的 HTML 中提取结构化报告数据
* 优先级:<script id="jjx-report-data"> → ```json 代码块 → HTML 结构解析
*/
function extractReportData(answer) {
if (!answer) return null;
const m = answer.match(/<script[^>]*id=["']jjx-report-data["'][^>]*>([\s\S]*?)<\/script>/i);
if (m && m[1]) {
try {
return JSON.parse(m[1].trim());
} catch (e) {
console.log('[Docx] jjx-report-data JSON 解析失败,改用 HTML 解析:', e.message);
}
}
const blocks = answer.match(/```json\s*([\s\S]*?)```/g) || [];
for (let i = blocks.length - 1; i >= 0; i--) {
try {
const raw = blocks[i].replace(/```json\s*/, '').replace(/```$/, '').trim();
const obj = JSON.parse(raw);
if (obj && (obj.high_risks || obj.highRisks || obj.summary)) return obj;
} catch (e) { /* 继续找 */ }
}
// 兜底:直接解析 HTML 报告结构
try {
const parsed = parseHtmlReport(answer);
if (parsed) {
console.log('[Docx] 已从 HTML 报告解析结构化数据');
return parsed;
}
// 诊断:打印实际 HTML 结构关键特征,定位解析失败原因
const h2s = (answer.match(/<h2[^>]*>([\s\S]*?)<\/h2>/gi) || [])
.map(function (h) { return h.replace(/<[^>]+>/g, '').replace(/\s+/g, '').slice(0, 20); });
console.log('[Docx] HTML 解析为空。诊断 → h2区块[' + h2s.length + ']:',
h2s.length ? h2s.join(' | ') : '无h2标签',
'| 有table:', /<table/i.test(answer),
'| 有risk-item:', /risk-item/i.test(answer),
'| 有审核结论字样:', /审核结论|审查结论/.test(answer),
'| 长度:', answer.length);
} catch (e) {
console.log('[Docx] HTML 报告解析失败:', e.message);
}
return null;
}
module.exports = {
buildDocx,
extractReportData,
parseHtmlReport,
extractDocxLink,
isDocxConvertible
};
@@ -25,23 +25,54 @@ module.exports = {
}
});
let answer = '暂时无法回复,请拨打 400-686-5559 咨询';
// 扣子 v3 流式事件说明:
// conversation.message.delta —— 增量片段(应当拼接)
// conversation.message.completed —— 该条消息的「完整内容」(不能再次拼接,否则重复)
// 因此必须按事件类型区分,只累加 delta;completed 仅用于兜底。
let conversationId = '';
const msgMap = new Map(); // messageId -> { text }
let curEvent = '';
const text = res.data || '';
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('event:')) {
curEvent = line.slice(6).trim();
continue;
}
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;
// 只取正文,忽略 verbose(工具调用日志)等其他类型
if (p.type !== 'answer' || typeof p.content !== 'string') continue;
const key = p.id || '__default__';
const rec = msgMap.get(key) || { text: '' };
if (curEvent === 'conversation.message.delta') {
// 防御:万一上游发的是累计内容而非增量,用长度+前缀判断避免重复
if (p.content.length > rec.text.length && p.content.indexOf(rec.text) === 0) {
rec.text = p.content;
} else {
rec.text += p.content;
}
msgMap.set(key, rec);
} else if (curEvent === 'conversation.message.completed') {
// 已有 delta 拼出的内容就丢掉 completed,避免重复;没有则用它兜底
if (!rec.text && p.content) {
rec.text = p.content;
msgMap.set(key, rec);
}
}
} catch (e) { /* 跳过 */ }
}
// 按消息首次出现顺序拼接(多段消息之间自带换行,直接相连)
let answer = '';
for (const rec of msgMap.values()) answer += rec.text;
if (!answer) answer = '暂时无法回复,请拨打 400-686-5559 咨询';
return {
code: 0,
msg: 'ok',
@@ -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 };
}
}
};
@@ -0,0 +1,30 @@
module.exports = {
main: async (event) => {
let { data = {}, userInfo, util } = event;
let { db } = util;
const { pageIndex = 1, pageSize = 50 } = data;
const uid = (data && data.uid) || '';
if (!uid) return { code: -1, msg: '未登录' };
const where = { user_id: uid };
const countRes = await db.collection('jjx-review-records').where(where).count();
const listRes = await db.collection('jjx-review-records')
.where(where)
.orderBy('_add_time', 'desc')
.skip((pageIndex - 1) * pageSize)
.limit(pageSize)
.get();
return {
code: 0,
msg: 'ok',
data: {
total: countRes.total,
rows: listRes.data
}
};
}
};
@@ -0,0 +1,27 @@
// 云函数:client/ai/pub/getReviewResult
module.exports = {
main: async (event) => {
let { data = {}, userInfo, util } = event;
let { db } = util;
const { record_id } = data;
if (!record_id) return { code: -1, msg: '缺少 record_id' };
const record = await db.collection('jjx-review-records').doc(record_id).get();
if (!record.data.length) return { code: -1, msg: '记录不存在' };
const rec = record.data[0];
return {
code: 0,
data: {
status: rec.status,
result: rec.result || '',
points_used: rec.points_used || 0,
html_url: rec.html_url || '',
contract_info: rec.contract_info || {},
error: rec.error || ''
}
};
}
};
@@ -0,0 +1,277 @@
// client/ai/pub/jjxDoReview.js
// Word 报告生成器(依赖 docx,未安装时降级跳过,不影响审查)
let buildDocx = null;
let extractReportData = null;
let extractDocxLink = null;
let isDocxConvertible = null;
try {
const docxMod = require('./buildDocx.js');
buildDocx = docxMod.buildDocx;
extractReportData = docxMod.extractReportData;
extractDocxLink = docxMod.extractDocxLink;
isDocxConvertible = docxMod.isDocxConvertible;
} catch (e) {
console.log('[Docx] docx 依赖未安装,跳过 Word 生成:', e.message);
}
module.exports = {
main: async (event) => {
let { data = {}, userInfo } = event;
const { record_id, file_id, user_id, file_name, file_size } = data;
if (!record_id || !file_id) return { code: -1, msg: '参数错误' };
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
const BOT_ID = '7689821072762011698';
const db = uniCloud.database();
const _ = db.command;
try {
// 构造请求
const objectString = JSON.stringify([
{ type: 'file', file_id: file_id },
{ type: 'text', text: '请审查这份合同' }
]);
// 调用 Coze 接口(流式模式)
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
method: 'POST',
dataType: 'stream', // 关键:改成 stream
contentType: 'json',
timeout: 300000, // 5分钟超时
headers: {
'Authorization': 'Bearer ' + COZE_TOKEN,
'Accept': 'text/event-stream'
},
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'
}]
}
});
// 从流中逐步解析
const stream = chatRes.data || chatRes.res;
let buffer = '';
let answer = '';
let conversationId = '';
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;
// 处理回答内容
// 注意:conversation.message.completed 携带的是该条消息的完整内容,
// 若前面已经通过 delta 拼过,再拼一次就会重复,这里做去重
if (p.type === 'answer' && p.content) {
const isDup = eventName === 'conversation.message.completed' && answer.indexOf(p.content) >= 0;
if (!isDup) {
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) {}
}
});
stream.on('end', async () => {
if (!answer) {
answer = '审查未能获取结果,请稍后重试';
}
try {
// 计算消耗积分
const pointsUsed = Math.max(150, Math.ceil((file_size || 0) / (100 * 1024)) * 150);
// 扣积分
const userPoints = await db.collection('jjx-user-points').doc(user_id).get();
if (userPoints.data.length && userPoints.data[0].balance >= pointsUsed) {
await db.collection('jjx-user-points').doc(user_id).update({
balance: _.inc(-pointsUsed),
total_spent: _.inc(pointsUsed),
update_time: new Date()
});
const updatedPoints = await db.collection('jjx-user-points').doc(user_id).get();
await db.collection('jjx-points-log').add({
user_id: user_id,
type: 'spend',
amount: -pointsUsed,
balance_after: updatedPoints.data[0] ? updatedPoints.data[0].balance : 0,
reason: '合同审查 — ' + (file_name || ''),
biz_type: 'contract_review',
biz_id: record_id,
_add_time: new Date()
});
}
// 保存 HTML 报告到云存储
let htmlUrl = '';
try {
// 如果回复本身就是 HTML(包含 <!DOCTYPE html> 或 <html>)
let htmlContent = answer;
const htmlMatch = answer.match(/```html\s*([\s\S]*?)```/);
if (htmlMatch && htmlMatch[1]) {
htmlContent = htmlMatch[1].trim();
}
if (htmlContent.includes('<!DOCTYPE') || htmlContent.includes('<html')) {
const cloudPath = 'review-reports/' + Date.now() + '_' + (file_name || 'report').replace(/\.[^.]+$/, '') + '.html';
const uploadRes = await uniCloud.uploadFile({
cloudPath: cloudPath,
fileContent: Buffer.from(htmlContent, 'utf-8')
});
if (uploadRes.fileID) {
const urlRes = await uniCloud.getTempFileURL({ fileList: [uploadRes.fileID] });
if (urlRes.fileList && urlRes.fileList[0]) {
htmlUrl = urlRes.fileList[0].tempFileURL;
}
}
}
} catch (e) {
console.log('HTML报告上传失败:', e.message);
}
// 解析 JSON 结构化数据(如果有的话)
let contractInfo = {};
try {
const jsonMatch = answer.match(/```json\s*([\s\S]*?)```/);
if (jsonMatch) contractInfo = JSON.parse(jsonMatch[1].trim());
} catch (e) {}
// 生成 Word 报告:优先用智能体插件生成的链接,否则本地结构化重建
let docxUrl = extractDocxLink ? extractDocxLink(answer) : '';
let docxFileId = '';
if (docxUrl) {
console.log('[Docx] 采用智能体插件生成的 Word 链接:', docxUrl);
} else if (isDocxConvertible && !isDocxConvertible(answer)) {
// 智能体没返回完整报告(道歉/澄清文本,或 HTML 被截断),不生成残缺 Word
console.log('[Docx] 无完整 HTML 报告,跳过 Word 生成');
} else if (buildDocx && extractReportData) {
try {
const reportData = extractReportData(answer);
if (reportData) {
const docxBuffer = await buildDocx({
data: reportData,
fileName: file_name || '合同审查报告'
});
const baseName = (file_name || 'report').replace(/\.[^.]+$/, '');
const docxPath = 'review-reports/' + Date.now() + '_' + baseName + '_审查报告.docx';
const docxRes = await uniCloud.uploadFile({
cloudPath: docxPath,
fileContent: docxBuffer
});
if (docxRes.fileID) {
docxFileId = docxRes.fileID;
const docxUrlRes = await uniCloud.getTempFileURL({ fileList: [docxRes.fileID] });
if (docxUrlRes.fileList && docxUrlRes.fileList[0]) {
docxUrl = docxUrlRes.fileList[0].tempFileURL;
}
}
} else {
console.log('[Docx] 既无插件链接也无结构化数据,跳过 Word 生成');
}
} catch (e) {
console.log('[Docx] Word 报告生成失败(不影响审查结果):', e.message);
}
}
// 更新审查记录为完成
await db.collection('jjx-review-records').doc(record_id).update({
status: 'completed',
result: answer,
html_url: htmlUrl,
docx_url: docxUrl,
docx_file_id: docxFileId,
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);
}
});
stream.on('error', async (err) => {
await db.collection('jjx-review-records').doc(record_id).update({
status: 'failed',
result: '审查失败: ' + err.message,
error: err.message,
update_time: new Date()
});
reject(err);
});
});
} catch (e) {
// 审查失败,更新记录
await db.collection('jjx-review-records').doc(record_id).update({
status: 'failed',
result: '审查失败: ' + e.message,
error: e.message,
update_time: new Date()
});
return { code: -1, msg: '审查失败: ' + e.message };
}
}
};
@@ -0,0 +1,252 @@
// client/ai/pub/review.js
// Markdown 转 HTML
function mdToHtml(md) {
if (!md) return '';
let html = md;
// 标题
html = html.replace(/^####\s+(.+)$/gm, '<h5>$1</h5>');
html = html.replace(/^###\s+(.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^##\s+(.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^#\s+(.+)$/gm, '<h2>$1</h2>');
// 加粗
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// 链接
html = html.replace(/(https?:\/\/[^\s\)\]\"\<]+)/g, '<a href="$1" target="_blank">$1</a>');
// 代码块
html = html.replace(/```[\w]*\n([\s\S]*?)```/g, '<pre class="code-block">$1</pre>');
// 行内代码
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// 列表
html = html.replace(/^[\-\•]\s+(.+)$/gm, '<li>$1</li>');
html = html.replace(/^(\d+)\.\s+(.+)$/gm, '<li>$1. $2</li>');
// 换行
html = html.replace(/\n\n/g, '</p><p>');
html = html.replace(/\n/g, '<br>');
html = '<p>' + html + '</p>';
// 清理空段落
html = html.replace(/<p>\s*<\/p>/g, '');
return html;
}
// 生成精美 HTML 报告
function generateHtmlReport(markdown, fileName, reportDate, info) {
const riskLevel = info.risk_level || '待评估';
const riskSummary = info.risk_summary || '';
const riskCount = info.risk_count || { high: 0, medium: 0, low: 0 };
const contractName = info.contract_name || fileName || '合同审查报告';
const contractType = info.contract_type || '';
// 风险等级颜色
let riskColor = '#67c23a';
if (riskLevel.includes('高')) riskColor = '#f56c6c';
else if (riskLevel.includes('中高')) riskColor = '#e6a23c';
else if (riskLevel.includes('中')) riskColor = '#e6a23c';
else if (riskLevel.includes('中低')) riskColor = '#409eff';
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${contractName} - 审查报告</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: #f5f7fa; color: #333; line-height: 1.8; }
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #a92924, #d4574e); color: #fff; padding: 40px; border-radius: 12px 12px 0 0; }
.header h1 { font-size: 24px; margin-bottom: 8px; }
.header .subtitle { opacity: 0.85; font-size: 14px; }
.summary-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 24px 40px; background: #fff; border-bottom: 1px solid #eee; }
.summary-card { text-align: center; padding: 16px; border-radius: 8px; background: #f9f9f9; }
.summary-card .label { font-size: 12px; color: #999; margin-bottom: 4px; }
.summary-card .value { font-size: 20px; font-weight: 700; }
.body { background: #fff; padding: 32px 40px; border-radius: 0 0 12px 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
.body h2 { font-size: 18px; color: #222; margin: 28px 0 12px; padding-bottom: 8px; border-bottom: 2px solid #a92924; }
.body h3 { font-size: 16px; color: #333; margin: 20px 0 8px; }
.body h4 { font-size: 14px; color: #444; margin: 16px 0 6px; }
.body p { margin: 8px 0; font-size: 14px; }
.body li { margin: 4px 0 4px 20px; font-size: 14px; }
.body strong { color: #a92924; }
.body a { color: #a92924; text-decoration: underline; }
.risk-badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600; color: #fff; }
.risk-high { background: #f56c6c; }
.risk-medium { background: #e6a23c; }
.risk-low { background: #67c23a; }
.code-block { background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 12px 16px; font-size: 13px; overflow-x: auto; margin: 8px 0; font-family: "SFMono-Regular", Consolas, monospace; }
.disclaimer { margin-top: 32px; padding: 16px 20px; background: #fff8e1; border-left: 4px solid #e6a23c; border-radius: 4px; font-size: 12px; color: #b8860b; }
.footer { text-align: center; padding: 24px; font-size: 12px; color: #999; }
@media print { body { background: #fff; } .container { padding: 0; } .header { border-radius: 0; } .body { box-shadow: none; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📋 合同审查报告</h1>
<div class="subtitle">${contractName} | ${reportDate}</div>
</div>
<div class="summary-cards">
<div class="summary-card">
<div class="label">综合风险等级</div>
<div class="value" style="color:${riskColor}">${riskLevel}</div>
</div>
<div class="summary-card">
<div class="label">风险统计</div>
<div class="value"><span style="color:#f56c6c">${riskCount.high||0}</span> / <span style="color:#e6a23c">${riskCount.medium||0}</span> / <span style="color:#67c23a">${riskCount.low||0}</span></div>
</div>
<div class="summary-card">
<div class="label">合同类型</div>
<div class="value" style="font-size:14px">${contractType || '待识别'}</div>
</div>
</div>
<div class="body">
<h2>总体评估</h2>
<p>${riskSummary || '暂无总体评估'}</p>
<h2>详细审查意见</h2>
${mdToHtml(markdown.replace(/```json[\s\S]*?```/, ''))}
<div class="disclaimer">
⚠️ <strong>免责声明</strong>:本报告由 AI 系统自动生成,仅供参考,不构成正式法律意见。涉及重大金额或复杂交易的合同,建议咨询执业律师。
</div>
</div>
<div class="footer">
金久信签约宝 · AI 合同审查服务 | 报告生成时间:${new Date().toLocaleString('zh-CN')}
</div>
</div>
</body>
</html>`;
}
module.exports = {
main: async (event) => {
let { data = {}, userInfo } = event;
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
const BOT_ID = '7689821072762011698';
const db = uniCloud.database();
const _ = db.command;
let fileId = data.file_id;
// ===== 模式1:上传文件 + 创建待审查记录 =====
if (data.upload_only && data.file_base64) {
const fileBuffer = Buffer.from(data.file_base64, 'base64');
const boundary = '----FormBoundary' + Date.now();
const header = Buffer.from(
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="file"; filename="' + (data.file_name || 'file') + '"\r\n' +
'Content-Type: application/octet-stream\r\n\r\n'
);
const footer = Buffer.from('\r\n--' + boundary + '--\r\n');
const uploadBody = Buffer.concat([header, fileBuffer, footer]);
try {
const uploadRes = await uniCloud.httpclient.request('https://api.coze.cn/v1/files/upload', {
method: 'POST',
dataType: 'json',
headers: {
'Authorization': 'Bearer ' + COZE_TOKEN,
'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Content-Length': uploadBody.length.toString()
},
content: uploadBody,
timeout: 600000
});
if (uploadRes.data.code !== 0) {
return { code: -1, msg: '文件上传失败: ' + uploadRes.data.msg };
}
fileId = uploadRes.data.data.id;
// 同时上传到 uniCloud 云存储(用于查看/下载)
let fileUrl = '';
try {
const cloudPath = 'review-files/' + Date.now() + '_' + (data.file_name || 'file');
const uploadFileRes = await uniCloud.uploadFile({
cloudPath: cloudPath,
fileContent: fileBuffer
});
if (uploadFileRes.fileID) {
const urlRes = await uniCloud.getTempFileURL({
fileList: [uploadFileRes.fileID]
});
if (urlRes.fileList && urlRes.fileList[0]) {
fileUrl = urlRes.fileList[0].tempFileURL;
}
}
} catch (e) {
console.log('云存储上传失败(不影响审查):', e.message);
}
// 创建待审查记录
const recordRes = await db.collection('jjx-review-records').add({
user_id: (data && data.uid) || 'anonymous',
file_name: data.file_name || '',
file_size: data.file_size || 0,
file_id: fileId,
file_url: fileUrl,
chat_id: '',
conversation_id: '',
status: 'uploaded',
result: '',
points_used: 0,
_add_time: new Date(),
update_time: new Date()
});
return { code: 0, msg: 'ok', data: { file_id: fileId, record_id: recordRes.id, file_url: fileUrl } };
} catch (e) {
return { code: -1, msg: '上传异常: ' + e.message };
}
}
// ===== 模式2:发起审查(只更新状态,立即返回)=====
if (!fileId) return { code: -1, msg: '缺少 file_id' };
const recordId = data.record_id;
if (!recordId) return { code: -1, msg: '缺少 record_id' };
// 检查当前状态
const record = await db.collection('jjx-review-records').doc(recordId).get();
if (record.data.length) {
const rec = record.data[0];
// 已完成,直接返回结果
if (rec.status === 'completed') {
return {
code: 0, msg: 'ok',
data: {
record_id: recordId,
status: 'completed',
result: rec.result,
points_used: rec.points_used,
html_url: rec.html_url || '',
contract_info: rec.contract_info || {}
}
};
}
// 正在审查中,直接返回
if (rec.status === 'reviewing') {
return { code: 0, msg: 'ok', data: { record_id: recordId, status: 'reviewing' } };
}
// 失败,返回错误
if (rec.status === 'failed') {
return { code: 0, msg: 'ok', data: { record_id: recordId, status: 'failed', result: rec.result || '审查失败' } };
}
}
// 更新状态为审查中
await db.collection('jjx-review-records').doc(recordId).update({
file_id: fileId,
file_name: data.file_name || '',
file_size: data.file_size || 0,
status: 'reviewing',
review_started_at: new Date(),
update_time: new Date()
});
// ===== 立即返回,审查由定时任务触发 =====
return {
code: 0, msg: 'ok',
data: { record_id: recordId, status: 'reviewing' }
};
}
};
@@ -260,7 +260,7 @@ module.exports = {
if (item.pricingForm) detail += ` ,${item.pricingForm}`
if (item.loadPaidBy) detail += ` ,${item.loadPaidBy}`
item.model.forEach((model) => {
detail += `,${model.label}:${model.value}`
if (model.label && model.value && model.value !== 'null') detail += `,${model.label}:${model.value}`
})
if (index < sheet.productList.length - 1) detail += '\r\n'
})
@@ -1,48 +1,71 @@
'use strict';
function formatZhDateTime(vk, timestamp, fallbackText) {
if (fallbackText) {
const matched = String(fallbackText).match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/);
if (matched) {
return `${matched[1]}年${matched[2]}月${matched[3]}日${matched[4]}:${matched[5]}`;
}
}
const date = new Date(Number(timestamp) || Date.now());
return vk.pubfn.timeFormat(date, 'yyyy年MM月dd日hh:mm');
}
module.exports = {
/**
* 查询当前买家 24 小时内即将自动划付的公证付款申请。
*/
main: async (event) => {
const {data = {}, util} = event;
const {uid} = data;
const {vk, _} = util;
const now = Date.now();
const deadline = now + 24 * 60 * 60 * 1000;
/**
* 查询当前买家 24 小时内即将自动划付的公证付款申请。
* @url client/order/kh/getExpiringNotaryPayList
*/
main: async (event) => {
const { data = {}, util } = event;
const { uid } = data;
const { vk, _ } = util;
const now = Date.now();
const deadline = now + 24 * 60 * 60 * 1000;
const result = await vk.baseDao.select({
dbName: 'jjx-order-pay-list',
whereJson: _.and([
{
buyerUid: uid,
isNotaryPay: true,
isNotaryApply: true,
hasPaid: true,
buyerRefuse: false,
sellerConfirm: false,
},
{autoTransferTimestamp: _.gte(now)},
{autoTransferTimestamp: _.lte(deadline)},
]),
sortArr: [{name: 'autoTransferTimestamp', type: 'asc'}],
pageIndex: 1,
pageSize: 20,
});
const result = await vk.baseDao.select({
dbName: 'jjx-order-pay-list',
whereJson: _.and([
{
buyerUid: uid,
isNotaryPay: true,
isNotaryApply: true,
hasPaid: true,
buyerRefuse: false,
sellerConfirm: false,
},
{ autoTransferTimestamp: _.gte(now) },
{ autoTransferTimestamp: _.lte(deadline) },
]),
sortArr: [{ name: 'autoTransferTimestamp', type: 'asc' }],
pageIndex: 1,
pageSize: 20,
});
return {
code: 0,
msg: '',
data: {
count: (result.rows || []).length,
rows: (result.rows || []).map(item => ({
orderId: item.orderId,
type: item.type,
amount: item.amount,
autoTransferAt: item.autoTransferAt,
autoTransferTimestamp: item.autoTransferTimestamp,
})),
},
};
},
const rows = (result.rows || []).map(item => ({
orderId: item.orderId,
type: item.type,
amount: item.amount,
autoTransferAt: item.autoTransferAt,
autoTransferTimestamp: item.autoTransferTimestamp,
autoTransferAtText: formatZhDateTime(vk, item.autoTransferTimestamp, item.autoTransferAt),
}));
const nearest = rows[0] || null;
const autoConfirmTimeText = nearest ? nearest.autoTransferAtText : '';
const content = autoConfirmTimeText
? `重要提示:您在金久信公正交易项下待确认业务的系统自动确认时间为${autoConfirmTimeText},该期限即将届满;若到期未完成相关处理,系统将依据本平台交易规则自动确认收货或处理申请金额,请您及时登录平台完成对应操作,避免产生利益损失。若您已完成相关处理,可忽略本提示,如有疑问请联系客服咨询。`
: '';
return {
code: 0,
msg: '',
data: {
count: rows.length,
rows,
autoConfirmTimeText,
content,
},
};
},
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
//创建积分充值订单
module.exports = {
main: async (event) => {
let { data = {}, userInfo, util } = event;
let { vk, db, _ } = util;
const { points, price } = data; // points=积分数量 price=价格(分→元)
const amount = price / 100; // 分转元
const offAmount = amount;
const orderId = await vk.baseDao.add({
dbName: "jjx-order",
dataJson: {
orderNo: vk.pubfn.createOrderNo("39XU", 20),
uid: userInfo.uid,
orderStatus: 0,
type: 20, // 20=积分充值
amount,
offAmount,
saveAmount: 0,
offRate: 100,
}
});
await vk.baseDao.add({
dbName: "jjx-order-goods",
dataJson: {
orderId,
goodsId: 'points_recharge',
num: 1,
goodsName: `积分充值_${points}积分`,
goodsPrice: amount,
goodsCover: null,
}
});
return { code: 0, msg: 'ok', data: orderId };
}
};
@@ -0,0 +1,47 @@
//扣积分(合同审查用)
module.exports = {
main: async (event) => {
let { data = {}, userInfo, util } = event;
let { db, _ } = util;
const { amount, reason, biz_type, biz_id } = data;
if (!amount || amount <= 0) {
return { code: -1, msg: '扣减数量无效' };
}
// 查余额
const userRes = await db.collection('jjx-user-points').doc(userInfo.uid).get();
if (!userRes.data.length) {
return { code: -1, msg: '积分账户不存在,请先获取积分' };
}
const currentBalance = userRes.data[0].balance;
if (currentBalance < amount) {
return { code: -1, msg: '积分不足', data: { balance: currentBalance, needed: amount } };
}
const newBalance = currentBalance - amount;
// 扣减余额
await db.collection('jjx-user-points').doc(userInfo.uid).update({
balance: newBalance,
total_spent: _.inc(amount),
update_time: new Date()
});
// 记录流水
await db.collection('jjx-points-log').add({
user_id: userInfo.uid,
type: 'spend',
amount: -amount,
balance_after: newBalance,
reason: reason || '积分消耗',
biz_type: biz_type || 'other',
biz_id: biz_id || '',
_add_time: new Date()
});
return { code: 0, msg: 'ok', data: { balance: newBalance } };
}
};
@@ -0,0 +1,37 @@
// 获取积分余额
module.exports = {
main: async (event) => {
let { data = {}, userInfo, util } = event;
let { db, _ } = util;
//默认积分500
let amount = 500;
const res = await db.collection('jjx-user-points').doc(userInfo.uid).get();
if (res.data.length === 0) {
// 新用户,自动赠送 500 积分
await db.collection('jjx-user-points').add({
_id: userInfo.uid,
balance: amount,
total_earned: amount,
total_spent: 0,
gift: amount,
update_time: new Date()
});
await db.collection('jjx-points-log').add({
user_id: userInfo.uid,
type: 'earn',
amount: amount,
balance_after: amount,
reason: '新用户注册赠送',
biz_type: 'gift',
_add_time: new Date()
});
return { code: 0, msg: 'ok', data: { balance: amount, total_earned: amount, total_spent: 0, gift: amount } };
}
return { code: 0, msg: 'ok', data: res.data[0] };
}
};
@@ -0,0 +1,40 @@
//查询积分流水
module.exports = {
main: async (event) => {
let { data = {}, userInfo, util } = event;
let { vk, db, _ } = util;
const { pageIndex = 1, pageSize = 50, biz_type } = data;
console.log('getLog uid:', userInfo.uid);
// 先查全部看看有没有数据
const allRes = await db.collection('jjx-points-log').limit(5).get();
console.log('getLog all data count:', allRes.data.length);
if (allRes.data.length > 0) {
console.log('getLog first record:', JSON.stringify(allRes.data[0]));
}
const where = { user_id: userInfo.uid };
if (biz_type) where.biz_type = biz_type;
const countRes = await db.collection('jjx-points-log').where(where).count();
console.log('getLog filtered count:', countRes.total);
const listRes = await db.collection('jjx-points-log')
.where(where)
.orderBy('_add_time', 'desc')
.skip((pageIndex - 1) * pageSize)
.limit(pageSize)
.get();
return {
code: 0,
msg: 'ok',
data: {
total: countRes.total,
rows: listRes.data
}
};
}
};
@@ -0,0 +1,55 @@
//积分充值
module.exports = {
main: async (event) => {
let { data = {}, userInfo, util } = event;
let { db, _ } = util;
const { order_id } = data;
if (!order_id) {
return { code: -1, msg: '缺少订单ID' };
}
// 查订单
const orderRes = await db.collection('vk-pay-orders').doc(order_id).get();
if (!orderRes.data.length) {
return { code: -1, msg: '订单不存在' };
}
const order = orderRes.data[0];
if (order.status !== 1) {
return { code: -1, msg: '订单未支付' };
}
// 解析充值积分数量
const attachedData = JSON.parse(order.attached_data || '{}');
const points = attachedData.points;
if (!points) {
return { code: -1, msg: '订单数据异常' };
}
// 增加余额
await db.collection('jjx-user-points').doc(userInfo.uid).update({
balance: _.inc(points),
total_earned: _.inc(points),
update_time: new Date()
});
// 查最新余额
const userRes = await db.collection('jjx-user-points').doc(userInfo.uid).get();
const balance = userRes.data[0].balance;
// 记录流水
await db.collection('jjx-points-log').add({
user_id: userInfo.uid,
type: 'earn',
amount: points,
balance_after: balance,
reason: '积分充值(' + (order.total_fee / 100) + '元)',
biz_type: 'recharge',
biz_id: order_id,
_add_time: new Date()
});
return { code: 0, msg: 'ok', data: { balance, points_added: points } };
}
};
@@ -0,0 +1,143 @@
'use strict';
const notaryPay = require('../../order/kh/util/notaryPay');
/** 短信模板字典 ID,content 填阿里云模板号,如 SMS_xxxxx;模板变量为 time */
const AUTO_TRANSFER_REMIND_SMS_DICT_ID = 'auto_transfer_remind_sms_template';
/** 兜底短信模板 ID(模板变量:time),上线前请替换为已审核通过的阿里云模板 */
const AUTO_TRANSFER_REMIND_SMS_TEMPLATE_ID = '';
function formatZhDateTime(vk, timestamp) {
const date = new Date(Number(timestamp) || Date.now());
return vk.pubfn.timeFormat(date, 'yyyy年MM月dd日hh:mm');
}
function buildRemindContent(autoConfirmTimeText) {
return `重要提示:您在金久信公正交易项下待确认业务的系统自动确认时间为${autoConfirmTimeText},该期限即将届满;若到期未完成相关处理,系统将依据本平台交易规则自动确认收货或处理申请金额,请您及时登录平台完成对应操作,避免产生利益损失。若您已完成相关处理,可忽略本提示,如有疑问请联系客服咨询。`;
}
async function getRemindSmsTemplateId(vk) {
try {
const config = await vk.baseDao.findByWhereJson({
dbName: 'jjx-dict',
whereJson: {
_id: AUTO_TRANSFER_REMIND_SMS_DICT_ID
}
});
if (config && config.content) return String(config.content).trim();
const configByKey = await vk.baseDao.findByWhereJson({
dbName: 'jjx-dict',
whereJson: {
key: AUTO_TRANSFER_REMIND_SMS_DICT_ID
}
});
if (configByKey && configByKey.content) return String(configByKey.content).trim();
} catch (err) {
console.error('getRemindSmsTemplateId error:', err);
}
return AUTO_TRANSFER_REMIND_SMS_TEMPLATE_ID;
}
module.exports = {
/**
* 距离自动划付不足 24 小时时,发送短信 + 站内信提醒(每笔申请仅提醒一次)
* @url client/timedTask/pub/remindAutoTransferPayList
*/
main: async (event) => {
let { data = {}, util } = event;
let { vk, _, pubFun } = util;
let res = { code: 0, msg: '', data: [] };
if (data.key !== 'lsgjoksdjf') return { code: 1, msg: '非法请求' };
const now = Date.now();
const deadline = now + 24 * 60 * 60 * 1000;
const smsTemplateId = await getRemindSmsTemplateId(vk);
const payList = await vk.baseDao.select({
dbName: 'jjx-order-pay-list',
whereJson: _.and([
{
isNotaryPay: true,
isNotaryApply: true,
hasPaid: true,
buyerRefuse: false,
sellerConfirm: false,
autoTransferRemindSent: _.neq(true)
},
{ autoTransferTimestamp: _.gt(now) },
{ autoTransferTimestamp: _.lte(deadline) }
]),
sortArr: [{ name: 'autoTransferTimestamp', type: 'asc' }],
pageIndex: 1,
pageSize: 200
});
for (const payRecord of payList.rows || []) {
const itemRes = {
_id: payRecord._id,
orderId: payRecord.orderId,
buyerUid: payRecord.buyerUid,
message: false,
sms: false,
msg: ''
};
try {
if (!payRecord.buyerUid) {
itemRes.msg = '缺少买家用户';
res.data.push(itemRes);
continue;
}
const autoConfirmTimeText = payRecord.autoTransferAt
? String(payRecord.autoTransferAt).replace(
/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}).*$/,
'$1年$2月$3日$4:$5'
)
: formatZhDateTime(vk, payRecord.autoTransferTimestamp);
const content = buildRemindContent(autoConfirmTimeText);
await notaryPay.addMessage(vk, {
uid: payRecord.buyerUid,
title: '重要提示',
content,
type: 'notary-auto-transfer-remind',
pk: payRecord.orderId
});
itemRes.message = true;
if (smsTemplateId) {
try {
if (typeof pubFun.sendNotifySms === 'function') {
await pubFun.sendNotifySms(payRecord.buyerUid, smsTemplateId, {
time: autoConfirmTimeText
});
itemRes.sms = true;
}
} catch (smsErr) {
console.error('remindAutoTransferPayList sms error:', payRecord._id, smsErr);
itemRes.msg = smsErr.message || '短信发送失败';
}
} else {
itemRes.msg = '未配置短信模板';
}
await vk.baseDao.updateById({
dbName: 'jjx-order-pay-list',
id: payRecord._id,
dataJson: {
autoTransferRemindSent: true,
autoTransferRemindAt: vk.pubfn.timeFormat(new Date(), 'yyyy-MM-dd hh:mm:ss'),
autoTransferRemindTimestamp: Date.now()
}
});
} catch (err) {
console.error('remindAutoTransferPayList error:', payRecord._id, err);
itemRes.msg = err.message || '提醒失败';
}
res.data.push(itemRes);
}
res.msg = `已处理${res.data.length}笔即将自动划付提醒`;
return res;
}
};
@@ -0,0 +1,20 @@
module.exports = {
/**
* 此函数名称
* @url template/test/sys/test 前端调用的url参数地址
* data 请求参数 说明
* res 返回参数说明
* @params {Number} code 错误码,0表示成功
* @params {String} msg 详细信息
*/
main: async (event) => {
let { data = {}, userInfo, util, originalParam } = event;
let { uniID, config, pubFun, vk , db, _ } = util;
let { uid } = data;
let res = { code : 0, msg : '' };
// 业务逻辑开始-----------------------------------------------------------
res.msg = "ok";
// 业务逻辑结束-----------------------------------------------------------
return res;
}
}
@@ -55,6 +55,15 @@ exports.main = async (event, context) => {
}
}
});
await uniCloud.callFunction({
name: "router",
data: {
$url: "client/timedTask/pub/remindAutoTransferPayList",
data: {
key: "lsgjoksdjf"
}
}
});
await uniCloud.callFunction({
name: "router",
data: {
@@ -0,0 +1 @@
{"bsonType":"object","required":[],"properties":{"_id":{"description":"记录ID,系统自动生成","bsonType":"string"},"user_id":{"bsonType":"string","title":"用户ID","description":"关联 uni-id-users._id","foreignKey":"uni-id-users._id"},"type":{"bsonType":"string","title":"变动类型","description":"earn=获得 spend=消耗","enum":[{"text":"获得","value":"earn"},{"text":"消耗","value":"spend"}]},"amount":{"bsonType":"number","title":"变动数量","description":"正数为获得,负数为消耗"},"balance_after":{"bsonType":"number","title":"变动后余额","description":"本次变动后的积分余额"},"reason":{"bsonType":"string","title":"变动原因","description":"如:新用户赠送、积分充值、合同审查消耗","trim":"both"},"biz_type":{"bsonType":"string","title":"业务类型","description":"关联的业务类型","enum":[{"text":"赠送","value":"gift"},{"text":"充值","value":"recharge"},{"text":"合同审查","value":"contract_review"},{"text":"签到","value":"checkin"},{"text":"其他","value":"other"}]},"biz_id":{"bsonType":"string","title":"业务ID","description":"关联的业务记录ID(如订单ID)"},"_add_time":{"bsonType":"date","title":"创建时间","description":"记录创建时间,系统自动填充"}}}
@@ -0,0 +1 @@
{"bsonType":"object","required":[],"properties":{"_id":{"description":"记录ID,系统自动生成","bsonType":"string"},"user_id":{"bsonType":"string","title":"用户ID","description":"关联 uni-id-users._id","foreignKey":"uni-id-users._id"},"file_name":{"bsonType":"string","title":"文件名","description":"上传的合同文件名","trim":"both"},"file_size":{"bsonType":"number","title":"文件大小","description":"文件大小(字节)"},"file_id":{"bsonType":"string","title":"扣子文件ID","description":"上传到扣子后的 file_id"},"file_url":{"bsonType":"string","title":"文件预览地址","description":"uniCloud 云存储临时链接,用于查看\/下载原文件"},"html_url":{"bsonType":"string","title":"HTML报告地址","description":"精美 HTML 审查报告链接"},"pdf_url":{"bsonType":"string","title":"PDF报告地址","description":"PDF 审查报告下载链接"},"contract_info":{"bsonType":"object","title":"合同结构化信息","description":"AI 返回的合同名称、风险等级、风险统计等"},"chat_id":{"bsonType":"string","title":"对话ID","description":"扣子对话 chat_id"},"conversation_id":{"bsonType":"string","title":"会话ID","description":"扣子会话 conversation_id"},"status":{"bsonType":"string","title":"审查状态","description":"uploaded=待审查 reviewing=等待处理 processing=审查中 completed=已完成 failed=失败","enum":[{"text":"待审查","value":"uploaded"},{"text":"等待处理","value":"reviewing"},{"text":"审查中","value":"processing"},{"text":"已完成","value":"completed"},{"text":"失败","value":"failed"}]},"result":{"bsonType":"string","title":"审查结果","description":"AI 返回的审查结果内容"},"points_used":{"bsonType":"number","title":"消耗积分","description":"本次审查消耗的积分数量(token_count \/ 100)"},"token_count":{"bsonType":"number","title":"Token消耗","description":"扣子返回的 token 总消耗"},"_add_time":{"bsonType":"date","title":"创建时间","description":"记录创建时间"},"update_time":{"bsonType":"date","title":"更新时间","description":"最后更新时间"}}}
@@ -0,0 +1 @@
{"bsonType":"object","required":[],"properties":{"_id":{"description":"用户ID(与 uni-id-users._id 对应)","bsonType":"string"},"balance":{"bsonType":"number","title":"积分余额","description":"当前可用积分","defaultValue":0,"minimum":0},"total_earned":{"bsonType":"number","title":"累计获得","description":"累计获得的积分总数","defaultValue":0},"total_spent":{"bsonType":"number","title":"累计消耗","description":"累计消耗的积分总数","defaultValue":0},"gift":{"bsonType":"number","title":"赠送积分","description":"新用户赠送的积分数量","defaultValue":500},"update_time":{"bsonType":"date","title":"更新时间","description":"最后更新时间"}}}
Binary file not shown.
Binary file not shown.