@tencent-weixin/openclaw-weixin 插件深度解析(三):CDN 媒体服务深度解析

13 阅读9分钟

媒体文件的处理是即时通讯插件的核心能力之一。微信采用 CDN(内容分发网络)存储媒体文件,并通过 AES-128-ECB 加密保护数据安全。本文将深入剖析 OpenClaw WeChat 插件的 CDN 媒体服务系统,包括上传流程、加密机制、下载解密、语音转码等关键技术实现。

一、CDN 媒体服务架构概览

微信的媒体文件存储采用分层架构,结合了业务服务器和 CDN 边缘节点:

┌─────────────────────────────────────────────────────────────────────────┐
│                      CDN Media Service Architecture                      │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   ┌─────────────┐      ┌─────────────┐      ┌─────────────────────┐     │
│   │   Client    │      │   Weixin    │      │       CDN Node      │     │
│   │   (Plugin)  │ <--> │    API      │ <--> │  (Edge Server)      │     │
│   └─────────────┘      └─────────────┘      └─────────────────────┘     │
│          │                                              │                │
│          │  1. getUploadUrl (filekey, aeskey, md5)      │                │
│          │ <------------------------------------------  │                │
│          │                                              │                │
│          │  2. upload (encrypted bytes)                 │                │
│          │ -------------------------------------------> │                │
│          │                                              │                │
│          │  3. download_param (for future access)       │                │
│          │ <------------------------------------------  │                │
│          │                                              │                │
│          │  4. download (encrypted bytes)               │                │
│          │ <------------------------------------------  │                │
│          │                                              │                │
│          │  5. decrypt (AES-128-ECB)                    │                │
│          │  (local)                                     │                │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

这种架构的优势在于:敏感媒体文件不经过业务服务器,直接上传到 CDN;AES-128-ECB 加密确保数据在传输和存储过程中的安全性;CDN 边缘节点提供高可用、低延迟的访问;下载参数(download_param)实现了访问控制。

二、媒体上传流程

2.1 上传流程概览

媒体上传是一个多步骤流程,涉及加密、元数据准备、CDN 上传:

export type UploadedFileInfo = {
  filekey: string;
  /** 由 upload_param 上传后 CDN 返回的下载加密参数 */
  downloadEncryptedQueryParam: string;
  /** AES-128-ECB key, hex-encoded */
  aeskey: string;
  /** Plaintext file size in bytes */
  fileSize: number;
  /** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding) */
  fileSizeCiphertext: number;
};

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) {
    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,
  };
}

上传流程的关键步骤:

  1. 读取文件:获取原始文件内容
  2. 计算元数据:原始大小、MD5 哈希、加密后大小
  3. 生成密钥:随机生成 filekey 和 AES 密钥
  4. 获取上传 URL:向微信 API 申请预签名上传 URL
  5. 上传加密文件:使用 AES-128-ECB 加密后上传到 CDN
  6. 获取下载参数:CDN 返回用于后续下载的加密参数

2.2 上传类型封装

插件为不同类型的媒体提供了便捷的封装函数:

/** 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. */
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",
  });
}

媒体类型常量定义:

export const UploadMediaType = {
  IMAGE: 1,
  VIDEO: 2,
  FILE: 3,
  VOICE: 4,
} as const;

2.3 CDN 上传实现

实际的 CDN 上传操作在 uploadBufferToCdn 中实现:

const UPLOAD_MAX_RETRIES = 3;

