feat(wechat): add image generation delivery policy
Memind CI / Test, build, and release guards (pull_request) Successful in 2m45s

This commit is contained in:
john
2026-07-21 23:21:44 +08:00
parent bfb1f6fea9
commit 28581310da
13 changed files with 1078 additions and 19 deletions
+90
View File
@@ -3,12 +3,15 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fetch as undiciFetch } from 'undici';
import sharp from 'sharp';
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_WECHAT_MEDIA_UPLOAD_URL = 'https://api.weixin.qq.com/cgi-bin/media/upload';
const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const DEFAULT_MAX_OUTBOUND_IMAGE_BYTES = 2 * 1024 * 1024;
const DEFAULT_MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024;
const ALLOWED_IMAGE_MIME_TYPES = new Map([
['image/jpeg', 'jpg'],
@@ -96,6 +99,93 @@ export async function downloadTemporaryMedia(accessToken, mediaId, { wechatFetch
return { buffer, contentType };
}
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 = '',
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();
if (publicBaseUrl && new URL(resolvedUrl).origin !== new URL(publicBaseUrl).origin) {
throw new Error('生成图片地址不属于当前 MindSpace 公网域名');
}
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,
};
}
export function buildWechatImagePublicUrl({
publicBaseUrl,
publishKey,