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'; 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'; const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024; const ALLOWED_IMAGE_MIME_TYPES = new Map([ ['image/jpeg', 'jpg'], ['image/png', 'png'], ['image/webp', 'webp'], ['image/gif', 'gif'], ]); 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'], ]); 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)`); } } 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)`); } } 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 }; } 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, }; } 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', }; }