export async function uploadBufferToCdn(params: {
  buf: Buffer;
  uploadParam: string;
  filekey: string;
  cdnBaseUrl: string;
  label: string;
  aeskey: Buffer;
}): Promise<{ downloadParam: string }> {
  const { buf, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
  const ciphertext = encryptAesEcb(buf, aeskey);
  const cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
  logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`);

  let downloadParam: string | undefined;
  let lastError: unknown;

  for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) {
    try {
      const res = await fetch(cdnUrl, {
        method: "POST",
        headers: { "Content-Type": "application/octet-stream" },
        body: new Uint8Array(ciphertext),
      });
      if (res.status >= 400 && res.status < 500) {
        const errMsg = res.headers.get("x-error-message") ?? (await res.text());
        logger.error(
          `${label}: CDN client error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
        );
        throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);
      }
      if (res.status !== 200) {
        const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
        logger.error(
          `${label}: CDN server error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
        );
        throw new Error(`CDN upload server error: ${errMsg}`);
      }
      downloadParam = res.headers.get("x-encrypted-param") ?? undefined;
      if (!downloadParam) {
        throw new Error("CDN upload response missing x-encrypted-param header");
      }
      logger.debug(`${label}: CDN upload success attempt=${attempt}`);
      break;
    } catch (err) {
      lastError = err;
      if (err instanceof Error && err.message.includes("client error")) throw err;
      if (attempt < UPLOAD_MAX_RETRIES) {
        logger.error(`${label}: attempt ${attempt} failed, retrying... err=${String(err)}`);
      }
    }
  }

  if (!downloadParam) {
    throw lastError instanceof Error
      ? lastError
      : new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);
  }
  return { downloadParam };
}

CDN 上传的关键设计点:

  • 重试机制:最多 3 次重试,客户端错误(4xx)立即失败,服务器错误(5xx)可重试
  • 错误分类:通过 HTTP 状态码区分错误类型
  • 响应头解析:从 x-encrypted-param 获取下载参数
  • URL 脱敏:日志中对 URL 进行脱敏处理,防止敏感信息泄露

三、AES-128-ECB 加密机制

3.1 加密算法实现

微信 CDN 使用 AES-128-ECB 模式进行加密,这是对称加密的一种:

import { createCipheriv, createDecipheriv } from "node:crypto";

/** Encrypt buffer with AES-128-ECB (PKCS7 padding is default). */
export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
  const cipher = createCipheriv("aes-128-ecb", key, null);
  return Buffer.concat([cipher.update(plaintext), cipher.final()]);
}

/** Decrypt buffer with AES-128-ECB (PKCS7 padding). */
export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
  const decipher = createDecipheriv("aes-128-ecb", key, null);
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}

/** Compute AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */
export function aesEcbPaddedSize(plaintextSize: number): number {
  return Math.ceil((plaintextSize + 1) / 16) * 16;
}

3.2 填充机制

AES-128-ECB 要求数据长度是 16 字节(128 位)的倍数。PKCS7 填充规则:

  • 如果数据长度已经是 16 的倍数,添加 16 字节的填充(值为 16)
  • 否则,添加 n 字节的填充(值为 n),使总长度达到 16 的倍数

例如,一个 100 字节的数据:

原始大小: 100 字节
填充后大小: ceil((100 + 1) / 16) * 16 = ceil(6.3125) * 16 = 7 * 16 = 112 字节
填充字节数: 12 字节(每个值为 12)

3.3 安全考量

AES-128-ECB 模式的特点:

  • 优点:简单、并行化、无需初始化向量(IV)
  • 缺点:相同的明文块会产生相同的密文块,可能泄露模式信息
  • 微信的选择:对于媒体文件,ECB 模式的缺点影响较小,因为文件内容通常具有足够的随机性

密钥管理策略:

  • 每个文件使用独立的随机 AES 密钥
  • 密钥通过业务服务器传递给接收方
  • 密钥不持久化存储,仅在传输过程中使用

四、媒体下载与解密

4.1 下载流程

媒体下载是上传的逆过程,涉及 CDN 下载和本地解密:

/**
 * Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer.
 */
export async function downloadAndDecryptBuffer(
  encryptedQueryParam: string,
  aesKeyBase64: string,
  cdnBaseUrl: string,
  label: string,
): Promise<Buffer> {
  const key = parseAesKey(aesKeyBase64, label);
  const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
  logger.debug(`${label}: fetching url=${url}`);
  const encrypted = await fetchCdnBytes(url, label);
  logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`);
  const decrypted = decryptAesEcb(encrypted, key);
  logger.debug(`${label}: decrypted ${decrypted.length} bytes`);
  return decrypted;
}

