You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

284 lines
8.6 KiB

  1. const COS = require('cos-nodejs-sdk-v5');
  2. const cosConfig = require('../config/cos.js');
  3. module.exports = class extends think.Service {
  4. /**
  5. * 生成签署合成图
  6. * @param {Object} params
  7. * @param {string} params.title - 协议标题
  8. * @param {string} params.content - 协议富文本内容
  9. * @param {string} params.signImageUrl - 签名图片URL
  10. * @param {string} params.signerName - 签署人姓名
  11. * @param {string} params.signerIdCard - 签署人身份证号
  12. * @param {string} params.signTime - 签署时间
  13. * @param {number} [params.amount] - 收入金额(仅income类型)
  14. * @param {string} [params.guardianName] - 监护人姓名(仅privacy_jhr类型)
  15. * @param {string} [params.guardianIdCard] - 监护人身份证号
  16. * @param {string} [params.guardianRelation] - 与患者关系
  17. * @returns {string} 合成图COS URL
  18. */
  19. async generate({ title, content, signImageUrl, signerName, signerIdCard, signTime, amount, guardianName, guardianIdCard, guardianRelation }) {
  20. const puppeteer = require('puppeteer');
  21. const html = this._buildHtml({ title, content, signImageUrl, signerName, signerIdCard, signTime, amount, guardianName, guardianIdCard, guardianRelation });
  22. let browser;
  23. try {
  24. browser = await puppeteer.launch({
  25. headless: 'new',
  26. args: [
  27. '--no-sandbox',
  28. '--disable-setuid-sandbox',
  29. '--disable-gpu',
  30. '--disable-dev-shm-usage',
  31. '--font-render-hinting=none'
  32. ]
  33. });
  34. const page = await browser.newPage();
  35. await page.setViewport({ width: 750, height: 1000, deviceScaleFactor: 2 });
  36. await page.setContent(html, { waitUntil: 'networkidle0', timeout: 15000 });
  37. // 等签名图片加载
  38. await page.waitForFunction(() => {
  39. const img = document.querySelector('.sign-img');
  40. return img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
  41. }, { timeout: 10000 });
  42. const screenshot = await page.screenshot({ fullPage: true, type: 'png' });
  43. await browser.close();
  44. browser = null;
  45. // 上传到 COS
  46. const url = await this._uploadToCos(screenshot);
  47. return url;
  48. } catch (error) {
  49. if (browser) await browser.close().catch(() => {});
  50. throw error;
  51. }
  52. }
  53. /**
  54. * 构建 HTML 模板
  55. */
  56. _buildHtml({ title, content, signImageUrl, signerName, signerIdCard, signTime, amount, guardianName, guardianIdCard, guardianRelation }) {
  57. const amountHtml = amount ? `
  58. <div style="margin: 30px 0; padding: 20px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
  59. <span style="font-size: 28px; color: #555;">个人年可支配收入:</span>
  60. <span style="font-size: 32px; color: #0e63e3; font-weight: 600;">¥${amount}</span>
  61. </div>` : '';
  62. // 监护人类型:特殊签署区布局
  63. const isGuardian = !!(guardianName && guardianIdCard);
  64. const signAreaHtml = isGuardian ? `
  65. <div class="sign-area">
  66. <div class="sign-row">
  67. <span class="sign-label">监护人签字:</span>
  68. <img class="sign-img" src="${signImageUrl}" />
  69. </div>
  70. <div class="sign-info">患者:${signerName}</div>
  71. <div class="sign-info">患者身份证:${signerIdCard}</div>
  72. <div class="sign-info">监护人姓名:${guardianName}</div>
  73. <div class="sign-info">监护人身份证:${guardianIdCard}</div>
  74. <div class="sign-info">与患者关系:${guardianRelation || ''}</div>
  75. <div class="sign-info">签署时间:${signTime}</div>
  76. </div>` : `
  77. <div class="sign-area">
  78. <div class="sign-row">
  79. <span class="sign-label">签名:</span>
  80. <img class="sign-img" src="${signImageUrl}" />
  81. </div>
  82. <div class="sign-info">签署人:${signerName}</div>
  83. <div class="sign-info">身份证:${signerIdCard}</div>
  84. <div class="sign-info">签署时间:${signTime}</div>
  85. </div>`;
  86. return `<!DOCTYPE html>
  87. <html>
  88. <head>
  89. <meta charset="utf-8">
  90. <style>
  91. * { margin: 0; padding: 0; box-sizing: border-box; }
  92. body {
  93. width: 750px;
  94. padding: 60px;
  95. font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
  96. background: #fff;
  97. color: #333;
  98. }
  99. .title {
  100. text-align: center;
  101. font-size: 36px;
  102. font-weight: bold;
  103. margin-bottom: 40px;
  104. color: #222;
  105. }
  106. .content {
  107. font-size: 28px;
  108. line-height: 2;
  109. color: #444;
  110. margin-bottom: 30px;
  111. }
  112. .content p {
  113. margin-bottom: 16px;
  114. text-indent: 2em;
  115. }
  116. .sign-area {
  117. margin-top: 50px;
  118. border-top: 2px solid #eee;
  119. padding-top: 30px;
  120. }
  121. .sign-row {
  122. display: flex;
  123. align-items: center;
  124. margin-bottom: 20px;
  125. }
  126. .sign-label {
  127. font-size: 26px;
  128. color: #666;
  129. width: 120px;
  130. flex-shrink: 0;
  131. }
  132. .sign-img {
  133. height: 100px;
  134. }
  135. .sign-info {
  136. font-size: 24px;
  137. color: #888;
  138. margin-bottom: 10px;
  139. }
  140. </style>
  141. </head>
  142. <body>
  143. <div class="title">${title}</div>
  144. <div class="content">${content}</div>
  145. ${amountHtml}
  146. ${signAreaHtml}
  147. </body>
  148. </html>`;
  149. }
  150. /**
  151. * 重新生成签署图(保留原图签名区域,替换上方内容)
  152. * 原理:原图做背景 bottom 对齐,白色 overlay 覆盖上方内容区,
  153. * placeholder 撑出签名区高度让背景图底部签名露出
  154. * @param {Object} params
  155. * @param {string} params.originalImageUrl - 原合成图URL
  156. * @param {string} params.title - 新协议标题
  157. * @param {string} params.content - 新协议富文本内容
  158. * @returns {string} 新合成图COS URL
  159. */
  160. async regenerate({ originalImageUrl, title, content }) {
  161. const puppeteer = require('puppeteer');
  162. const html = `<!DOCTYPE html>
  163. <html>
  164. <head>
  165. <meta charset="utf-8">
  166. <style>
  167. * { margin: 0; padding: 0; box-sizing: border-box; }
  168. body {
  169. width: 750px;
  170. overflow: hidden;
  171. font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
  172. background-image: url('${originalImageUrl}');
  173. background-size: 100% auto;
  174. background-repeat: no-repeat;
  175. background-position: bottom center;
  176. position: relative;
  177. }
  178. .overlay {
  179. width: 100%;
  180. background: #fff;
  181. padding: 60px;
  182. }
  183. .title {
  184. text-align: center;
  185. font-size: 36px;
  186. font-weight: bold;
  187. margin-bottom: 40px;
  188. color: #222;
  189. }
  190. .content {
  191. font-size: 28px;
  192. line-height: 2;
  193. color: #444;
  194. }
  195. .content p {
  196. margin-bottom: 16px;
  197. text-indent: 2em;
  198. }
  199. .sign-area-placeholder {
  200. height: 360px;
  201. }
  202. </style>
  203. </head>
  204. <body>
  205. <div class="overlay">
  206. <div class="title">${title}</div>
  207. <div class="content">${content}</div>
  208. </div>
  209. <div class="sign-area-placeholder"></div>
  210. </body>
  211. </html>`;
  212. let browser;
  213. try {
  214. browser = await puppeteer.launch({
  215. headless: 'new',
  216. args: [
  217. '--no-sandbox',
  218. '--disable-setuid-sandbox',
  219. '--disable-gpu',
  220. '--disable-dev-shm-usage',
  221. '--font-render-hinting=none'
  222. ]
  223. });
  224. const page = await browser.newPage();
  225. await page.setViewport({ width: 750, height: 1000, deviceScaleFactor: 2 });
  226. await page.setContent(html, { waitUntil: 'networkidle0', timeout: 15000 });
  227. const screenshot = await page.screenshot({ fullPage: true, type: 'png' });
  228. await browser.close();
  229. browser = null;
  230. const url = await this._uploadToCos(screenshot);
  231. return url;
  232. } catch (error) {
  233. if (browser) await browser.close().catch(() => {});
  234. throw error;
  235. }
  236. }
  237. /**
  238. * 上传截图到 COS
  239. */
  240. async _uploadToCos(buffer) {
  241. const cos = new COS({ SecretId: cosConfig.secretId, SecretKey: cosConfig.secretKey });
  242. const now = new Date();
  243. const dateFolder = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
  244. const fileName = `sign_${Date.now()}.png`;
  245. const cosKey = `signs/${dateFolder}/${fileName}`;
  246. const result = await new Promise((resolve, reject) => {
  247. cos.putObject({
  248. Bucket: cosConfig.bucket,
  249. Region: cosConfig.region,
  250. Key: cosKey,
  251. Body: buffer,
  252. ContentType: 'image/png'
  253. }, (err, data) => {
  254. if (err) reject(err);
  255. else resolve(data);
  256. });
  257. });
  258. if (result.statusCode === 200) {
  259. const bucketUrl = `https://${result.Location}`;
  260. return bucketUrl.replace(cosConfig.bucketUrl, cosConfig.cdnUrl);
  261. }
  262. throw new Error('截图上传COS失败');
  263. }
  264. };