feat:新增存储im图片
This commit is contained in:
parent
2eca9f867c
commit
44ac5d3a02
5
.env.pro
5
.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=
|
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
|
## 海宁his api
|
||||||
CONFIG_HN_HIS_BASE_URL=http://122.225.16.74:8026
|
CONFIG_HN_HIS_BASE_URL=http://122.225.16.74:8026
|
||||||
|
|
||||||
|
## im图片存储服务端的位置
|
||||||
|
CONFIG_IM_IMAGE_ROOT=/opt/apps/private-files/im-chat
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,6 +4,7 @@ node_modules
|
|||||||
|
|
||||||
/test
|
/test
|
||||||
/logs
|
/logs
|
||||||
|
/private-files
|
||||||
|
|
||||||
|
|
||||||
* except index.js
|
* except index.js
|
||||||
|
|||||||
38
README.md
38
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` 查询数据库中归档成功的受控路径。
|
||||||
|
生产使用前建议补充问诊订单权限验证。
|
||||||
412
hlw/im-image-archive/index.js
Normal file
412
hlw/im-image-archive/index.js
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
151
hlw/im-image-archive/index.test.js
Normal file
151
hlw/im-image-archive/index.test.js
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
index.js
30
index.js
@ -41,6 +41,7 @@ const sessionArchive = require("./sessionArchive");
|
|||||||
const customerHisSync = require("./customerHisSync");
|
const customerHisSync = require("./customerHisSync");
|
||||||
const hlw = require("./hlw");
|
const hlw = require("./hlw");
|
||||||
const automation = require("./hlw/automation");
|
const automation = require("./hlw/automation");
|
||||||
|
const imImageArchive = require("./hlw/im-image-archive");
|
||||||
const trigger = require("./trigger");
|
const trigger = require("./trigger");
|
||||||
const consultTrigger = require("./hlw/consult-order/trigger");
|
const consultTrigger = require("./hlw/consult-order/trigger");
|
||||||
const callBack = require("./callBack");
|
const callBack = require("./callBack");
|
||||||
@ -468,17 +469,36 @@ app.post(
|
|||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const requestData = req.body;
|
const requestData = req.body;
|
||||||
const db = await getDatabase("Internet-hospital");
|
const db = await getDatabase("Internet-hospital");
|
||||||
await hlw(
|
const storedMessage = imImageArchive.prepareMessageForArchive(requestData);
|
||||||
|
const result = await hlw(
|
||||||
{
|
{
|
||||||
type: "addChatMsg",
|
type: "addChatMsg",
|
||||||
params: requestData,
|
params: storedMessage,
|
||||||
},
|
},
|
||||||
db
|
db
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
|
result &&
|
||||||
|
result.success &&
|
||||||
|
result.res &&
|
||||||
|
result.res.insertedId &&
|
||||||
|
storedMessage.imageArchive
|
||||||
|
) {
|
||||||
|
imImageArchive.enqueueArchive(db, result.res.insertedId);
|
||||||
|
}
|
||||||
sendSuccess(res, null, "请求成功");
|
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 音视频通话:云端自动录制回调
|
* mPaaS 音视频通话:云端自动录制回调
|
||||||
* - 控制台配置:通话应用 -> 功能配置 -> 配置云端自动录制及录制回调地址
|
* - 控制台配置:通话应用 -> 功能配置 -> 配置云端自动录制及录制回调地址
|
||||||
@ -715,6 +735,12 @@ databaseReadyPromise.then(async (connected) => {
|
|||||||
internetHospitalDb
|
internetHospitalDb
|
||||||
);
|
);
|
||||||
await automation.start(internetHospitalDb);
|
await automation.start(internetHospitalDb);
|
||||||
|
const resumedImageArchives = await imImageArchive.resumePendingArchives(
|
||||||
|
internetHospitalDb
|
||||||
|
);
|
||||||
|
if (resumedImageArchives) {
|
||||||
|
logger.info(`已恢复 ${resumedImageArchives} 个腾讯 IM 图片归档任务`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("恢复问诊延迟任务或启动自动化任务失败:", error);
|
logger.error("恢复问诊延迟任务或启动自动化任务失败:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user