feat(contract): 接入移动端合同打印与小程序文档交付以支持导出分享。

This commit is contained in:
wuzilong committed 2026-10-31 22:34:11 +08:00
1 parent 0cd120ef5e
commit 13630045df
16 files changed
+3666 -4

No files matched your search

+1 -1
View File
@@ -76,6 +76,6 @@ npm run release:check-api # 发版前:要求 APP_ENV=production
| MinIO 附件 | https://res.shanxiachem.com/ruoyi |
| 微信 AppID | `wx7db1ddb2a90c9425` |
微信公众平台合法域名请配置:`w.shanxiachem.com`(request / uploadFile / downloadFile)。
微信公众平台服务器域名:`https://w.shanxiachem.com` 用于 request / uploadFile;合同文档还必须将 `https://res.shanxiachem.com` 加入 **downloadFile 合法域名**。只填写域名,不带 `/ruoyi` 路径;H5 的业务域名配置不替代 downloadFile 配置。
维护入口:[小程序工程规则](AGENTS.md)、[小程序 UI 规范](../docs/miniprogram-ui-spec-sanqiang.md);地磅页面与公共组件见上文「已接通业务:地磅管理」。
+1
View File
@@ -54,6 +54,7 @@
"navigationStyle": "custom"
}
},
{ "path": "pages/common/contract-document", "style": { "navigationBarTitleText": "合同文档" } },
{
"path": "pages/common/biz-placeholder",
"style": {
@@ -0,0 +1,75 @@
<template>
<view class="contract-document">
<text class="contract-document__title">{{ fileName }}</text>
<text>{{ message }}</text>
<button :disabled="busy || !documentInfo" @click="openDocument">{{ busy ? '正在打开…' : '打开文档' }}</button>
<text class="contract-document__hint">打开后可从右上角菜单转发文件;打印请将 PDF 交给支持打印的应用或电脑。</text>
<button @click="goBack">返回合同</button>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { MINIO_BASE_URL } from '@/config/env'
import { validateContractDocument, contractDownloadErrorMessage } from '@/utils/contract-document'
const fileName = ref('合同文档')
const message = ref('正在准备文档…')
const busy = ref(false)
const documentInfo = ref(null)
let localPath = ''
let alive = true
let downloadTask
function removeTemp(path) {
if (!path) return
try { uni.getFileSystemManager().unlink({ filePath: path, fail() {} }) } catch {}
}
function goBack() { uni.navigateBack({ delta: 1 }) }
async function openDocument() {
if (busy.value || !documentInfo.value) return
busy.value = true
message.value = '正在下载并打开文档…'
try {
if (!localPath) {
localPath = await new Promise((resolve, reject) => {
downloadTask = uni.downloadFile({
url: documentInfo.value.url,
success: (res) => {
if (!alive) { removeTemp(res.tempFilePath); reject(new Error('已关闭')); return }
if (res.statusCode !== 200) { removeTemp(res.tempFilePath); reject(new Error('文件下载失败,请返回合同重新生成')); return }
resolve(res.tempFilePath)
},
fail: (error) => reject(new Error(contractDownloadErrorMessage(error, documentInfo.value.url)))
})
})
}
if (!alive) return
await new Promise((resolve, reject) => uni.openDocument({
filePath: localPath,
fileType: documentInfo.value.type,
showMenu: true,
success: resolve,
fail: () => reject(new Error('无法打开文档,请重试'))
}))
message.value = '文档已打开,可再次打开或返回继续编辑合同。'
} catch (error) {
if (alive) message.value = error.message || '打开失败,请重试'
} finally { if (alive) busy.value = false }
}
onLoad((query) => {
try {
// uni-app 页面参数按平台可能已解码,最多解码一次完整 URL。
const url = /^https?:\/\//.test(query?.url || '') ? query.url : decodeURIComponent(query?.url || '')
documentInfo.value = validateContractDocument({ ...query, url }, MINIO_BASE_URL)
fileName.value = documentInfo.value.name
try { fileName.value = decodeURIComponent(fileName.value) } catch { /* 参数可能已由宿主解码 */ }
void openDocument()
} catch (error) { message.value = error.message || '无效的文档地址' }
})
onUnload(() => { alive = false; downloadTask?.abort(); removeTemp(localPath) })
</script>
<style scoped>
.contract-document { padding: 24px 16px; display: flex; flex-direction: column; gap: 16px; color: #243b33; }
.contract-document__title { font-size: 18px; font-weight: 600; word-break: break-all; }
.contract-document button { width: 100%; min-height: 44px; color: #0a9f6d; background: #e9f9f2; }
.contract-document__hint { font-size: 13px; color: #6b7b74; line-height: 1.6; }
</style>
@@ -0,0 +1,98 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync } from 'node:fs'
const file = new URL('../utils/contract-document.js', import.meta.url)
test('document links only accept configured storage, PDF/DOCX and safe boundaries', async () => {
assert.ok(existsSync(file), 'document validator exists')
const { validateContractDocument } = await import(file)
const base = 'https://res.example.com/ruoyi'
assert.equal(validateContractDocument({url: base+'/a.pdf?signature=x', type:'pdf'}, base).type, 'pdf')
for (const url of ['https://evil.com/a.pdf', base+'evil/a.pdf', 'https://res.example.com/ruoyi/../secret', 'javascript:alert(1)', 'https://res.example.com@evil.com/ruoyi/a.pdf']) {
assert.throws(() => validateContractDocument({url,type:'pdf'},base))
}
assert.throws(() => validateContractDocument({url:base+'/a.exe',type:'exe'},base))
})
import vm from 'node:vm'
import { readFileSync } from 'node:fs'
async function pageHarness() {
const { validateContractDocument, contractDownloadErrorMessage } = await import(file)
const source = readFileSync(new URL('../pages/common/contract-document.vue', import.meta.url),'utf8')
.split('<script setup>')[1].split('</script>')[0].replace(/^import .*$/gm,'')
const callbacks = {}
const calls = {downloads: [], opens: [], removed: [], aborted:0}
const context = vm.createContext({
ref: value => ({value}),
onLoad: fn => callbacks.load = fn,
onUnload: fn => callbacks.unload = fn,
MINIO_BASE_URL: 'https://res.example.com/ruoyi',
validateContractDocument,
contractDownloadErrorMessage,
uni: {
downloadFile: options => {calls.downloads.push(options); return {abort() {calls.aborted++}}},
openDocument: options => calls.opens.push(options),
getFileSystemManager: () => ({unlink: options => calls.removed.push(options.filePath)}),
navigateBack() {}
}
})
vm.runInContext(source+'\nglobalThis.state = { busy, message, fileName, documentInfo, openDocument };',context)
return {callbacks,calls,state: context.state}
}
const tick = () => new Promise(resolve=>setImmediate(resolve))
test('download, preview/share, retry and cleanup use local files without credentials', async () => {
const h = await pageHarness()
h.callbacks.load({url:'https://res.example.com/ruoyi/a.pdf',type:'pdf',name:'100%合同.pdf'})
assert.equal(h.state.fileName.value,'100%合同.pdf')
assert.equal(h.calls.downloads[0].header,undefined)
h.calls.downloads[0].fail()
await tick()
assert.equal(h.state.busy.value,false)
void h.state.openDocument()
h.calls.downloads[1].success({statusCode:200,tempFilePath:'/tmp/contract.pdf'})
await tick()
assert.equal(h.calls.opens[0].filePath,'/tmp/contract.pdf')
assert.equal(h.calls.opens[0].showMenu,true)
h.calls.opens[0].success()
await tick()
h.callbacks.unload()
assert.deepEqual(h.calls.removed,['/tmp/contract.pdf'])
})
test('unload during download prevents opening and removes late temp files', async () => {
const h=await pageHarness()
h.callbacks.load({url:'https://res.example.com/ruoyi/a.pdf',type:'pdf'})
h.callbacks.unload()
h.calls.downloads[0].success({statusCode:200,tempFilePath:'/tmp/late.pdf'})
await tick()
assert.equal(h.calls.opens.length,0)
assert.deepEqual(h.calls.removed,['/tmp/late.pdf'])
})
test('invalid links never start download', async () => {
const h=await pageHarness()
h.callbacks.load({url:'https://evil.com/a.pdf',type:'pdf'})
assert.equal(h.calls.downloads.length,0)
assert.equal(h.state.documentInfo.value,null)
})
test('registering contract documents preserves the login launch page', () => {
const pages = JSON.parse(readFileSync(new URL('../pages.json',import.meta.url),'utf8').replace(/^\s*\/\/.*$/gm, '')).pages
assert.equal(pages[0].path,'pages/login/login')
assert.equal(pages.filter(p => p.path === 'pages/common/contract-document').length,1)
})
test('native domain rejection identifies the required downloadFile domain', async () => {
const h = await pageHarness()
h.callbacks.load({url:'https://res.example.com/ruoyi/a.pdf',type:'pdf'})
h.calls.downloads[0].fail({errMsg:'downloadFile:fail url not in domain list',errno:600002})
await tick()
assert.match(h.state.message.value, /downloadFile 合法域名/)
assert.match(h.state.message.value, /https:\/\/res\.example\.com/)
assert.equal(h.calls.opens.length, 0)
})
test('native download failures retain diagnostic details without signed URLs', async () => {
const h = await pageHarness()
h.callbacks.load({url:'https://res.example.com/ruoyi/a.pdf',type:'pdf'})
h.calls.downloads[0].fail({errMsg:'downloadFile:fail connection reset https://res.example.com/a.pdf?X-Amz-Signature=secret',errno:600001})
await tick()
assert.match(h.state.message.value, /connection reset/)
assert.match(h.state.message.value, /600001/)
assert.doesNotMatch(h.state.message.value, /secret|X-Amz/)
})
+31
View File
@@ -0,0 +1,31 @@
/** 不依赖小程序中可能不存在的 URL 构造器,不接受任意下载主机。 */
export function validateContractDocument(query, storageBase) {
const url = String(query?.url || '')
const type = String(query?.type || '').toLowerCase()
const base = String(storageBase || '').replace(/\/$/, '')
if (!['pdf', 'docx'].includes(type)) throw new Error('不支持的文档格式')
if (!/^https?:\/\/[^/@?#]+(?:\/[^?#]*)?$/.test(base) || !url.startsWith(base + '/')) {
throw new Error('无效的文档地址')
}
const path = url.split(/[?#]/)[0]
let decoded
try { decoded = decodeURIComponent(path) } catch { throw new Error('无效的文档地址') }
if (/[\\\s\x00-\x1f]/.test(url) || decoded.split('/').some((part) => part === '..' || part === '.')) {
throw new Error('无效的文档地址')
}
return { url, type, name: String(query?.name || `合同.${type}`) }
}
/** 保留微信下载错误用于排查,但不展示含临时签名的文件地址。 */
export function contractDownloadErrorMessage(error, url) {
const raw = String(error?.errMsg || '')
const origin = String(url || '').match(/^https?:\/\/[^/?#]+/)?.[0] || '文件存储域名'
if (/url not in domain list|domain.*not.*allowed|域名.*不.*合法/i.test(raw)) {
return `下载被微信拦截:请管理员将 ${origin} 加入小程序的 downloadFile 合法域名,更新配置后重新进入小程序。`
}
const detail = raw.replace(/https?:\/\/[^\s]+/gi, '[下载地址]')
.replace(/[\r\n\t]/g, ' ').replace(/^downloadFile:fail\s*/i, '').trim().slice(0, 180)
const code = error?.errno ?? error?.errCode
const codeText = Number.isFinite(Number(code)) && code != null ? `(错误码 ${Number(code)})` : ''
return detail ? `下载失败:${detail}${codeText}` : `下载失败${codeText},请检查网络或稍后重试`
}