Files
weixin-plugin-test/weixin-plugin/weixin/cdn/upload.ts
zyh 77019ec050 feat: 初始化微信插件独立测试环境
添加完整的微信插件测试框架,包含:
- 微信插件源码(weixin-plugin/)
- TypeScript 类型定义(types.ts)
- 插件编译脚本(scripts/build-weixin-plugin.sh)
- 简化测试脚本(scripts/test-weixin-simple.js)
- 完整测试脚本(scripts/test-weixin-plugin.js)
- 项目配置和依赖管理(package.json)
- 使用说明文档(README.md)

主要功能:
- 支持二维码登录认证
- HTTP 长轮询消息接收
- 文本、图片、视频、文件消息收发
- 自动回复测试
- 运行状态统计

Generated with [Claude Code](https://claude.com/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-03-30 07:14:00 +00:00

156 lines
4.9 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { getUploadUrl } from "../api/api.js";
import type { WeixinApiOptions } from "../api/api.js";
import { aesEcbPaddedSize } from "./aes-ecb.js";
import { uploadBufferToCdn } from "./cdn-upload.js";
import { logger } from "../util/logger.js";
import { getExtensionFromContentTypeOrUrl } from "../media/mime.js";
import { tempFileName } from "../util/random.js";
import { UploadMediaType } from "../api/types.js";
export type UploadedFileInfo = {
filekey: string;
/** 由 upload_param 上传后 CDN 返回的下载加密参数; fill into ImageItem.media.encrypt_query_param */
downloadEncryptedQueryParam: string;
/** AES-128-ECB key, hex-encoded; convert to base64 for CDNMedia.aes_key */
aeskey: string;
/** Plaintext file size in bytes */
fileSize: number;
/** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding); use for ImageItem.hd_size / mid_size */
fileSizeCiphertext: number;
};
/**
* Download a remote media URL (image, video, file) to a local temp file in destDir.
* Returns the local file path; extension is inferred from Content-Type / URL.
*/
export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> {
logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
const res = await fetch(url);
if (!res.ok) {
const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`;
logger.error(`downloadRemoteImageToTemp: ${msg}`);
throw new Error(msg);
}
const buf = Buffer.from(await res.arrayBuffer());
logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`);
await fs.mkdir(destDir, { recursive: true });
const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url);
const name = tempFileName("weixin-remote", ext);
const filePath = path.join(destDir, name);
await fs.writeFile(filePath, buf);
logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);
return filePath;
}
/**
* Common upload pipeline: read file → hash → gen aeskey → getUploadUrl → uploadBufferToCdn → return info.
*/
async function uploadMediaToCdn(params: {
filePath: string;
toUserId: string;
opts: WeixinApiOptions;
cdnBaseUrl: string;
mediaType: (typeof UploadMediaType)[keyof typeof UploadMediaType];
label: string;
}): Promise<UploadedFileInfo> {
const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params;
const plaintext = await fs.readFile(filePath);
const rawsize = plaintext.length;
const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
const filesize = aesEcbPaddedSize(rawsize);
const filekey = crypto.randomBytes(16).toString("hex");
const aeskey = crypto.randomBytes(16);
logger.debug(
`${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`,
);
const uploadUrlResp = await getUploadUrl({
...opts,
filekey,
media_type: mediaType,
to_user_id: toUserId,
rawsize,
rawfilemd5,
filesize,
no_need_thumb: true,
aeskey: aeskey.toString("hex"),
});
const uploadParam = uploadUrlResp.upload_param;
if (!uploadParam) {
logger.error(
`${label}: getUploadUrl returned no upload_param, resp=${JSON.stringify(uploadUrlResp)}`,
);
throw new Error(`${label}: getUploadUrl returned no upload_param`);
}
const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({
buf: plaintext,
uploadParam,
filekey,
cdnBaseUrl,
aeskey,
label: `${label}[orig filekey=${filekey}]`,
});
return {
filekey,
downloadEncryptedQueryParam,
aeskey: aeskey.toString("hex"),
fileSize: rawsize,
fileSizeCiphertext: filesize,
};
}
/** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */
export async function uploadFileToWeixin(params: {
filePath: string;
toUserId: string;
opts: WeixinApiOptions;
cdnBaseUrl: string;
}): Promise<UploadedFileInfo> {
return uploadMediaToCdn({
...params,
mediaType: UploadMediaType.IMAGE,
label: "uploadFileToWeixin",
});
}
/** Upload a local video file to the Weixin CDN. */
export async function uploadVideoToWeixin(params: {
filePath: string;
toUserId: string;
opts: WeixinApiOptions;
cdnBaseUrl: string;
}): Promise<UploadedFileInfo> {
return uploadMediaToCdn({
...params,
mediaType: UploadMediaType.VIDEO,
label: "uploadVideoToWeixin",
});
}
/**
* Upload a local file attachment (non-image, non-video) to the Weixin CDN.
* Uses media_type=FILE; no thumbnail required.
*/
export async function uploadFileAttachmentToWeixin(params: {
filePath: string;
fileName: string;
toUserId: string;
opts: WeixinApiOptions;
cdnBaseUrl: string;
}): Promise<UploadedFileInfo> {
return uploadMediaToCdn({
...params,
mediaType: UploadMediaType.FILE,
label: "uploadFileAttachmentToWeixin",
});
}