4.2 AES 密钥解析

微信的 AES 密钥有两种编码格式,需要兼容处理:

/**
 * Parse CDNMedia.aes_key into a raw 16-byte AES key.
 *
 * Two encodings are seen in the wild:
 *   - base64(raw 16 bytes)          → images
 *   - base64(hex string of 16 bytes) → file / voice / video
 */
function parseAesKey(aesKeyBase64: string, label: string): Buffer {
  const decoded = Buffer.from(aesKeyBase64, "base64");
  if (decoded.length === 16) {
    return decoded;
    }
  if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) {
    // hex-encoded key: base64 → hex string → raw bytes
    return Buffer.from(decoded.toString("ascii"), "hex");
  }
  const msg = `${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes`;
  logger.error(msg);
  throw new Error(msg);
}

密钥格式说明:

  • 格式 1:直接 base64 编码的 16 字节原始密钥(主要用于图片)
  • 格式 2:base64 编码的 32 字符十六进制字符串(主要用于文件、语音、视频)

4.3 CDN URL 构建

CDN 上传和下载 URL 的构建规则:

/** Build a CDN download URL from encrypt_query_param. */
export function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string {
  return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
}

/** Build a CDN upload URL from upload_param and filekey. */
export function buildCdnUploadUrl(params: {
  cdnBaseUrl: string;
  uploadParam: string;
  filekey: string;
}): string {
  return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
}

4.4 媒体类型处理

不同类型的媒体文件有不同的处理逻辑:

export async function downloadMediaFromItem(
  item: WeixinMessage["item_list"] extends (infer T)[] | undefined ? T : never,
  deps: {
    cdnBaseUrl: string;
    saveMedia: SaveMediaFn;
    log: (msg: string) => void;
    errLog: (msg: string) => void;
    label: string;
  },
): Promise<WeixinInboundMediaOpts> {
  const { cdnBaseUrl, saveMedia, log, errLog, label } = deps;
  const result: WeixinInboundMediaOpts = {};

  if (item.type === MessageItemType.IMAGE) {
    const img = item.image_item;
    if (!img?.media?.encrypt_query_param) return result;
    const aesKeyBase64 = img.aeskey
      ? Buffer.from(img.aeskey, "hex").toString("base64")
      : img.media.aes_key;
    
    const buf = aesKeyBase64
      ? await downloadAndDecryptBuffer(
          img.media.encrypt_query_param,
          aesKeyBase64,
          cdnBaseUrl,
          `${label} image`,
        )
      : await downloadPlainCdnBuffer(
          img.media.encrypt_query_param,
          cdnBaseUrl,
          `${label} image-plain`,
        );
    const saved = await saveMedia(buf, undefined, "inbound", WEIXIN_MEDIA_MAX_BYTES);
    result.decryptedPicPath = saved.path;
  }
  // ... 语音、文件、视频的处理
}

五、语音转码处理

5.1 SILK 格式简介

微信语音消息使用 SILK(Skype Lite)格式,这是一种高效的语音编码格式:

  • 采样率:24000 Hz(微信默认)
  • 编码方式:自适应多速率(AMR)的变体
  • 优点:高压缩率、低带宽占用
  • 缺点:需要转码才能在大多数播放器中使用

5.2 SILK 转 WAV 实现

插件支持将 SILK 格式转码为通用的 WAV 格式:

const SILK_SAMPLE_RATE = 24_000;

/**
 * Wrap raw pcm_s16le bytes in a WAV container.
 * Mono channel, 16-bit signed little-endian.
 */
