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