From 44ac5d3a0224a6adca26f3c1053f0be0d829029a Mon Sep 17 00:00:00 2001 From: huxuejian Date: Tue, 25 Aug 2026 17:03:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9E=E5=AD=98=E5=82=A8im?= =?UTF-8?q?=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.pro | 5 +- .gitignore | 1 + README.md | 38 +++ hlw/im-image-archive/index.js | 412 +++++++++++++++++++++++++++++ hlw/im-image-archive/index.test.js | 151 +++++++++++ index.js | 30 ++- 6 files changed, 634 insertions(+), 3 deletions(-) create mode 100644 hlw/im-image-archive/index.js create mode 100644 hlw/im-image-archive/index.test.js diff --git a/.env.pro b/.env.pro index 3150b41..d27fa67 100644 --- a/.env.pro +++ b/.env.pro @@ -16,4 +16,7 @@ CONFIG_ALIPAY_MINI_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo7Ulsw CONFIG_ALIPAY_MINI_PRIVATE_KEY=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCirWNE4gjlUOV6+PNo6CPyLUnJOe/yw8ODZInBun6P2h3rs33WBLFBGdbV9Ct5pXXou48l2icKPsFAWVI5MmtZUIHGBkdvxzQeLNeKpKlJvpujWS/oLQq232MeM7YmWa4tjr40SHqlhVzxcgYST55jBXmVo9KJNrBj5i5zlR9N49x5EJZrUXsVUprsavk1z1JJyUKthw/FrycGsS47YQ5jHyQUFsqEviyOY5Bgz4qZ4xhHJ7p7zhwkiXHqscyeF0+Eu1xf4jS23jlbOiV+5yPo4eWcHElf0aD+k+ngPVpgVqNHMYBTSW1u8pFCpUVDv02QUNg1NRJpBcTAtnLY7yyhAgMBAAECggEAKl54ZV9tq7QnIKEoXGFhw1iSSCuj2BmumVEdI+zbu8G2W0YzdwNf2zHMp4rhq9wxWn1HKjeoRV3U3VqmNKlyFiAGvLmz72P1qgpJ1R1Pzhor1R6MgK8ChGCoIxyay2A+IjV0So+6CdQzQdtNw4UjHm01noi9c+lfhZVU8dub/J/62cGCfcBh/NX+juyI+5Vb9BLnG0CbFZ1SbssK8FN9w6MiKNq/fgIdMyvZXWt9Zoj21Bc9eyxd0EUPp800P6eeThzwNVyeSOETehIDSGwndrbxbLX1nCmievnEYZ/pT6T0J+UuPkdLjZq+VQQry0IxAlVvRim33JS4w99gBYIHcQKBgQDSQsEL5QtSLNg3xs5R3vMG+C8wJbm0eYiPlpSLcFgLxedoxRPdjzeT/Wfi245mA1L8NADRn37gfgPukc5TgWzTV340KzlScAv0Y2HHiQV9HII6b5I/f2S3AsM2IY4jDN+PryBDT5mTYUwW7LhtxC97LqCozPhY6aPozoD6VKBhFQKBgQDGEMAcrPSmXNjxhg/1lPJoe7fe4A3uyLPfHIVqZTFSxrfzwteL3bMuhsx8eSNnmT35trQfKXYT8Pz85IWGcoBPuhDkrjDQy+w5u4QeRcyJQRyUqtDxruIwNbvN/EL6FME24u6bIng6FjHW+0j2SFrFIWpUo1v3B5mSRGzvTWZIXQKBgAXI1lzIaV5SY4RtUMHJ22/Gpi2x7AD+Eva4Us/fQFzajfpA6cH/razBnPkNx0YhM0hC+f7KX4g3j04h7sY3WlIaJxuyULpm4L1ReqAtsdv2S9LlvOVIUPID/nDfzXRDZ2chj0aqyvPWCmzbCFvkuNWvu7XJJc/0ZZEDFWNNrrfdAoGAB/2dAosnw55zUisqcan0rapwHJnFdiC6wQGRkF1MxB4oRblSoN3s4K+niE4K058nFO/+DFPL47oROtKtYGM2MgE7tT4h0gqoGRwrlF0Uh2UNFiJeE6IOlgtU7FaFDTnRIbm+DpwxZYaHtDinFOiOYO2cMJX2m9y+oVwJWQ4yVjECgYEAg01aEYSOYQVeeub8g1e1wHKj3L4v/xECpZNzoQWF/WhZDHiKCqN2VD54XYl0dUHFLqBXn01tfg0gt4kO/roiWJdH273DBYu52ttwNdcRVlud6RlTBDO//M8dglrxOWz51HhSTepOXmJflF1dept9OPVddzQAJdFN65umF27DIVI= ## 海宁his api -CONFIG_HN_HIS_BASE_URL=http://122.225.16.74:8026 \ No newline at end of file +CONFIG_HN_HIS_BASE_URL=http://122.225.16.74:8026 + +## im图片存储服务端的位置 +CONFIG_IM_IMAGE_ROOT=/opt/apps/private-files/im-chat \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0f18041..c609e49 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules /test /logs +/private-files * except index.js diff --git a/README.md b/README.md index e69de29..669589c 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,38 @@ +# 腾讯 IM 图片本地归档 + +腾讯 IM 的图片消息在 `/IMCallBack` 入库后,会异步下载 `TIMImageElem` +中的 `Type=1` 原图并保存到服务器私有目录。归档状态保存在 +`Internet-hospital.im-chat-msg.imageArchive`。 + +可选环境变量: + +```ini +# 建议在生产环境显式配置为代码目录之外、具备持久化和备份的绝对路径 +CONFIG_IM_IMAGE_ROOT=/data/hn-private-files/im-chat + +# 默认 30MB +CONFIG_IM_IMAGE_MAX_BYTES=31457280 + +# 逗号分隔的腾讯图片域名后缀 +CONFIG_IM_IMAGE_ALLOWED_HOSTS=.my-imcloud.com,.im.qcloud.com,.myqcloud.com +``` + +未设置 `CONFIG_IM_IMAGE_ROOT` 时,路径规则与现有 `/upload` 一致:以服务入口 +目录为基准,保存到 `../private-files/im-chat`。例如: + +- 源码从 `hn-hlw-service/index.js` 运行时,保存到项目组目录下的 + `private-files/im-chat`,与现有 `upload-files` 同级。 +- bundle 位于 `/opt/apps/hnhlw/bundle_xx.js` 时,保存到 + `/opt/apps/private-files/im-chat`,与 `/opt/apps/upload-files` 同级。 + +`private-files` 不会通过 Express 静态目录公开。该目录不能在发布时被清理; +Docker 部署时必须挂载持久化卷。 + +归档成功后可通过下面的接口读取图片: + +```text +GET /im-chat/image/:msgId +``` + +当前接口按需求暂未启用 JWT,但仅允许通过 `MsgId` 查询数据库中归档成功的受控路径。 +生产使用前建议补充问诊订单权限验证。 diff --git a/hlw/im-image-archive/index.js b/hlw/im-image-archive/index.js new file mode 100644 index 0000000..7f013d8 --- /dev/null +++ b/hlw/im-image-archive/index.js @@ -0,0 +1,412 @@ +const axios = require("axios"); +const crypto = require("crypto"); +const fs = require("fs"); +const fsp = require("fs/promises"); +const path = require("path"); +const { Transform } = require("stream"); +const { pipeline } = require("stream/promises"); +const logger = require("../../utils/logger"); + +const DEFAULT_MAX_FILE_SIZE = 30 * 1024 * 1024; +const RETRY_DELAYS = [60 * 1000, 5 * 60 * 1000, 15 * 60 * 1000]; + +function resolveDefaultImageRoot( + moduleDirectory = __dirname, + isBuilt = process.env.IS_BUILDED === "YES" +) { + // 源码运行时 __dirname 在 hlw/im-image-archive,需要先回到服务根目录; + // esbuild 打包后所有代码共用 bundle 所在目录,可直接将其作为服务根目录。 + const serviceRoot = isBuilt + ? moduleDirectory + : path.resolve(moduleDirectory, "../.."); + return path.resolve(serviceRoot, "../private-files/im-chat"); +} + +function getImageRoot() { + return path.resolve( + process.env.CONFIG_IM_IMAGE_ROOT || resolveDefaultImageRoot() + ); +} + +function getMaxFileSize() { + const configured = Number(process.env.CONFIG_IM_IMAGE_MAX_BYTES); + return Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_MAX_FILE_SIZE; +} + +function getAllowedHostSuffixes() { + return (process.env.CONFIG_IM_IMAGE_ALLOWED_HOSTS || + ".my-imcloud.com,.im.qcloud.com,.myqcloud.com") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +function findOriginalImage(message) { + if (!message || !Array.isArray(message.MsgBody)) return null; + + for (const element of message.MsgBody) { + if (element && element.MsgType === "TIMImageElem") { + const content = element.MsgContent || {}; + const imageList = Array.isArray(content.ImageInfoArray) + ? content.ImageInfoArray + : []; + const image = + imageList.find((item) => Number(item && item.Type) === 1 && item.URL) || + imageList.find((item) => item && item.URL); + if (image) { + return { + uuid: String(content.UUID || ""), + imageFormat: content.ImageFormat, + type: Number(image.Type) || 1, + url: String(image.URL), + expectedSize: Number(image.Size) || 0, + width: Number(image.Width) || 0, + height: Number(image.Height) || 0, + }; + } + } + } + + return null; +} + +function prepareMessageForArchive(message) { + const source = findOriginalImage(message); + if (!source) return message; + + const now = Date.now(); + return { + ...message, + imageArchive: { + status: "pending", + sourceUUID: source.uuid, + sourceType: source.type, + sourceURL: source.url, + expectedSize: source.expectedSize, + width: source.width, + height: source.height, + relativePath: "", + size: 0, + mimeType: "", + sha256: "", + retryCount: 0, + lastError: "", + createdAt: now, + updatedAt: now, + archivedAt: null, + }, + }; +} + +function isAllowedSourceUrl(value) { + let parsed; + try { + parsed = new URL(value); + } catch (_error) { + return false; + } + + if (parsed.protocol !== "https:") return false; + const hostname = parsed.hostname.toLowerCase(); + return getAllowedHostSuffixes().some((suffix) => { + const normalized = suffix.startsWith(".") ? suffix : `.${suffix}`; + return hostname === normalized.slice(1) || hostname.endsWith(normalized); + }); +} + +function safePathPart(value, fallback) { + const normalized = String(value || "") + .replace(/[^a-zA-Z0-9._-]/g, "_") + .slice(0, 160); + return normalized || fallback; +} + +function getMessageDate(message) { + const seconds = Number(message && message.MsgTime); + const date = Number.isFinite(seconds) && seconds > 0 + ? new Date(seconds * 1000) + : new Date(); + return Number.isNaN(date.getTime()) ? new Date() : date; +} + +function detectImageType(buffer) { + if (!buffer || buffer.length < 4) return null; + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return { extension: ".jpg", mimeType: "image/jpeg" }; + } + if (buffer.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) { + return { extension: ".png", mimeType: "image/png" }; + } + const signature = buffer.subarray(0, 6).toString("ascii"); + if (signature === "GIF87a" || signature === "GIF89a") { + return { extension: ".gif", mimeType: "image/gif" }; + } + if (buffer.subarray(0, 2).toString("ascii") === "BM") { + return { extension: ".bmp", mimeType: "image/bmp" }; + } + if ( + buffer.subarray(0, 4).toString("ascii") === "RIFF" && + buffer.subarray(8, 12).toString("ascii") === "WEBP" + ) { + return { extension: ".webp", mimeType: "image/webp" }; + } + if (buffer.length >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp") { + const brand = buffer.subarray(8, 12).toString("ascii").toLowerCase(); + if (["heic", "heix", "hevc", "hevx", "mif1", "msf1"].includes(brand)) { + return { extension: ".heic", mimeType: "image/heic" }; + } + if (["avif", "avis"].includes(brand)) { + return { extension: ".avif", mimeType: "image/avif" }; + } + } + return null; +} + +async function downloadImage(sourceUrl, directory, fileBase) { + if (!isAllowedSourceUrl(sourceUrl)) { + throw new Error("图片下载地址不是允许的腾讯 IM HTTPS 域名"); + } + + await fsp.mkdir(directory, { recursive: true }); + const temporaryPath = path.join( + directory, + `${fileBase}.${process.pid}.${Date.now()}.part` + ); + const maxFileSize = getMaxFileSize(); + let downloadedSize = 0; + const hash = crypto.createHash("sha256"); + + try { + const response = await axios.get(sourceUrl, { + responseType: "stream", + timeout: 20000, + maxRedirects: 0, + validateStatus: (status) => status === 200, + }); + + const contentLength = Number(response.headers["content-length"] || 0); + if (contentLength > maxFileSize) { + response.data.destroy(); + throw new Error(`图片超过最大限制 ${maxFileSize} 字节`); + } + + const limiter = new Transform({ + transform(chunk, _encoding, callback) { + downloadedSize += chunk.length; + if (downloadedSize > maxFileSize) { + callback(new Error(`图片超过最大限制 ${maxFileSize} 字节`)); + return; + } + hash.update(chunk); + callback(null, chunk); + }, + }); + + await pipeline( + response.data, + limiter, + fs.createWriteStream(temporaryPath, { flags: "wx" }) + ); + + const fileHandle = await fsp.open(temporaryPath, "r"); + const header = Buffer.alloc(16); + try { + await fileHandle.read(header, 0, header.length, 0); + } finally { + await fileHandle.close(); + } + + const imageType = detectImageType(header); + if (!imageType) { + throw new Error("下载内容不是支持的图片格式"); + } + + const finalPath = path.join(directory, `${fileBase}${imageType.extension}`); + try { + await fsp.access(finalPath, fs.constants.F_OK); + await fsp.unlink(temporaryPath); + } catch (_notFound) { + await fsp.rename(temporaryPath, finalPath); + } + + return { + filePath: finalPath, + size: downloadedSize, + mimeType: imageType.mimeType, + sha256: hash.digest("hex"), + }; + } catch (error) { + await fsp.unlink(temporaryPath).catch(() => {}); + throw error; + } +} + +async function archiveDocument(db, documentId) { + const collection = db.collection("im-chat-msg"); + const document = await collection.findOne({ _id: documentId }); + if (!document || !document.imageArchive) return; + if (document.imageArchive.status === "success") return; + + const claimed = await collection.updateOne( + { + _id: documentId, + "imageArchive.status": { $in: ["pending", "retry", "processing"] }, + }, + { + $set: { + "imageArchive.status": "processing", + "imageArchive.updatedAt": Date.now(), + }, + } + ); + if (!claimed.modifiedCount) return; + + try { + const date = getMessageDate(document); + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const msgId = safePathPart(document.MsgId, String(documentId)); + const fileBase = crypto + .createHash("sha256") + .update(document.imageArchive.sourceUUID || msgId) + .digest("hex"); + const directory = path.join(getImageRoot(), year, month, day, msgId); + const result = await downloadImage( + document.imageArchive.sourceURL, + directory, + fileBase + ); + const relativePath = path + .relative(getImageRoot(), result.filePath) + .replace(/\\/g, "/"); + + await collection.updateOne( + { _id: documentId }, + { + $set: { + "imageArchive.status": "success", + "imageArchive.relativePath": relativePath, + "imageArchive.size": result.size, + "imageArchive.mimeType": result.mimeType, + "imageArchive.sha256": result.sha256, + "imageArchive.archivedAt": Date.now(), + "imageArchive.updatedAt": Date.now(), + "imageArchive.lastError": "", + }, + } + ); + } catch (error) { + await collection.updateOne( + { _id: documentId }, + { + $set: { + "imageArchive.status": "retry", + "imageArchive.updatedAt": Date.now(), + "imageArchive.lastError": String(error.message || error).slice(0, 500), + }, + $inc: { "imageArchive.retryCount": 1 }, + } + ); + throw error; + } +} + +function enqueueArchive(db, documentId, attempt = 0) { + const run = () => { + archiveDocument(db, documentId).catch((error) => { + logger.error("腾讯 IM 图片归档失败", { + documentId: String(documentId), + attempt: attempt + 1, + error: error.message, + }); + if (attempt < RETRY_DELAYS.length) { + setTimeout( + () => enqueueArchive(db, documentId, attempt + 1), + RETRY_DELAYS[attempt] + ); + } else { + db.collection("im-chat-msg") + .updateOne( + { _id: documentId, "imageArchive.status": "retry" }, + { + $set: { + "imageArchive.status": "failed", + "imageArchive.updatedAt": Date.now(), + }, + } + ) + .catch((updateError) => { + logger.error("更新腾讯 IM 图片归档最终失败状态失败", updateError); + }); + } + }); + }; + setImmediate(run); +} + +async function resumePendingArchives(db) { + const documents = await db + .collection("im-chat-msg") + .find( + { "imageArchive.status": { $in: ["pending", "retry", "processing"] } }, + { projection: { _id: 1 } } + ) + .limit(500) + .toArray(); + documents.forEach((document) => enqueueArchive(db, document._id)); + return documents.length; +} + +async function sendArchivedImage(req, res, db) { + const document = await db.collection("im-chat-msg").findOne( + { MsgId: req.params.msgId, "imageArchive.status": "success" }, + { projection: { imageArchive: 1 } } + ); + if (!document || !document.imageArchive || !document.imageArchive.relativePath) { + res.status(404).json({ success: false, message: "图片不存在或尚未归档" }); + return; + } + + const root = getImageRoot(); + const filePath = path.resolve(root, document.imageArchive.relativePath); + if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) { + res.status(400).json({ success: false, message: "图片路径无效" }); + return; + } + + try { + await fsp.access(filePath, fs.constants.R_OK); + } catch (_error) { + res.status(404).json({ success: false, message: "图片文件不存在" }); + return; + } + + res.setHeader( + "Content-Type", + document.imageArchive.mimeType || "application/octet-stream" + ); + res.setHeader("Content-Disposition", "inline"); + res.setHeader("Cache-Control", "private, max-age=3600"); + await new Promise((resolve, reject) => { + res.sendFile(filePath, (error) => (error ? reject(error) : resolve())); + }); +} + +module.exports = { + findOriginalImage, + prepareMessageForArchive, + enqueueArchive, + resumePendingArchives, + sendArchivedImage, + // Exported for focused unit tests. + _test: { + archiveDocument, + detectImageType, + isAllowedSourceUrl, + getImageRoot, + resolveDefaultImageRoot, + }, +}; diff --git a/hlw/im-image-archive/index.test.js b/hlw/im-image-archive/index.test.js new file mode 100644 index 0000000..6e71ed7 --- /dev/null +++ b/hlw/im-image-archive/index.test.js @@ -0,0 +1,151 @@ +jest.mock("axios", () => ({ + get: jest.fn(), +})); +jest.mock("../../utils/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +const fs = require("fs"); +const fsp = require("fs/promises"); +const os = require("os"); +const path = require("path"); +const { Readable } = require("stream"); +const axios = require("axios"); +const archive = require("./index"); + +function setNested(target, dottedKey, value) { + const keys = dottedKey.split("."); + let current = target; + for (let index = 0; index < keys.length - 1; index += 1) { + current[keys[index]] = current[keys[index]] || {}; + current = current[keys[index]]; + } + current[keys[keys.length - 1]] = value; +} + +function createDatabase(document) { + const collection = { + findOne: jest.fn(async (query) => { + if (query._id !== undefined && query._id !== document._id) return null; + return document; + }), + updateOne: jest.fn(async (_query, update) => { + Object.entries(update.$set || {}).forEach(([key, value]) => { + setNested(document, key, value); + }); + Object.entries(update.$inc || {}).forEach(([key, value]) => { + const current = key.split(".").reduce((result, part) => result[part], document); + setNested(document, key, current + value); + }); + return { modifiedCount: 1 }; + }), + }; + return { + collection: jest.fn(() => collection), + }; +} + +describe("IM image archive", () => { + let temporaryRoot; + + beforeEach(async () => { + jest.clearAllMocks(); + temporaryRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "im-image-archive-")); + process.env.CONFIG_IM_IMAGE_ROOT = temporaryRoot; + delete process.env.CONFIG_IM_IMAGE_ALLOWED_HOSTS; + }); + + afterEach(async () => { + delete process.env.CONFIG_IM_IMAGE_ROOT; + await fsp.rm(temporaryRoot, { recursive: true, force: true }); + }); + + test("prepares the original image from a Tencent callback", () => { + const message = { + MsgBody: [ + { + MsgType: "TIMImageElem", + MsgContent: { + UUID: "image-uuid", + ImageInfoArray: [ + { Type: 3, URL: "https://example.my-imcloud.com/thumb" }, + { Type: 1, URL: "https://example.my-imcloud.com/original", Size: 42 }, + ], + }, + }, + ], + }; + + const result = archive.prepareMessageForArchive(message); + + expect(result.imageArchive).toMatchObject({ + status: "pending", + sourceUUID: "image-uuid", + sourceType: 1, + sourceURL: "https://example.my-imcloud.com/original", + expectedSize: 42, + }); + }); + + test("rejects non-Tencent and non-HTTPS source URLs", () => { + expect(archive._test.isAllowedSourceUrl("https://a.my-imcloud.com/image")).toBe(true); + expect(archive._test.isAllowedSourceUrl("http://a.my-imcloud.com/image")).toBe(false); + expect(archive._test.isAllowedSourceUrl("https://my-imcloud.com.example.com/image")).toBe(false); + }); + + test("uses the same ../private-files rule in source and bundled runtime", () => { + expect( + archive._test.resolveDefaultImageRoot( + path.join("/workspace", "hn-hlw-service", "hlw", "im-image-archive"), + false + ) + ).toBe(path.resolve("/workspace", "private-files", "im-chat")); + expect( + archive._test.resolveDefaultImageRoot("/opt/apps/hnhlw", true) + ).toBe(path.resolve("/opt/apps", "private-files", "im-chat")); + }); + + test("downloads an image and marks the database record successful", async () => { + const jpeg = Buffer.concat([ + Buffer.from([0xff, 0xd8, 0xff, 0xe0]), + Buffer.alloc(64, 1), + ]); + axios.get.mockResolvedValue({ + headers: { "content-length": String(jpeg.length) }, + data: Readable.from([jpeg]), + }); + const message = archive.prepareMessageForArchive({ + _id: "document-1", + MsgId: "message-1", + MsgTime: 1786013773, + MsgBody: [ + { + MsgType: "TIMImageElem", + MsgContent: { + UUID: "image-uuid", + ImageInfoArray: [ + { + Type: 1, + URL: "https://a.my-imcloud.com/original", + Size: jpeg.length, + }, + ], + }, + }, + ], + }); + const db = createDatabase(message); + + await archive._test.archiveDocument(db, message._id); + + expect(message.imageArchive.status).toBe("success"); + expect(message.imageArchive.mimeType).toBe("image/jpeg"); + expect(message.imageArchive.size).toBe(jpeg.length); + expect(message.imageArchive.relativePath).toMatch(/\.jpg$/); + expect( + fs.existsSync(path.resolve(temporaryRoot, message.imageArchive.relativePath)) + ).toBe(true); + }); +}); diff --git a/index.js b/index.js index fb38043..c6a0fa9 100644 --- a/index.js +++ b/index.js @@ -41,6 +41,7 @@ const sessionArchive = require("./sessionArchive"); const customerHisSync = require("./customerHisSync"); const hlw = require("./hlw"); const automation = require("./hlw/automation"); +const imImageArchive = require("./hlw/im-image-archive"); const trigger = require("./trigger"); const consultTrigger = require("./hlw/consult-order/trigger"); const callBack = require("./callBack"); @@ -468,17 +469,36 @@ app.post( asyncHandler(async (req, res) => { const requestData = req.body; const db = await getDatabase("Internet-hospital"); - await hlw( + const storedMessage = imImageArchive.prepareMessageForArchive(requestData); + const result = await hlw( { type: "addChatMsg", - params: requestData, + params: storedMessage, }, db ); + if ( + result && + result.success && + result.res && + result.res.insertedId && + storedMessage.imageArchive + ) { + imImageArchive.enqueueArchive(db, result.res.insertedId); + } sendSuccess(res, null, "请求成功"); }) ); +// 暂不校验 JWT;接口只接受 MsgId,并从数据库中解析受控的私有文件路径。 +app.get( + "/im-chat/image/:msgId", + asyncHandler(async (req, res) => { + const db = await getDatabase("Internet-hospital"); + await imImageArchive.sendArchivedImage(req, res, db); + }) +); + /** * mPaaS 音视频通话:云端自动录制回调 * - 控制台配置:通话应用 -> 功能配置 -> 配置云端自动录制及录制回调地址 @@ -715,6 +735,12 @@ databaseReadyPromise.then(async (connected) => { internetHospitalDb ); await automation.start(internetHospitalDb); + const resumedImageArchives = await imImageArchive.resumePendingArchives( + internetHospitalDb + ); + if (resumedImageArchives) { + logger.info(`已恢复 ${resumedImageArchives} 个腾讯 IM 图片归档任务`); + } } catch (error) { logger.error("恢复问诊延迟任务或启动自动化任务失败:", error); }