function pcmBytesToWav(pcm: Uint8Array, sampleRate: number): Buffer {
  const pcmBytes = pcm.byteLength;
  const totalSize = 44 + pcmBytes;
  const buf = Buffer.allocUnsafe(totalSize);
  let offset = 0;

  // RIFF header
  buf.write("RIFF", offset);
  offset += 4;
  buf.writeUInt32LE(totalSize - 8, offset);
  offset += 4;
  buf.write("WAVE", offset);
  offset += 4;

  // fmt chunk
  buf.write("fmt ", offset);
  offset += 4;
  buf.writeUInt32LE(16, offset);
  offset += 4; // fmt chunk size
  buf.writeUInt16LE(1, offset);
  offset += 2; // PCM format
  buf.writeUInt16LE(1, offset);
  offset += 2; // mono
  buf.writeUInt32LE(sampleRate, offset);
  offset += 4;
  buf.writeUInt32LE(sampleRate * 2, offset);
  offset += 4; // byte rate (mono 16-bit)
  buf.writeUInt16LE(2, offset);
  offset += 2; // block align
  buf.writeUInt16LE(16, offset);
  offset += 2; // bits per sample

  // data chunk
  buf.write("data", offset);
  offset += 4;
  buf.writeUInt32LE(pcmBytes, offset);
  offset += 4;

  Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset);

  return buf;
}

5.3 转码流程

使用 silk-wasm 库进行解码:

export async function silkToWav(silkBuf: Buffer): Promise<Buffer | null> {
  try {
    const { decode } = await import("silk-wasm");

    logger.debug(`silkToWav: decoding ${silkBuf.length} bytes of SILK`);
    const result = await decode(silkBuf, SILK_SAMPLE_RATE);
    logger.debug(
      `silkToWav: decoded duration=${result.duration}ms pcmBytes=${result.data.byteLength}`,
    );

    const wav = pcmBytesToWav(result.data, SILK_SAMPLE_RATE);
    logger.debug(`silkToWav: WAV size=${wav.length}`);
    return wav;
  } catch (err) {
    logger.warn(`silkToWav: transcode failed, will use raw silk err=${String(err)}`);
    return null;
  }
}

转码失败时的回退策略:

if (item.type === MessageItemType.VOICE) {
  const voice = item.voice_item;
  if (!voice?.media?.encrypt_query_param || !voice.media.aes_key) return result;
  
  const silkBuf = await downloadAndDecryptBuffer(
    voice.media.encrypt_query_param,
    voice.media.aes_key,
    cdnBaseUrl,
    `${label} voice`,
  );
  
  const wavBuf = await silkToWav(silkBuf);
  if (wavBuf) {
    const saved = await saveMedia(wavBuf, "audio/wav", "inbound", WEIXIN_MEDIA_MAX_BYTES);
    result.decryptedVoicePath = saved.path;
    result.voiceMediaType = "audio/wav";
  } else {
    // 转码失败,保存原始 SILK 文件
    const saved = await saveMedia(silkBuf, "audio/silk", "inbound", WEIXIN_MEDIA_MAX_BYTES);
    result.decryptedVoicePath = saved.path;
    result.voiceMediaType = "audio/silk";
  }
}

六、MIME 类型处理

6.1 MIME 类型映射

插件维护了常见文件扩展名与 MIME 类型的映射表:

const EXTENSION_TO_MIME: Record<string, string> = {
  ".pdf": "application/pdf",
  ".doc": "application/msword",
  ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  ".xls": "application/vnd.ms-excel",
  ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  ".ppt": "application/vnd.ms-powerpoint",
  ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
  ".txt": "text/plain",
  ".csv": "text/csv",
  ".zip": "application/zip",
  ".mp3": "audio/mpeg",
  ".wav": "audio/wav",
  ".mp4": "video/mp4",
  ".png": "image/png",
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".gif": "image/gif",
  ".webp": "image/webp",
  // ... 更多类型
};

const MIME_TO_EXTENSION: Record<string, string> = {
  "image/jpeg": ".jpg",
  "image/png": ".png",
  "image/gif": ".gif",
  "video/mp4": ".mp4",
  "audio/mpeg": ".mp3",
  "application/pdf": ".pdf",
  // ... 反向映射
};

6.2 MIME 类型解析函数

