2026-06-26 15:19:03 +08:00
|
|
|
|
import crypto from 'node:crypto';
|
|
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
|
|
import { fetch as undiciFetch } from 'undici';
|
2026-07-21 23:21:44 +08:00
|
|
|
|
import sharp from 'sharp';
|
2026-06-26 15:19:03 +08:00
|
|
|
|
import { buildPublicUrl, PUBLISH_ROOT_DIR, PUBLIC_ZONE_DIR } from './user-publish.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_WECHAT_MEDIA_URL = 'https://api.weixin.qq.com/cgi-bin/media/get';
|
2026-07-21 23:21:44 +08:00
|
|
|
|
const DEFAULT_WECHAT_MEDIA_UPLOAD_URL = 'https://api.weixin.qq.com/cgi-bin/media/upload';
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
2026-07-21 23:21:44 +08:00
|
|
|
|
const DEFAULT_MAX_OUTBOUND_IMAGE_BYTES = 2 * 1024 * 1024;
|
2026-07-18 17:41:32 +08:00
|
|
|
|
const DEFAULT_MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024;
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const ALLOWED_IMAGE_MIME_TYPES = new Map([
|
|
|
|
|
|
['image/jpeg', 'jpg'],
|
|
|
|
|
|
['image/png', 'png'],
|
|
|
|
|
|
['image/webp', 'webp'],
|
|
|
|
|
|
['image/gif', 'gif'],
|
|
|
|
|
|
]);
|
2026-07-18 17:41:32 +08:00
|
|
|
|
const ALLOWED_ATTACHMENT_EXTENSIONS = new Map([
|
|
|
|
|
|
['.doc', 'application/msword'],
|
|
|
|
|
|
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
|
|
|
|
|
['.xls', 'application/vnd.ms-excel'],
|
|
|
|
|
|
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
|
|
|
|
|
]);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
|
|
|
|
|
|
function resolveImageExtension(contentType = '', fallbackUrl = '') {
|
|
|
|
|
|
const normalized = String(contentType ?? '')
|
|
|
|
|
|
.split(';')[0]
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
|
if (ALLOWED_IMAGE_MIME_TYPES.has(normalized)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
mimeType: normalized,
|
|
|
|
|
|
extension: ALLOWED_IMAGE_MIME_TYPES.get(normalized),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
const ext = path.extname(String(fallbackUrl ?? '').split('?')[0]).replace(/^\./, '').toLowerCase();
|
|
|
|
|
|
if (ext === 'jpg' || ext === 'jpeg') return { mimeType: 'image/jpeg', extension: 'jpg' };
|
|
|
|
|
|
if (ext === 'png') return { mimeType: 'image/png', extension: 'png' };
|
|
|
|
|
|
if (ext === 'webp') return { mimeType: 'image/webp', extension: 'webp' };
|
|
|
|
|
|
if (ext === 'gif') return { mimeType: 'image/gif', extension: 'gif' };
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ensureImageWithinLimit(buffer, maxBytes) {
|
|
|
|
|
|
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
|
|
|
|
|
throw new Error('微信图片内容为空');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (buffer.length > maxBytes) {
|
|
|
|
|
|
throw new Error(`图片超过大小限制(${maxBytes} bytes)`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 17:41:32 +08:00
|
|
|
|
function sanitizeAttachmentFilename(filename = '') {
|
|
|
|
|
|
const basename = path.basename(String(filename ?? '').trim()).replace(/[\u0000-\u001f\u007f]/g, '');
|
|
|
|
|
|
if (!basename || basename === '.' || basename === '..') {
|
|
|
|
|
|
throw new Error('微信文件缺少有效文件名');
|
|
|
|
|
|
}
|
|
|
|
|
|
const extension = path.extname(basename).toLowerCase();
|
|
|
|
|
|
const mimeType = ALLOWED_ATTACHMENT_EXTENSIONS.get(extension);
|
|
|
|
|
|
if (!mimeType) {
|
|
|
|
|
|
throw new Error('当前服务号文件仅支持 Word(doc/docx)和 Excel(xls/xlsx)');
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
filename: basename.slice(0, 160),
|
|
|
|
|
|
extension,
|
|
|
|
|
|
mimeType,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ensureAttachmentWithinLimit(buffer, maxBytes) {
|
|
|
|
|
|
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
|
|
|
|
|
throw new Error('微信文件内容为空');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (buffer.length > maxBytes) {
|
|
|
|
|
|
throw new Error(`文件超过大小限制(${maxBytes} bytes)`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
export async function downloadTemporaryMedia(accessToken, mediaId, { wechatFetch = undiciFetch } = {}) {
|
|
|
|
|
|
if (!accessToken) throw new Error('缺少微信 access_token');
|
|
|
|
|
|
if (!mediaId) throw new Error('缺少微信 mediaId');
|
|
|
|
|
|
const url = new URL(DEFAULT_WECHAT_MEDIA_URL);
|
|
|
|
|
|
url.searchParams.set('access_token', accessToken);
|
|
|
|
|
|
url.searchParams.set('media_id', mediaId);
|
|
|
|
|
|
const response = await wechatFetch(url.toString(), {
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
headers: { Accept: '*/*' },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const text = await response.text().catch(() => '');
|
|
|
|
|
|
throw new Error(text || `微信临时素材下载失败 (${response.status})`);
|
|
|
|
|
|
}
|
|
|
|
|
|
const contentType = response.headers.get('content-type') || '';
|
|
|
|
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
|
|
|
|
return { buffer, contentType };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 23:21:44 +08:00
|
|
|
|
async function normalizeWechatOutboundImage(buffer, maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES) {
|
|
|
|
|
|
ensureImageWithinLimit(buffer, DEFAULT_MAX_IMAGE_BYTES * 2);
|
|
|
|
|
|
const attempts = [
|
|
|
|
|
|
{ width: 2048, quality: 86 },
|
|
|
|
|
|
{ width: 1600, quality: 74 },
|
|
|
|
|
|
{ width: 1280, quality: 62 },
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const attempt of attempts) {
|
|
|
|
|
|
const normalized = await sharp(buffer, { sequentialRead: true })
|
|
|
|
|
|
.rotate()
|
|
|
|
|
|
.resize({
|
|
|
|
|
|
width: attempt.width,
|
|
|
|
|
|
height: attempt.width,
|
|
|
|
|
|
fit: 'inside',
|
|
|
|
|
|
withoutEnlargement: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
.flatten({ background: '#ffffff' })
|
|
|
|
|
|
.jpeg({ quality: attempt.quality, progressive: true, mozjpeg: true })
|
|
|
|
|
|
.toBuffer();
|
|
|
|
|
|
if (normalized.length <= maxBytes) {
|
|
|
|
|
|
return { buffer: normalized, contentType: 'image/jpeg', filename: 'memind-generated.jpg' };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`生成图片压缩后仍超过微信图片限制(${maxBytes} bytes)`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function uploadWechatGeneratedImage(
|
|
|
|
|
|
accessToken,
|
|
|
|
|
|
publicUrl,
|
|
|
|
|
|
{
|
|
|
|
|
|
wechatFetch = undiciFetch,
|
|
|
|
|
|
publicBaseUrl = '',
|
2026-07-21 23:57:01 +08:00
|
|
|
|
allowedPublicBaseUrls = [],
|
2026-07-21 23:21:44 +08:00
|
|
|
|
uploadUrl = DEFAULT_WECHAT_MEDIA_UPLOAD_URL,
|
|
|
|
|
|
maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES,
|
|
|
|
|
|
} = {},
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (!accessToken) throw new Error('缺少微信 access_token');
|
|
|
|
|
|
if (!publicUrl) throw new Error('缺少生成图片公网地址');
|
|
|
|
|
|
const resolvedUrl = new URL(String(publicUrl), publicBaseUrl || undefined).toString();
|
2026-07-21 23:57:01 +08:00
|
|
|
|
const allowedOrigins = new Set();
|
|
|
|
|
|
for (const baseUrl of [publicBaseUrl, ...allowedPublicBaseUrls]) {
|
|
|
|
|
|
if (!baseUrl) continue;
|
|
|
|
|
|
try {
|
|
|
|
|
|
allowedOrigins.add(new URL(String(baseUrl)).origin);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Ignore invalid optional bases; at least one valid configured origin is required below.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (allowedOrigins.size > 0 && !allowedOrigins.has(new URL(resolvedUrl).origin)) {
|
|
|
|
|
|
throw new Error('生成图片地址不属于当前 MindSpace 可信公网域名');
|
2026-07-21 23:21:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
const sourceResponse = await wechatFetch(resolvedUrl, {
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
headers: { Accept: 'image/*,*/*;q=0.8' },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!sourceResponse.ok) {
|
|
|
|
|
|
const text = await sourceResponse.text().catch(() => '');
|
|
|
|
|
|
throw new Error(text || `生成图片下载失败 (${sourceResponse.status})`);
|
|
|
|
|
|
}
|
|
|
|
|
|
const declaredBytes = Number(sourceResponse.headers.get('content-length') ?? 0);
|
|
|
|
|
|
if (Number.isFinite(declaredBytes) && declaredBytes > DEFAULT_MAX_IMAGE_BYTES * 2) {
|
|
|
|
|
|
throw new Error('生成图片下载体积超过安全限制');
|
|
|
|
|
|
}
|
|
|
|
|
|
const sourceBuffer = Buffer.from(await sourceResponse.arrayBuffer());
|
|
|
|
|
|
const normalized = await normalizeWechatOutboundImage(sourceBuffer, maxBytes);
|
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
|
form.append(
|
|
|
|
|
|
'media',
|
|
|
|
|
|
new Blob([normalized.buffer], { type: normalized.contentType }),
|
|
|
|
|
|
normalized.filename,
|
|
|
|
|
|
);
|
|
|
|
|
|
const endpoint = new URL(uploadUrl);
|
|
|
|
|
|
endpoint.searchParams.set('access_token', accessToken);
|
|
|
|
|
|
endpoint.searchParams.set('type', 'image');
|
|
|
|
|
|
const response = await wechatFetch(endpoint.toString(), {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: form,
|
|
|
|
|
|
});
|
|
|
|
|
|
const text = await response.text().catch(() => '');
|
|
|
|
|
|
let payload = {};
|
|
|
|
|
|
try {
|
|
|
|
|
|
payload = text ? JSON.parse(text) : {};
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
payload = {};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!response.ok || Number(payload?.errcode ?? 0) !== 0 || !String(payload?.media_id ?? '').trim()) {
|
|
|
|
|
|
const detail = String(payload?.errmsg ?? text ?? '').trim() || `HTTP ${response.status}`;
|
|
|
|
|
|
throw new Error(`微信生成图片素材上传失败:${detail}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
mediaId: String(payload.media_id),
|
|
|
|
|
|
bytes: normalized.buffer.length,
|
|
|
|
|
|
contentType: normalized.contentType,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
export function buildWechatImagePublicUrl({
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
publishKey,
|
|
|
|
|
|
filename,
|
|
|
|
|
|
publicBasePath = `${PUBLIC_ZONE_DIR}/wechat-mp`,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (!publicBaseUrl) throw new Error('缺少图片公网基础地址');
|
|
|
|
|
|
if (!publishKey) throw new Error('缺少用户 publish key');
|
|
|
|
|
|
if (!filename) throw new Error('缺少图片文件名');
|
|
|
|
|
|
return buildPublicUrl(publicBaseUrl, publishKey, `${publicBasePath}/${filename}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function persistWechatImage(
|
|
|
|
|
|
{
|
|
|
|
|
|
userId,
|
|
|
|
|
|
appId,
|
|
|
|
|
|
openid,
|
|
|
|
|
|
msgId,
|
|
|
|
|
|
mediaId,
|
|
|
|
|
|
picUrl,
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
maxImageBytes = DEFAULT_MAX_IMAGE_BYTES,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
wechatFetch = undiciFetch,
|
|
|
|
|
|
accessToken,
|
|
|
|
|
|
h5Root = __dirname,
|
|
|
|
|
|
} = {},
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (!userId) throw new Error('缺少 userId');
|
|
|
|
|
|
const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, 'wechat-mp');
|
|
|
|
|
|
fs.mkdirSync(publishDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
|
|
let source = 'wechat_media';
|
|
|
|
|
|
let buffer;
|
|
|
|
|
|
let contentType = '';
|
|
|
|
|
|
|
|
|
|
|
|
if (mediaId && accessToken) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch });
|
|
|
|
|
|
buffer = downloaded.buffer;
|
|
|
|
|
|
contentType = downloaded.contentType;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (!picUrl) throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!buffer && picUrl) {
|
|
|
|
|
|
source = 'wechat_pic_url';
|
|
|
|
|
|
const response = await wechatFetch(picUrl, {
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
headers: { Accept: 'image/*,*/*;q=0.8' },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const text = await response.text().catch(() => '');
|
|
|
|
|
|
throw new Error(text || `微信图片下载失败 (${response.status})`);
|
|
|
|
|
|
}
|
|
|
|
|
|
contentType = response.headers.get('content-type') || contentType;
|
|
|
|
|
|
buffer = Buffer.from(await response.arrayBuffer());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ensureImageWithinLimit(buffer, maxImageBytes);
|
|
|
|
|
|
const resolved = resolveImageExtension(contentType, picUrl);
|
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
|
throw new Error(`暂不支持的图片类型:${contentType || 'unknown'}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const timestamp = Date.now();
|
|
|
|
|
|
const hash = crypto.createHash('sha1').update(buffer).digest('hex').slice(0, 12);
|
|
|
|
|
|
const fileBase = [appId || 'wx', openid || 'openid', msgId || timestamp, mediaId || hash]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join('-')
|
|
|
|
|
|
.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
|
|
|
|
|
.slice(0, 120);
|
|
|
|
|
|
const filename = `${fileBase}.${resolved.extension}`;
|
|
|
|
|
|
const absolutePath = path.join(publishDir, filename);
|
|
|
|
|
|
fs.writeFileSync(absolutePath, buffer);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
absolutePath,
|
|
|
|
|
|
bytes: buffer.length,
|
|
|
|
|
|
contentType: resolved.mimeType,
|
|
|
|
|
|
filename,
|
|
|
|
|
|
publicUrl: buildWechatImagePublicUrl({
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
publishKey: String(userId),
|
|
|
|
|
|
filename,
|
|
|
|
|
|
}),
|
|
|
|
|
|
source,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-07-18 17:41:32 +08:00
|
|
|
|
|
|
|
|
|
|
export async function persistWechatAttachment(
|
|
|
|
|
|
{
|
|
|
|
|
|
userId,
|
|
|
|
|
|
appId,
|
|
|
|
|
|
openid,
|
|
|
|
|
|
msgId,
|
|
|
|
|
|
mediaId,
|
|
|
|
|
|
filename,
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
maxFileBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
wechatFetch = undiciFetch,
|
|
|
|
|
|
accessToken,
|
|
|
|
|
|
h5Root = __dirname,
|
|
|
|
|
|
} = {},
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (!userId) throw new Error('缺少 userId');
|
|
|
|
|
|
const resolved = sanitizeAttachmentFilename(filename);
|
|
|
|
|
|
const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch });
|
|
|
|
|
|
ensureAttachmentWithinLimit(downloaded.buffer, maxFileBytes);
|
|
|
|
|
|
|
|
|
|
|
|
const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, 'wechat-mp');
|
|
|
|
|
|
fs.mkdirSync(publishDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
|
|
const timestamp = Date.now();
|
|
|
|
|
|
const hash = crypto.createHash('sha1').update(downloaded.buffer).digest('hex').slice(0, 12);
|
|
|
|
|
|
const originalStem = path.basename(resolved.filename, resolved.extension)
|
|
|
|
|
|
.replace(/[^\p{L}\p{N}._-]+/gu, '_')
|
|
|
|
|
|
.replace(/^_+|_+$/g, '')
|
|
|
|
|
|
.slice(0, 80) || 'attachment';
|
|
|
|
|
|
const identity = [appId || 'wx', openid || 'openid', msgId || timestamp, mediaId || hash]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join('-')
|
|
|
|
|
|
.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
|
|
|
|
|
.slice(0, 100);
|
|
|
|
|
|
const publicFilename = `${originalStem}-${identity}-${hash}${resolved.extension}`;
|
|
|
|
|
|
const absolutePath = path.join(publishDir, publicFilename);
|
|
|
|
|
|
fs.writeFileSync(absolutePath, downloaded.buffer);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
absolutePath,
|
|
|
|
|
|
bytes: downloaded.buffer.length,
|
|
|
|
|
|
contentType: resolved.mimeType,
|
|
|
|
|
|
filename: resolved.filename,
|
|
|
|
|
|
publicFilename,
|
|
|
|
|
|
publicUrl: buildWechatImagePublicUrl({
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
publishKey: String(userId),
|
|
|
|
|
|
filename: publicFilename,
|
|
|
|
|
|
}),
|
|
|
|
|
|
source: 'wechat_media',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|