/** Get MIME type from filename extension. */
export function getMimeFromFilename(filename: string): string {
  const ext = path.extname(filename).toLowerCase();
  return EXTENSION_TO_MIME[ext] ?? "application/octet-stream";
}

/** Get file extension from MIME type. */
export function getExtensionFromMime(mimeType: string): string {
  const ct = mimeType.split(";")[0].trim().toLowerCase();
  return MIME_TO_EXTENSION[ct] ?? ".bin";
}

/** Get file extension from Content-Type header or URL path. */
export function getExtensionFromContentTypeOrUrl(contentType: string | null, url: string): string {
  if (contentType) {
    const ext = getExtensionFromMime(contentType);
    if (ext !== ".bin") return ext;
  }
  const ext = path.extname(new URL(url).pathname).toLowerCase();
  const knownExts = new Set(Object.keys(EXTENSION_TO_MIME));
  return knownExts.has(ext) ? ext : ".bin";
}

七、远程媒体下载

7.1 远程 URL 下载

当 AI 需要发送远程图片时,插件会先下载到本地临时文件:

/**
 * Download a remote media URL (image, video, file) to a local temp file in destDir.
 */
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;
}

7.2 临时文件管理

下载的远程文件保存在临时目录,由框架统一管理生命周期:

const MEDIA_OUTBOUND_TEMP_DIR = "/tmp/openclaw/weixin/media/outbound-temp";

八、配置缓存管理

8.1 用户配置缓存

为了优化性能,插件缓存每个用户的配置信息(如 typing_ticket):

export interface CachedConfig {
  typingTicket: string;
}

const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const CONFIG_CACHE_INITIAL_RETRY_MS = 2_000;
const CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000;

export class WeixinConfigManager {
  private cache = new Map<string, ConfigCacheEntry>();

  constructor(
    private apiOpts: { baseUrl: string; token?: string },
    private log: (msg: string) => void,
  ) {}

  async getForUser(userId: string, contextToken?: string): Promise<CachedConfig> {
    const now = Date.now();
    const entry = this.cache.get(userId);
    const shouldFetch = !entry || now >= entry.nextFetchAt;

    if (shouldFetch) {
      let fetchOk = false;
      try {
        const resp = await getConfig({
          baseUrl: this.apiOpts.baseUrl,
          token: this.apiOpts.token,
          ilinkUserId: userId,
          contextToken,
        });
        if (resp.ret === 0) {
          this.cache.set(userId, {
            config: { typingTicket: resp.typing_ticket ?? "" },
            everSucceeded: true,
            nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
            retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
          });
          fetchOk = true;
        }
      } catch (err) {
        this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
      }
      
      if (!fetchOk) {
        // 指数退避重试
        const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;
        const nextDelay = Math.min(prevDelay * 2, CONFIG_CACHE_MAX_RETRY_MS);
        if (entry) {
          entry.nextFetchAt = now + nextDelay;
          entry.retryDelayMs = nextDelay;
        }
      }
    }

    return this.cache.get(userId)?.config ?? { typingTicket: "" };
  }
}

8.2 缓存策略

配置缓存采用以下策略:

  • TTL:24 小时,随机分布避免缓存雪崩
  • 失败重试:指数退避,从 2 秒到最大 1 小时
  • 内存存储:每个用户独立的缓存条目
  • 优雅降级:获取失败时返回空配置,不影响主流程

九、总结

OpenClaw WeChat 插件的 CDN 媒体服务系统展现了以下技术特点:

  1. 安全传输:AES-128-ECB 加密确保媒体文件安全
  2. 分层存储:业务服务器与 CDN 分离,提升性能和可靠性
  3. 类型支持:图片、视频、文件、语音等多种媒体类型
  4. 语音转码:SILK 到 WAV 的自动转码,提升兼容性
  5. 容错设计:重试机制、失败回退、优雅降级
  6. 性能优化:配置缓存、MIME 类型快速识别

这些设计不仅满足了微信平台的特殊要求,也为开发者提供了稳定可靠的媒体处理能力。在下一篇文章中,我们将探讨 API 协议与数据流设计的细节。