Skip to content

About

一款Halo插件:无需离开 Halo 控制台,即可把已写好的文章推送到微信公众号的草稿箱,封面与正文图片会自动转存到微信素材库,同步结果实时显示在文章列表。

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

微信公众号同步(plugin-wechat-official-sync)

Halo version releases license downloads commits

在 Halo 后台的文章列表中,一键将文章同步到微信公众号草稿箱。

一款 Halo 插件:无需离开 Halo 控制台,即可把已写好的文章推送到微信公众号的草稿箱,封面与正文图片会自动转存到微信素材库,同步结果实时显示在文章列表。


功能特性

  • 一键同步:文章行的操作菜单中新增「同步到微信公众号」,先在弹窗中预览上传后的排版效果,并核对本次上传的标题、作者、原文链接与留言设置——字段因超过微信长度上限被截断时,会在该字段旁标出「已截断」(悬停可看说明)并在底部给出长度检查结论;确认后即提交同步任务。
  • 复制正文兜底:预览弹窗底部提供「复制正文」按钮,把美化后的正文按富文本复制到剪贴板(保留行内样式),用于同步失败的场景——直接粘贴到公众号编辑器即可手动发布,无需重新排版。注意:复制内容中的图片仍是原图地址,微信通常不会自动转存(甚至会被过滤),图片与封面需自行在公众号中上传处理,详见常见问题。
  • 提交前预检:点击「同步到微信公众号」时先自动校验微信配置(AppID / AppSecret)、封面图与文章同步状态等「提交前即可发现」的已知问题,有问题直接提示并中止、不进入预览与同步流程(预检只读:不调用微信接口、不写任何记录)。
  • 自动创建草稿:调用微信 draft/add 接口,把文章标题、作者、摘要、正文写入公众号草稿箱,并把「原文链接(阅读原文)」填为文章在站点上的地址。
  • 重复同步更新草稿(可在 微信公众号 → 重复同步更新草稿 中关闭,默认开启):同一篇文章首次同步直接新建草稿;再次同步时先校验上次写入的那份草稿是否还在——在则调用 draft/update 更新它(草稿 media_id 不变),不在则新建。校验草稿时发生任何错误都按「草稿不存在」处理、直接新建,一次校验失败不会打断同步。这样反复同步、修改后重推都不会在草稿箱里堆出一堆重复草稿(草稿被手动删除后也会自动改为新建,无需人工干预)。更新这一步被拒时同样自动改为新建:草稿引用的封面素材失效(40007),或请求被微信网关 / WAF 拒绝(如内容风控的 501,见非 errcode 类失败)时,插件不会让整次同步失败,而是改走 draft/add 新建一份——此时任务记录里的实际动作记为 create(新建),原草稿仍留在草稿箱,可自行删除。关闭该开关则不做任何校验,每次同步都新建一份草稿(与旧版本行为一致)。
  • 封面处理:将文章封面下载后上传为微信永久图片素材,作为草稿封面(thumb_media_id)。
  • 正文图片转存:解析正文 HTML,把其中的图片逐张转存到微信域名(media/uploadimg)并替换链接,避免微信过滤外部图片。
  • 正文附件链接处理:正文里指向文件的链接(Halo 附件库的 /upload/...、带文件扩展名的下载链接,以及「下载链接」等插件组件在美化阶段转换出的链接)会先按真实字节判定,再决定是否调用微信接口:是微信支持的图片(jpg/png/gif/bmp,webp 自动转码)就转存为微信图片显示;其余(pdf / zip 等非图片、字节与图片不符、下载失败)不上传,直接把链接改为纯文本,避免草稿里留下微信点不开的死链、或把非图片字节交给图片接口换来 40005/40113 报错——纯文本显示链接地址(默认,正文里写的是什么就显示什么)还是链接内容(链接自身的文字)可在 正文美化 → 附件链接显示 中配置。站内文章路由、无文件扩展名的网页链接不受影响,原样保留。
  • 正文排版美化:微信图文会剥离外部 CSS 与 class,只保留行内 style。插件在提交草稿前用 jsoup 按标签为标题、段落、引用、代码块、图片、列表、表格等注入微信友好的内联样式,让排版贴合公众号阅读体验(你在编辑器里已设置的行内样式优先保留)。代码块采用微信编辑器原生代码块结构:自带行号(删除代码行时行号自动减少)、内容不折行,行号与代码行严格对应,微信会按代码语言自动高亮;表格对齐微信编辑器插入表格的原生观感:1px 浅灰细边框、表头加粗无底色、单元格内容自动换行,表格宽度模式可配(默认「保持比例」):按文章表格的原始列宽比例渲染,列宽总和超出屏宽时由外层容器横向滚动查看全貌;「宽度铺满」则把列宽按比例压缩进屏宽、表格始终铺满。任务列表(待办清单)重建为 Emoji 图标呈现:微信会剥离 <input> 复选框,已完成显示 ✅、未完成显示 ⬜,列表去掉默认圆点——编辑器输出的待办清单与 markdown 渲染输出的任务列表(如「Markdown 编辑块」的 - [ ] / - [x] 清单)均已适配;正文中以纯文本残留的 markdown 任务清单也会自动转为图标。列表结构会压紧:<ul>/<ol>/<li> 之间的换行与缩进空白(如「Markdown 编辑块」渲染出的 <ul>\n<li>…</li>\n</ul>)在浏览器预览里会被折叠,但微信编辑器重建列表结构时会把它当成列表项内容、表现为列表前后多出空的 <li> 行,故提交草稿前统一清除这类结构空白与视觉为空的列表项。列表项里直接裸露的行内内容(如 markdown 列表渲染出的 `<code>SERVER_PORT=3022</code>:说明`)会补一层段落包裹:微信端会把直接挂在 <li> 下的裸文本提升成它自己的块(<section><span leaf>…</span></section>),发布后表现为这段文字被拆到下一行——预览与草稿都正常、只有发布后可见;包进 <p>(与 Halo 原生列表结构一致)后行内代码与文字保持同行。Halo 编辑器的折叠内容(<details>)在微信中无法保留折叠交互,会重建为静态展开的卡片:加粗标题栏(▸ 标记、底部细分隔线与内容区隔开)+ 内容区,内容照常排版;标题栏与内容区的背景色、边框颜色均可配置(默认浅灰标题栏 + 白色内容区)。分栏卡片与画廊版式可分别配置(各默认「表格」,也可选「独占一行」):Halo 编辑器用 display:flex/display:grid 排布分栏与画廊,而微信会过滤 grid、对 flex 支持不稳定,直接同步会让各列、各图纵向堆叠、各占一行;「表格」版式下分栏卡片按列宽 flex 比例换算为单元格百分比宽度,让各列并排;画廊重建为一张整体表格、所有列等宽——所有行合并进同一个表格、不按行拆表,行内图片用列合并(colspan)均分整行,末行不满时也铺满整行;行/列间距折算为单元格内边距。「独占一行」版式则不重建表格:分栏卡片每栏一行、画廊每张图片一行,图片与描述内容照常保留。引用块边框(可开关,默认显示)、引用块背景色、标题边框(可开关)、各级标题颜色、行内代码配色、折叠块配色(标题背景色/内容背景色/边框颜色)、视频/音频卡片配色(背景色/主行/引导语/标记符号)、表格宽度模式、分栏卡片版式、画廊版式均可在插件设置的 正文美化 标签中配置。
  • 视频与音频提示卡片:微信图文会过滤 <video>/<audio> 标签(草稿接口不支持正文内嵌视频与音频),直接同步会渲染成空白块;插件会把编辑器插入的视频、音频重建为提示卡片——主行为标记 + 加粗标签(▶ 视频 / ♪ 音频),编辑器中填写的媒体描述随卡片保留,末行为引导语「请点击文末『阅读原文』观看 / 收听」(公众号正文外链不可点击,「阅读原文」是进入原文页播放的唯一可靠入口)。卡片配色(背景色、主行文字、引导语、标记符号)可在 正文美化 标签中随文章风格配置;预览与草稿中呈现的都是这一卡片形态。
  • webp 自动转换:微信素材仅支持 bmp/png/jpeg/jpg/gif;插件会把 webp 等格式自动解码并重编码为 png/jpg 再上传。
  • 异步不阻塞:接口立即返回 202 Accepted,实际同步在后台线程执行,不卡住控制台。
  • 任务持久化与重启恢复:同步任务(含待同步的文章输入与状态)持久化为 Halo 自定义模型 WechatSyncTask(保存在 Halo 数据库中,随 Halo 数据备份/迁移一起走);插件或 Halo 服务重启后,未完成的任务会自动恢复执行(按持久化输入重放一遍,执行时读取最新的插件配置),无需手动重新提交。
  • 状态可视化:文章列表新增状态列,用颜色编码的微信 Logo 展示每篇文章最近一次同步结果,鼠标悬停查看详细信息。
  • MCP 工具(可选):安装并启用 Halo MCP Server 插件后,AI 助手可通过 wechat_sync_preview(获取预览信息)、wechat_sync_submit(提交同步到微信)、wechat_sync_status(查询同步状态)与 wechat_cache_cleanup(清理素材缓存)四个 MCP 工具完成预览、同步、结果查询与缓存维护,效果与在 Console 上操作一致,详见MCP 工具。

兼容性说明:正文美化的兼容与测试主要针对 Halo 默认编辑器输出的内容;由其他插件生成的内容(自定义组件、专属区块等)依赖插件自身的样式与脚本渲染,微信无法识别与渲染,因此无法同步到微信。

使用方式

  1. 在文章列表找到目标文章,点击行尾的操作菜单(···)。
  2. 选择 同步到微信公众号:插件会先自动预检微信配置、封面图与同步状态,有问题会直接提示且不会进入后续流程;校验通过后弹窗中会按手机端图文版式展示文章标题与正文上传到公众号后的大致效果,并列出本次上传使用的作者、原文链接与留言设置;确认无误后点击「确认同步」,不满意可取消、不提交。若同步始终失败、想先在公众号手动发布,可点弹窗底部的 复制正文 把美化后的正文复制走,直接粘贴到公众号编辑器(详见常见问题)。
  3. 状态列的微信 Logo 会先变为橙色(同步中),列表会自动持续刷新(约每 5 秒)直到变为绿色(成功)或红色(失败)——服务器带宽较低、正文图片较多导致同步耗时较久时,无需手动刷新页面。
  4. 同步成功后,前往公众号后台的草稿箱即可看到该文章;再次同步同一篇文章会更新这份草稿(media_id 不变),而不是在草稿箱里新增一份——该行为由 微信公众号 → 重复同步更新草稿 开关控制(默认开启,关闭后每次都新建,见配置)。

状态列颜色含义

颜色 状态 说明
🟢 绿色 #07c160 成功 已写入公众号草稿箱
🔴 红色 #ef4444 失败 悬停查看失败原因
🟠 橙色 #f59e0b 同步中 任务已提交,正在处理

鼠标悬停在 Logo 上会显示状态文案、说明与更新时间;成功时不展示 media_id 等技术细节;失败时的说明即微信返回的原始结果(含 errcode / errmsg),可对照下方微信接口错误码速查排查。

环境要求

  • Halo >= 2.26.0
  • Java 21+
  • Node.js >= 22.12.0
  • pnpm
  • 一个微信公众号(订阅号或服务号),且已开通素材管理、草稿箱等接口权限

安装

方式一:应用商店安装(推荐)

  • 在 Halo 后台左侧菜单进入 应用市场,搜索 微信公众号同步,点击进入详情页后一键安装;安装后新版本发布时可在后台直接升级。
  • 也可直接在浏览器打开商店页面 微信公众号同步 - Halo 应用商店 下载安装。

方式二:手动上传安装

从 GitHub Releases 下载构建好的 jar,在 Halo 后台「插件」页面点击「安装」并上传 jar 文件。

方式三:自行构建

见下方构建,产物位于 build/libs/*.jar,再按方式二上传安装。

配置

安装并启用插件后,进入插件的「设置」,分为 微信公众号 与 正文美化 两个标签。

微信公众号 标签:

配置项 必填 说明
AppID 是 公众平台「设置与开发 - 基本配置」中的开发者 ID
AppSecret 是 公众平台的开发者密码。通过 Halo Secret 组件托管:值保存在独立的 Secret 资源中,插件设置(ConfigMap)只保存该 Secret 的名称,不保存任何明文
接口地址 否 微信接口基址。留空则直连官方 https://api.weixin.qq.com;无固定公网 IP 时填自建反向代理地址,详见接口地址与反向代理
默认作者 否 同步到公众号时展示的作者名,留空则使用文章作者。微信限制作者名不超过 8 个字(编辑器计字口径:汉字 1 字、半角字符 0.5 字、emoji 2 字,超过会被接口拒绝),超长会被自动截断并在服务端日志中提示,建议直接控制在 8 字以内
留言设置 否 同步生成的草稿的留言权限,可选「关闭留言」(默认)/「所有人可留言」/「已关注的人可留言」,对应微信 need_open_comment 与 only_fans_can_comment;「已关注 7 天及以上」仅支持在公众号后台设置
图片下载白名单 否 多行文本,每行一个信任的域名/IP/CIDR。留空(默认)= 不放行任何内网地址;仅当 Halo 部署在内网、图片也在内网地址时才需配置,详见内网部署与图片下载白名单
重复同步更新草稿 否 开关,默认开启:同一篇文章再次同步时,先校验上次写入的那份草稿是否还在——在则更新那份草稿(draft/update,草稿 media_id 不变)、不在则新建,避免草稿箱里堆出重复草稿。关闭则不做校验,每次同步都新建一份草稿。校验草稿拿不到结论(如「接口地址」代理未放行 /cgi-bin/draft/get)时一律按「不在」处理、改为新建,不会打断同步;更新本身被拒(封面素材失效 40007、或请求被微信网关 / WAF 拒绝)时同样改为新建,任务记录里把实际动作记为 create(由 MCP 的 wechat_sync_status 回报;文章列表的状态说明只写「已同步到公众号草稿箱」,不提新建 / 更新)
缓存保留天数 否 数字输入,默认 30:素材缓存的保留期(天)。留空、0 或负数表示「全部保留」。保留期按记录的最近一次使用时间计算——某张图只要还会被同步命中,计时就会刷新,因此不会被误删;被清理的都是确实已不再使用的缓存。设为「全部保留」则永久保留(数据库会持续增长)。清理任务固定每天 0 点执行(清理判定只看「多少天没被使用」,执行时刻不影响结果,故不做成配置项);保留天数在每次执行时读取,改完无需重启插件

正文美化 标签(控制提交草稿前的排版美化,均已提供默认值):

配置项 必填 说明
正文文字颜色 是 段落、列表、表格正文的文字颜色,默认 #3f3f3f
链接颜色 是 正文中超链接的文字颜色,默认 #576b95
附件链接显示 否 下拉选择,默认「显示链接地址」:仅对无法提交到微信的正文链接(pdf、zip 等非图片附件)生效——这类链接会被改为纯文本,此项决定显示原始地址(如 /upload/2026/09/manual.pdf)还是链接自身的文字(如「下载手册」);链接没有文字时回退显示地址
行内代码颜色 是 行内 code 的文字颜色,默认 #d14(对齐微信图文经典风格)
行内代码底色 是 行内 code 的背景色,默认 #f2f3f5
引用块显示边框 否 开关;开启后引用块显示左侧强调边框(颜色由「引用块边框颜色」决定),默认开启
引用块边框颜色 是 引用块左侧强调边框的颜色,默认微信绿 #07c160
引用块背景色 是 引用块的背景颜色,默认 #f7f7f7
标题显示边框 否 开关;开启后 H2–H6 标题显示左侧强调边框(H1 不加),默认关闭
一级标题颜色 是 H1 标题文字颜色,默认 #222222
一级标题对齐方式 否 下拉选择,默认「居中」:可选左对齐、居中、右对齐;正文中的 H1 若已自带对齐方式(行内 text-align 或 align 属性),则保持原样、本项对其不生效
二级标题颜色 / 二级标题边框颜色 是 H2 文字颜色(默认 #222222)与左侧边框颜色(默认 #07c160,仅开关开启时显示)
三级标题颜色 / 三级标题边框颜色 是 H3 文字颜色(默认 #222222)与左侧边框颜色(默认 #07c160,仅开关开启时显示)
四级标题颜色 / 四级标题边框颜色 是 H4 文字颜色(默认 #222222)与左侧边框颜色(默认 #07c160,仅开关开启时显示)
五级标题颜色 / 五级标题边框颜色 是 H5 文字颜色(默认 #333333)与左侧边框颜色(默认 #07c160,仅开关开启时显示)
六级标题颜色 / 六级标题边框颜色 是 H6 文字颜色(默认 #888888)与左侧边框颜色(默认 #07c160,仅开关开启时显示)
折叠块标题背景色 是 折叠块(Halo 折叠内容)标题栏的背景色,默认浅灰 #f7f7f7(标题文字保持深色)
折叠块内容背景色 是 折叠块内容区的背景色,默认白色 #ffffff
折叠块边框颜色 是 折叠块卡片边框与标题栏分隔线的颜色,默认 #e6e6e6
视频/音频卡片背景色 是 微信不支持正文内嵌视频与音乐,编辑器插入的视频、音频同步时会重建为提示卡片(▶ 视频 / ♪ 音频 + 「阅读原文」引导语);此项控制卡片背景色,默认 #f7f7f7
视频/音频卡片主行颜色 是 提示卡片主行「▶ 视频」/「♪ 音频」的文字颜色,默认 #333333
视频/音频卡片引导语颜色 是 提示卡片引导语「请点击文末『阅读原文』观看/收听」的文字颜色,默认 #999999
视频/音频卡片标记颜色 是 提示卡片标记符号「▶」/「♪」的颜色,默认微信绿 #07c160
表格宽度模式 否 下拉选择,默认「保持比例」:按文章表格的原始列宽比例渲染,列宽总和超出屏宽时在微信里横向滚动查看全貌(推荐);「宽度铺满」:列宽按比例压缩进屏宽,表格始终铺满屏幕、不产生横向滚动
分栏卡片版式 否 下拉选择,默认「表格」:各列并排(重建为微信兼容的表格布局,推荐);「独占一行」:不重建表格,每栏各占一行,栏内内容照常保留
画廊版式 否 下拉选择,默认「表格」:多图并排(重建为一张整体表格、所有列等宽,推荐);「独占一行」:不重建表格,每张图片各占一行,图片与描述内容照常保留

还需要在微信公众平台 / Halo 侧完成的准备

  • IP 白名单:在公众平台「基本配置 - IP 白名单」中加入 Halo 服务器的公网出口 IP,否则无法获取 access_token。若 Halo 服务器没有固定公网 IP(动态 IP、家用宽带、部署在 NAT 之后),请改用接口地址与反向代理方案:用一台有固定公网 IP 的服务器做反向代理,把该代理服务器的固定 IP 加入白名单。
  • 外部访问地址:若文章封面或正文图片使用相对路径,需在 Halo「设置 - 基本设置 - 外部访问地址」中配置可公网访问的站点地址,插件据此拼接出图片的绝对地址后再下载转存(会自动兼容地址尾部有无 /);草稿的「原文链接」同样基于该地址与文章路由拼接。

接口地址与反向代理

微信要求获取 access_token 的服务器出口 IP 必须在公众号「IP 白名单」中。若你的 Halo 服务器没有固定公网 IP(动态 IP、家用宽带、部署在 NAT 之后等),白名单会频繁失效,导致同步失败。

解决办法:准备一台有固定公网 IP 的低配服务器(VPS)作为反向代理 / 白名单代理,只把这台代理服务器的固定 IP 加入微信 IP 白名单。Halo 将微信接口请求发往「接口地址」,由代理转发到 api.weixin.qq.com——微信看到的出口 IP 始终是代理的固定 IP,从而绕开 Halo 无固定公网 IP 的限制。

Halo 服务器(无固定公网 IP)
        │  请求发往插件设置的「接口地址」
        ▼
反向代理服务器(固定公网 IP,已加入微信 IP 白名单)
        │  原样转发到
        ▼
https://api.weixin.qq.com

配置:在插件设置的 接口地址 中填入代理服务器地址(如 https://wechat-proxy.example.com);留空则直连微信官方接口。地址支持带路径前缀(如 https://example.com/wechat-proxy),插件会保留前缀并去除尾部 /。

⚠️ 重要提醒:代理服务器会获取请求中的敏感信息(包括但不限于 access_token、AppID 等敏感信息),务必使用自行部署或可信的服务器进行代理,切勿使用来源不明的公共代理,以免造成信息泄露。

⚠️ 你必须在该地址上反向代理插件用到的全部微信接口,并保持原始请求路径不变,否则相应步骤会失败。其中 material/get_material(封面复用前的校验)与 draft/get(重复同步前校验已有草稿)是校验用接口:漏掉它们不会让同步失败,只会退化为「每次同步都重传一份封面 / 每次同步都新建一份草稿」,建议一并放行(见下方说明)。

插件用到的微信接口(相对于「接口地址」基址,均为微信官方路径):

方法 路径 用途 请求体
GET /cgi-bin/token 获取 access_token 查询参数
POST /cgi-bin/media/uploadimg 上传正文图片 multipart/form-data
POST /cgi-bin/material/add_material?type=image 上传封面为永久图片素材 multipart/form-data
POST /cgi-bin/material/get_material 复用封面前校验该永久素材是否仍在(请求体 {"media_id":"..."}) application/json
POST /cgi-bin/draft/add 创建图文草稿 application/json
POST /cgi-bin/draft/get 重复同步前校验上次写入的草稿是否仍在(请求体 {"media_id":"..."};仅开启「重复同步更新草稿」时调用) application/json
POST /cgi-bin/draft/update 更新既有草稿(请求体 {"media_id":"...","index":0,"articles":{...}};仅开启「重复同步更新草稿」时调用) application/json

ℹ️ 关于 material/get_material:它是只读校验接口,用来判断缓存里的封面 media_id 还能不能复用——素材仍在时微信直接返回图片二进制流(不是 JSON),素材已被删除时返回 {"errcode":40007,"errmsg":"invalid media_id"}。代理没有转发它不会导致同步失败(插件按「给不出结论」处理,保守地重新上传封面),但代价是每次同步都新增一份封面永久素材:永久图片素材有 10 万份上限,长期如此会白占配额,日志里也会反复出现「校验给不出结论…重新上传」。因此建议一并转发该路径;转发时同样要保持路径原样(该接口是 POST,请求体为 JSON)。

ℹ️ 关于 draft/get 与 draft/update:开启「重复同步更新草稿」(默认开启,见配置)时,重复同步同一篇文章会先用 draft/get 校验上次写入的草稿是否还在——在则用 draft/update 更新那份草稿,不在(或校验给不出结论)则用 draft/add 新建;该开关关闭时这两个路径都不会被调用。只转发旧的那几个路径也不会让同步失败:draft/get 拿不到结论时按「草稿不存在」处理、走新建,只是会退化为「每次同步都新建一份草稿」(草稿箱里会越积越多,需自行清理)。因此建议连同这两个路径一并转发;它们同样是 POST + JSON 请求体,路径也须原样透传。

Nginx 反向代理示例(部署在代理服务器上,将上述路径整体转发到微信):

server {
    listen 443 ssl;
    server_name wechat-proxy.example.com;

    # ssl_certificate     /path/to/fullchain.pem;
    # ssl_certificate_key /path/to/privkey.pem;

    # 仅放行插件用到的 7 个接口路径,其余一律拒绝,避免沦为开放代理
    location ~ ^/cgi-bin/(token|media/uploadimg|material/add_material|material/get_material|draft/add|draft/get|draft/update)$ {
        proxy_pass https://api.weixin.qq.com;   # 不含 URI,nginx 会原样透传路径与查询参数
        proxy_set_header Host api.weixin.qq.com;
        proxy_ssl_server_name on;               # 关键:向微信发起 TLS 时携带 SNI
        proxy_ssl_name api.weixin.qq.com;

        client_max_body_size 20m;               # 素材上传可能较大,按需调整
        proxy_request_buffering off;
        proxy_read_timeout 60s;
    }

    location / {
        return 403;
    }
}

1Panel 反向代理示例(适用于通过 1Panel 建站面板管理的服务器):

location ~ ^/cgi-bin/(token|media/uploadimg|material/add_material|material/get_material|draft/add|draft/get|draft/update)$ {
    proxy_pass https://api.weixin.qq.com;
    proxy_set_header Host api.weixin.qq.com;
    proxy_ssl_server_name on;
    proxy_ssl_name api.weixin.qq.com;
    client_max_body_size 30m; # 素材上传可能较大,按需调整
    proxy_request_buffering off;
    proxy_read_timeout 120s;
}

在 1Panel 中创建「反向代理」网站后,进入该网站的 反向代理,将上方整段 location 配置粘贴到源文文件中保存即可生效。

要点:

  • proxy_pass 到 https:// 上游时务必开启 proxy_ssl_server_name on;(SNI),否则与微信的 TLS 握手可能失败。
  • 保持路径原样转发:proxy_pass 后不要带会改写路径的 URI 部分,让 nginx 原样透传 /cgi-bin/... 路径与查询参数。
  • 代理服务器的出口 IP 必须与加入微信白名单的 IP 一致。
  • 建议用 location 精确匹配上面 7 个路径、拒绝其它请求,防止代理被滥用;material/get_material 与 draft/get 漏掉不会报错,但会让「封面复用 / 草稿更新」分别退化为每次都重传封面、每次都新建草稿(draft/get、draft/update 仅在开启「重复同步更新草稿」时才会用到,见上文说明)。
  • 生产环境请为代理配置 https:// 与合法证书;仅内网测试时可用 http://。

代理侧的日志脱敏

插件自身不会把 access_token / AppSecret 写进日志、异常消息与同步记录(见日志与错误信息的脱敏),但反向代理服务器的访问日志(access log)默认会记录完整请求行——/cgi-bin/token?...&secret=...、/cgi-bin/draft/add?access_token=... 这类带凭据的查询串会原样落到代理的日志文件里。这份日志属于代理的职责范围,插件无法代管,建议一并脱敏:

# 只记录「方法 + 路径」,不记录查询串(access_token / secret 都在查询串里)
log_format wechat_masked '$remote_addr - $remote_user [$time_local] "$request_method $uri" '
                         '$status $body_bytes_sent "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/wechat-proxy.log wechat_masked;
  • 默认的 $request / $request_uri 含完整请求行与查询串,改成 $request_method $uri 即可丢掉查询串;
  • 排障时若确实需要查询串,可临时切回默认格式,排查完再改回;也可用 map 只保留非敏感参数;
  • 同一台机器上的网关 / 负载均衡(Cloudflare、阿里云 SLB、Kong 等)的访问日志同理,建议关闭或对查询串脱敏。

微信接口错误码速查

同步过程中任一步失败,失败原因会在文章列表的红色微信 Logo 上悬停显示,内容为插件请求微信后的原始结果(含 errcode / errmsg),服务端日志中也会记录。本节按插件实际调用的 7 个接口整理微信官方给出的限制与接口级错误码(其中 material/get_material 只用于素材复用前的校验、draft/get 只用于重复同步前校验已有草稿,它们出错不会让同步失败:前者保守重传封面、后者按「草稿不存在」处理改为新建草稿,判定规则见下方单独一节),再汇总通用(全局)错误码在本插件中的常见诱因,最后补充未出现在全局返回码表 / 各接口文档只一笔带过、但在同步场景里真实会遇到的错误码,以及非 errcode 类失败,便于对照排查。

各接口的官方限制与接口级错误码

接口 关键限制 官方文档明确列出的错误码
GET /cgi-bin/token access_token 有效期 7200 秒;AppSecret 必须正确且未被冻结;调用方出口 IP 必须在公众号 IP 白名单内 -1、40001、40002、40013、40125、40164、40243、41004、50004、50007
POST /cgi-bin/media/uploadimg 仅支持 jpg / png,单张必须 < 1 MB;上传的图片不占用素材库 10 万配额 40005、40009
POST /cgi-bin/material/add_material?type=image 支持 bmp / png / jpeg / jpg / gif,必须 < 10 MB;永久图片素材数量上限 100,000 40007(其余走通用错误码)
POST /cgi-bin/material/get_material 只能用本公众号的永久素材 media_id 查询(临时素材、其它公众号的 id 均无效);图片类素材返回图片二进制流、图文/视频才返回 JSON,故「响应不是 JSON」正是素材存在的标志;官方文档的错误码小节只列出右列 3 个 -1、40001、40007
POST /cgi-bin/draft/add title ≤ 64 字、author ≤ 8 字、digest ≤ 120 字、content 需 < 2 万字符且 < 1 MB、content_source_url ≤ 1 KB;thumb_media_id 必须是本公众号的永久图片素材 id;正文图片 URL 必须来自 uploadimg,外部图片会被过滤。长度口径以实测为准:接口文档把 title 写作 32 字、author 写作 16 字,但平台与编辑器口径为标题 64 字、作者 8 字,服务端也按后者校验(作者超 8 字会返回未收录的 45110),插件按 64 / 8 / 120 截断 53404、53405、53406(商品/带货能力相关,插件不使用);其余走通用错误码
POST /cgi-bin/draft/get 只能用本公众号的草稿 media_id 查询(永久素材、其它公众号的 id 都无效,会返回 40007);草稿仍在时返回带 news_item 的草稿详情 -1、40001、40007(插件只把「拿到 news_item」当「草稿仍在」,其余一律按不存在处理,见下方说明)
POST /cgi-bin/draft/update 与 draft/add 同样的字段限制(title / author / digest / content 上限一致),另须带 media_id 与 index;请求体的 articles 是单个对象(draft/add 是数组) 同 draft/add:53404、53405、53406(插件不使用);其余走通用错误码

字段长度以实测为准(接口文档与实际校验不一致):插件会主动截断为 标题 → 64 字、作者 → 8 字(插件设置的「默认作者」与回退的文章作者同样适用)、摘要 → 120 字。

计字统一采用公众号编辑器口径(与编辑器的标题 / 作者 / 摘要计数器一致,实测可准确截到微信允许的长度):一个汉字 / 全角字符计 1 个字,一个半角字符(英文字符、数字、符号)计 0.5 个字,一个 emoji 等增补字符计 2 个字;按码点整体取舍,不会把 emoji 截成半个代理对。

注意接口文档写的上限偏大 / 偏小都可能踩坑:文档称 author ≤ 16 字,但服务端按平台规则(作者名 8 字)校验——按 16 字提交会被拒绝并返回未收录的 45110 author size out of limit;文档称 title ≤ 32 字,而编辑器与平台口径是 64 字。

只要有字段因超长被截断:预览弹窗会在该字段旁显示「已截断」标识、底部给出长度检查结论(并可在预览中直接看到截断后的值),服务端日志同时输出 warn(字段名、原始长度、上限与截断后的值),便于核对实际提交到草稿的内容。

素材校验接口 material/get_material 的判定规则

该接口只用于判断缓存里的封面 media_id 还能不能复用(见工作原理的「素材缓存」),因此插件把它的响应分成三态处理,任何一态都不会让同步失败:

响应 判定 插件的处理
2xx 且响应体不是 JSON(图片二进制流) 素材仍在 复用缓存里的 media_id,不重复上传
JSON 中 errcode 为 40007 素材已被删除(素材在公众号后台被清理) 重新上传一份封面并覆盖缓存
其余全部:-1(系统繁忙)、40001 / 40014 / 42001(凭证失效/过期)、45009 / 45011(额度、频控)、代理未转发返回的 404/HTML、网络异常、空响应体 给不出结论 保守重传并覆盖缓存(日志记「校验给不出结论(代理未转发该接口 / 限流 / 网络异常等)」)

关键点:

  • 只有明确的 40007 才判「已删除」,因为「响应不是 JSON」本身就代表成功拿到素材本体——图片类是二进制流,判「素材还在」不能靠 JSON 字段。校验请求只读取响应体头部 256 字节即中止传输,不会把整张封面重新下载一遍。
  • 没有结论时选择重传:多传一份素材的代价(永久素材占配额)远小于复用一个已失效的 media_id(整篇草稿被 40007 拒绝、发不出去),因此这类日志属正常降级;若它每次同步都出现,首要排查「接口地址」代理是否放行了 /cgi-bin/material/get_material(详见接口地址与反向代理),其次看是否 45009 / 45011 限流或凭证失效。
  • 与正文图片的地址校验不同:正文图片只用 HEAD 直连它的微信图片地址(不经代理),而本接口与其他微信接口一样发往插件设置的「接口地址」(留空则直连官方),因此需要被代理转发。

草稿校验接口 draft/get 的判定规则

仅在开启「重复同步更新草稿」时才会调用(见配置):该开关关闭时插件不做任何校验,每次同步都直接新建草稿(draft/add),本节与 draft/update 都不参与。除同步的执行阶段外,MCP 的 wechat_sync_submit 也会在提交时调用它一次,用来判断本次「预计」是更新还是新建(还在 → 预计 update,已不在 → 预计 create),避免草稿已删除时仍报「将更新」;该判断只写进提交结果的说明文案,不作为返回字段(实际动作由 wechat_sync_status 的 draftAction 给出)。

该接口只用于判断「上次同步写入的那份草稿还在不在」,据此决定这次是更新还是新建,因此任何拿不到确定结论的情况都按不存在处理,不会让同步失败:

响应 判定 插件的处理
2xx 且 JSON 里带 news_item(草稿详情) 草稿仍在 调用 draft/update 更新这份草稿(media_id 不变)
JSON 里 errcode 为 40007(草稿已被删除,或 media_id 不属于本公众号) 草稿已不存在 调用 draft/add 新建一份草稿,并把新的 media_id 记到该文章的任务记录上
其余全部:-1(系统繁忙)、40001 / 40014 / 42001(凭证失效/过期)、45009 / 45011(额度、频控)、代理未转发返回的 404/HTML、网络异常、空响应体 给不出结论 同样按不存在处理、直接新建草稿(日志记「校验草稿是否存在…按『不存在』处理,改为新建草稿」)

关键点:

  • 判错方向的取舍:误判「不存在」的代价只是多出一份草稿(可在公众号后台删除,且下次同步不会再命中它);误判「存在」会让 draft/update 失败、整次同步发不出去。因此只有明确取回草稿详情才算「存在」。
  • 更新失败的处理:draft/update 被 40007 拒绝(草稿已被删除,或草稿引用的封面素材已失效)时改为新建草稿;封面若确实失效,新建路径会再触发一次封面重传(见下方「素材缓存」与 retryWithFreshCover)。与素材失效无关的更新失败(如 45011 频控)照原样报错,不做无谓重试。
  • 代理未转发该接口:draft/get 拿不到结论 → 按「不存在」处理 → 每次都新建草稿(同步照常成功,只是草稿箱里会多出重复草稿)。若日志里每次同步都出现该提示,先检查「接口地址」代理是否放行了 /cgi-bin/draft/get(详见接口地址与反向代理)。
  • 校验本身不算「写操作」:draft/get 是只读的,未转发它不会报错;但只要转发了 draft/get 却没转发 draft/update,就会出现「草稿被判定为仍在 → 更新失败」的报错,因此建议两个路径一起放行。

通用错误码:含义与常见诱因

错误码 官方 errmsg 含义 本插件中的常见诱因与处理
-1 system error 系统繁忙 多为瞬时抖动,稍后重试即可
0 ok 成功 ——
40001 invalid credential AppSecret 错误,或 access_token 失效 ① 插件设置里 AppSecret 填错/需重新保存;② 同一 AppID 只维持一个有效 token,其它服务(别的插件、自建脚本、第三方平台、公众号后台调试工具)重新获取 token 会让插件缓存的 token 失效。避开互踢或减少其它服务的获取频次后重试
40002 invalid grant_type 凭证类型不合法 插件固定传 client_credential;出现多为反向代理改写/丢弃了查询参数,检查代理是否原样透传
40004 invalid media type 媒体类型不合法 同上:add_material 的 ?type=image 查询串被代理丢弃
40005 invalid file type 文件格式不受支持 uploadimg 只收 jpg/png:webp 会被插件自动转码;站点里的 gif/bmp 正文图会原样上传,需先转为 jpg/png
40006 invalid media size 素材文件大小超出限制 封面超过 10 MB,先压缩再同步
40007 invalid media_id 媒体 id 不合法 ① 复用前校验(material/get_material)收到它 = 该素材确已在微信侧被删除(不是代理故障),插件据此重新上传封面;② 建草稿时收到它(draft/add)= 封面 thumb_media_id 无效/过期、属于其它公众号或是临时素材,插件会作废该封面的缓存记录、重新上传一张并重试一次(只重试一次),无需手动处理;③ 草稿校验 / 更新时收到它(draft/get / draft/update,仅开启「重复同步更新草稿」时)= 上次写入的那份草稿已不存在(或在公众号后台被删除),插件会改为新建草稿并记录新的 media_id,无需手动处理
40009 invalid image size 图片尺寸太大 正文图片超过 1 MB(webp 转 png 后体积可能变大),先压缩或改用更小的图
40013 invalid appid AppID 不合法 插件设置里的 AppID 填写有误
40014 / 42001 invalid access_token / access_token expired token 无效或已过期 偶发属正常,插件会自动重新获取;反复出现同 40001 的互踢排查
40033 invalid charset(含 \uxxxx) 请求字符不合法 草稿字段里含 Unicode 转义字符串;精简标题/作者/摘要后重试
45110 author size out of limit 作者字段超长 该码未收录在官方返回码表,只在调用中出现。虽然 draft/add 接口文档把 author 写作「不超过 16 个字」,但服务端实际按平台规则校验——作者名上限 8 个字(编辑器计字:汉字 1 字、半角 0.5 字、emoji 2 字),超过即返回本错误。插件已把「默认作者」与文章作者都截断到 8 字,一般不会触发;若在旧版本遇到,把作者名缩短到 8 字以内即可
40113 unsupported file type 不支持的文件类型 扩展名与实际字节不符(如把 webp 存成 .png)或图片损坏;插件已按文件魔数判定并转码,仍失败请检查原图能否正常打开
40125 invalid appsecret AppSecret 不合法 重新填写并保存 AppSecret
40128 invalid media id! must be uploaded by api media_id 来源不对 同 40007:确保封面由本次同步上传的永久素材而来
40137 invalid image format 不支持的图片格式 同 40005:先转 jpg/png
40164 invalid ip, not in whitelist 调用方 IP 不在白名单 把 Halo 服务器出口 IP(使用代理时则为代理服务器 IP)加入公众号「IP 白名单」,详见接口地址与反向代理
41004 appsecret missing 缺少 secret 参数 代理丢失了查询参数,检查 location 是否原样透传
41005 / 44001 media data missing / empty media data 缺少或为空的媒体数据 封面/图片下载后为空(多为图片地址失效或返回空响应),确认该图片可被 Halo 访问
44002 / 44003 / 44004 empty post data / news data / content 请求体或正文为空 文章正文为空;补充正文后重试
45001 media size out of limit 多媒体文件超过限制 封面超过 10 MB
45002 content size out of limit 内容超过限制 正文超过 2 万字符 或 1 MB;超长文章需拆分
45003 title size out of limit 标题字段超过限制 标题超过 64 字;插件已按编辑器计字口径自动截断到 64 字,一般不会触发
45004 description size out of limit 描述字段超过限制 摘要超过 120 字;插件已按编辑器计字口径(半角 0.5 字)自动截断到 120 字,一般不会触发
45005 url size out of limit 链接字段超过限制 「原文链接」超过 1 KB,检查文章路由
45009 reach max api daily quota limit 当日接口调用额度用尽 当天同步/上传素材次数过多。可在公众平台「开发 - 接口权限」查看额度,或用 AppSecret 调用官方的「重置 API 调用次数」接口重置当日额度
45011 api minute-quota reach limit 分钟级频控 正文图片多且连续同步时容易触发;错开高峰、减少并发后重试
45034 / 45074 media file count / list size out of limit 素材数量触顶 每次同步都会新增一张封面永久素材,长期大量同步可能抵近 10 万上限;定期在公众号后台清理无用素材
45166 invalid content 正文内容不被微信接受 常见于正文里存在 <video> 且 src 非空(微信对图文中的视频数量有上限,第三方编辑器反馈为 3 个、后期群发场景为 10 个)、或插入了小程序卡片等不受支持的元素。插件已把编辑器插入的 <video> / <audio> 重建为「请点击文末『阅读原文』观看 / 收听」的提示卡片,正是为了规避该错误;若正文里仍残留视频标签(多为其他插件生成、本插件无法识别的内容),需移除后再同步。errmsg 只会给出 invalid content hint: [...] 而不指明具体元素,可凭其中的 rid 在微信开发者平台的 API 诊断工具里定位
46001 media data no exist 媒体数据不存在 thumb_media_id 对应的素材已被删除,重新同步
47001 data format error 解析 JSON/XML 失败 反向代理返回了 HTML 错误页而非微信 JSON,见下方非 errcode 类失败
48001 api unauthorized 接口未授权 公众号未认证或未开通素材管理 / 草稿箱权限;个人订阅号常见
48004 api forbidden for irregularities 接口被封禁 登录 mp.weixin.qq.com 查看站内信与处罚详情
48015 该账号无留言功能权限 无留言功能 插件设置的「留言设置」选了「所有人可留言 / 已关注的人可留言」,但该号不具备留言功能;改为「关闭留言」
50002 user limited 用户受限(账号被冻结或注销) 登录公众号后台确认账号状态

容易被忽略的错误码(接口专属 / 只有英文说明)

以下错误码要么只写在某个接口的「错误码」小节、未出现在公共/通用返回码表(如 40243、50004、50007、53404 系列),要么虽已收录但官方说明过于简略、缺少中英文对照(如 1003、45035、89503 系列)。它们在同步场景里都真实会出现:

错误码 实践中的含义 来源与处理
40243 AppSecret 已被冻结 接口专属:公众号中冻结了 AppSecret,解冻后再同步
50004 禁止使用 token 接口 接口专属:账号被限制调用 token 接口,需联系公众号管理员核实
50007 账号已冻结 接口专属:账号处于冻结/注销状态,恢复后再同步
53404 / 53405 / 53406 带货能力被限制 / 商品信息有误 / 未开通带货能力 接口专属(draft/add 商品字段):插件不传商品信息,正常不会触发
1003 POST 参数非法 通用表已收录但只有中文名:多见于 multipart 报文被反向代理改写(未透传 Content-Length / boundary、client_max_body_size 过小被截断)。检查代理是否原样转发请求体
45021 某字段可能超出长度限制 多由 author(≤ 8 字)这类短字段超限引起;插件已对作者自动截断到 8 字(截断时日志有提示),一般不会触发
45035 access clientip is not registered, not in ip-white-list 与 40164 同属 IP 白名单校验,个别账号/网段返回这个英文变体;同样去核对白名单 IP
89503 此次调用需要管理员确认 出口 IP 未加入白名单、走了「管理员确认」流程:在公众号后台确认该 IP 的调用,或直接加入白名单
89506 / 89507 该 IP 的调用已被公众号管理员拒绝 分别需等待 24 小时 / 1 小时 后重试;长期调用请先与管理员沟通并加入白名单
空 errcode / 返回非 JSON 代理返回 HTML 或网关错误页 见下方非 errcode 类失败

微信错误码会随版本调整,若上表未覆盖,可在微信开发者平台用官方的「智能 API 诊断」工具,配合响应里的 rid 辅助定位。

非 errcode 类失败

插件的失败提示里若没有 errcode,通常是网络、代理或本地安全策略拦截:

提示 原因与处理
「解析微信响应失败:<html>...」 收到了 Nginx / 1Panel / CDN 的错误页(常见 403、404、502)。检查 proxy 的 location 是否放行全部 7 个接口路径(含 material/get_material、draft/get、draft/update)、proxy_pass 是否指向 https://api.weixin.qq.com
日志「校验给不出结论(代理未转发该接口 / 限流 / 网络异常等)」 复用封面前调用 material/get_material 没拿到结论,插件按保守策略重新上传封面(同步不受影响,也不会打断任务)。若每次同步都出现,先查代理是否放行 /cgi-bin/material/get_material(漏掉它不报错,但每次同步会新增一份封面永久素材),其次看是否被 45009 / 45011 限流或凭证失效(40001 / 42001)
412 Precondition Failed 微信网关校验 Content-Length,旧版本 Spring 6.1+ 分块传输曾触发,当前版本已通过手动拼 multipart 并显式设置 Content-Length 修复;若仍出现,确认代理未把请求体重新分块或改写
HTTP 501 Not Implemented,响应体是 waf.tencent.com 的 501 拦截页 请求被腾讯云 WAF 按内容风控拦截,根本没到微信接口(所以既没有 errcode 也没有 rid)。微信开放社区有同类记录:请求体里出现 ORDER BY 1、DBMS_PIPE.RECEIVE_MESSAGE() 等注入特征串即被拦。命中的是正文 / 标题里的某个特征串,插件侧无法改写;插件已能兜住:更新草稿时若被这样拦下,会自动改为新建草稿(draft/add 走的是另一套规则,实测存在「更新被拦、新建放行」的组合),因此同步不会失败,只是草稿箱里会多出一份旧草稿(任务记录里的实际动作记为 create,见 MCP 工具;message 里保留 WAF 拦截页开头与「被腾讯云 WAF…拦截」的说明)。也可把 接口地址 改为自建反向代理(换一个出口 IP)再试
其它 4xx / 5xx 状态行(如 502 Bad Gateway) 微信的业务错误一律是 HTTP 200 + errcode,出现状态行说明请求被网关 / 代理层挡下、没到接口本身。失败提示里会带上「状态行 + 出错接口 + 响应体摘要」(形如 微信接口返回 HTTP 502 Bad Gateway(POST /cgi-bin/draft/add):…):响应体若是自建代理的报文,就查该 location 是否放行全部接口路径(含 material/get_material、draft/get、draft/update)
TLS 握手失败 / SSLHandshakeException 代理到上游未开启 SNI(proxy_ssl_server_name on; 与 proxy_ssl_name api.weixin.qq.com;)
请求超时、连接被重置 调大代理的 proxy_read_timeout、client_max_body_size;图片多、带宽低时同步耗时本就较长,可等待任务自动出结果
「已拒绝下载:目标发生重定向 xxx」 插件的 SSRF 防护禁止跟随跳转:图片地址 301/302 到其它域名。改用不跳转的直链
封面/图片下载失败地址指向内网 同上:命中 SSRF 默认策略。公网图片请保证可直接访问,内网图片按内网部署与图片下载白名单放行

权限说明

插件提供了名为 发布到微信公众号 的角色模板(在权限列表中归属「微信公众号同步」分组),用于把同步能力授予超级管理员以外的用户。

  • 默认情况下,插件的自定义接口仅 超级管理员 可访问。
  • 若要让其他角色也能同步,请在 Halo「用户与权限 - 角色」中编辑目标角色,勾选 微信公众号同步 → 发布到微信公众号 权限。
  • 该权限同时控制两个层面:
    • 接口访问:POST .../sync(提交同步)、POST .../validate(同步前预检)、POST .../preview(生成排版预览与草稿元信息)与 GET .../status(查询状态);
    • 界面展示:未授权用户在文章列表的操作菜单中看不到「同步到微信公众号」入口。

工作原理

Console 侧:点击「同步到微信公众号」后先调用 POST /validate 预检(微信配置、封面图、文章是否正在同步中,有问题直接提示并中止),通过后打开预览弹窗(POST /preview),用户确认后才提交同步任务。

一次同步的完整流程(响应式、后台异步执行):

解析外部访问地址(Halo 基本设置,回退 ExternalUrlSupplier)
        │
        ▼
获取并缓存 access_token
        │
        ▼
上传封面为永久图片素材(add_material?type=image,webp 自动转码;命中素材缓存则复用 media_id)
        │
        ▼
转存正文图片(解析 HTML → uploadimg → 替换为微信图片地址;命中素材缓存则复用微信图片地址)
        │
        ▼
美化正文排版(按标签注入微信友好的内联样式,代码块重建为微信原生结构、列表结构空白压紧(避免微信把列表项之间的换行当成空 `<li>` 行)、表格按配置的宽度模式渲染(默认保持比例、列宽超屏时支持横向滚动),分栏卡片与画廊按各自配置重建版式(表格或独占一行),折叠内容(`<details>`)重建为静态展开的卡片,安全清理 script / style / link / 事件属性(含粘贴、导入时被转义为纯文本残留的 `<style>`/`<script>` 块))
        │
        ▼
处理正文附件链接(图片型附件转存为微信图片;非图片附件不调用微信接口,链接替换为原始地址文本)
        │
        ▼
保存图文草稿(含原文链接与留言设置)→ 返回草稿 media_id
        │  · 首次同步 / 上次那份草稿已不在 / 关闭了「重复同步更新草稿」:draft/add 新建
        │  · 重复同步且上次那份草稿仍在:draft/update 更新(media_id 不变)
        │  · draft/get 校验出错:按「不存在」处理,走新建
        │  · draft/update 被拒(40007 草稿/封面失效,或网关/WAF 拦截):改为新建
        │
        ▼
写入同步任务记录(`WechatSyncTask` 自定义模型:PENDING / SUCCESS / FAILED + 任务输入快照)
  • 同步任务与状态持久化为名为 WechatSyncTask 的 Halo 自定义模型(每篇文章一条,保存在 Halo 数据库中),供文章列表渲染状态列;任务落到成功/失败终态后会清空正文输入快照,不长期占用数据库空间。任务记录上另留一份提交时的文章标题(spec.postTitle),清空快照后仍能在扩展记录里认出这条任务属于哪篇文章(文章 name 是 Halo 生成的随机串,单看它认不出文章)。
  • 文章列表在存在「同步中」记录时会自动轮询状态(约每 5 秒一次,单轮最长 30 分钟),直到任务变为成功或失败;低带宽 + 大量图片的长耗时同步同样会刷新到最终结果。
  • 支持同时同步多篇文章:各任务相互独立、并发执行;任务记录独立更新(带乐观锁冲突重试),多篇文章同时完成也不会互相覆盖状态。
  • 同一篇文章在「同步中」时重复提交会被拒绝(返回 409;点击同步时的预检也会提前拦截并提示),避免重复上传素材与重复创建草稿。
  • 插件(或 Halo 服务)重启时,未完成的同步任务会被自动恢复:启动后按持久化的任务输入自动重放一遍完整同步流程(封面与正文图片会重新转存,已上传过的素材直接复用缓存、不会重复上传),期间状态继续显示「同步中」(悬停可看到「正在自动恢复」)。重放会带上该文章上次成功同步写入的草稿 media_id,因此只要那份草稿还在就是更新草稿而不是又新建一份。注意:恢复是「用缓存输入重新执行」而非断点续传——仅当文章从未成功同步过、而中断恰好发生在草稿创建成功之后、状态写回之前时,重放才会多出一份草稿,按需在公众号后台删除即可。单个任务最多执行 3 次(首次提交 + 中断后的自动恢复),仍未能完成时标记为失败,需手动重新同步。
  • 从旧版本升级时,原存放在 ConfigMap(wechat-official-sync-records)中的历史同步状态会自动迁移到任务模型(只补缺失、不覆盖已有任务);旧记录中遗留的「同步中」因没有可重放的输入,会按「因插件升级中断」标记为失败。迁移全部完成后旧 ConfigMap 会被自动删除(后续启动不再重复扫描;若个别记录迁移失败则保留旧数据,下次启动自动重试)。
  • access_token 带内存缓存并在到期前自动刷新,避免频繁请求。
  • 素材缓存:封面(永久图片素材)与正文图片上传前,先按「公众号 + 上传接口 + 文件内容 SHA-256 + 图片归一化规则版本」查本地缓存;命中且校验到微信侧资源仍然存在时直接复用上次返回的 media_id / 图片地址,不再重复上传。复用前会校验微信侧资源是否还在,校验依据与复用值一致:正文图片复用的就是它的微信图片地址,就校验这个地址(HEAD 一次,直连、不经「接口地址」代理);永久素材复用的是 media_id,就用 material/get_material(POST /cgi-bin/material/get_material,与其他接口一样经设置的「接口地址」转发)查素材本身——素材图片的 CDN 地址不能当依据(素材在公众号后台被删除后地址往往仍可访问,据此复用会拿到已失效的 media_id)。只有「明确存在」才复用;明确不存在(地址 404/410、或微信明确回 40007 invalid media_id)与给不出结论(代理没转发该接口、限流、网络异常、3xx/5xx 等)都重新上传并覆盖缓存——判定不出结论时重传最多多传一份(正文图片不占素材库,永久素材多一份),而复用一个已失效的资源会让整篇草稿发不出去,两边代价不对等;因此日志里出现「校验给不出结论…重新上传」是正常降级,若它每次同步都出现,多半是「接口地址」代理没有转发 material/get_material。另外,草稿真被微信以 40007 invalid media_id 拒绝时,插件会作废该封面的缓存记录、重新上传一张再建一次草稿(只重试一次),此后同步同一张封面即复用新的 media_id,不会被同一个失效 id 反复挡住。永久图片素材会占用微信素材库(有数量上限),同一张图因此只会被上传一次——换个文件名、换篇文章、换个来源地址都能命中。指纹取的是格式转换前的原始文件字节(转换后的字节依赖 JDK 图像编解码器实现,跨 JDK 版本并不稳定,用它做键会让升级后缓存全部失效);「归一化规则版本」则保证插件调整图片转码规则后旧缓存自动失效、按新规则重新上传。已上传的素材若在公众号后台被删除,下次同步会校验到失效并自动重新上传;缓存不可用(目录不可写等)时只记日志并自动关闭缓存,同步流程退回「每次都上传」的行为,不影响文章发布。缓存库为 SQLite,路径 <Halo 工作目录>/plugins/plugin-wechat-official-sync/wechat-media-cache.sqlite(插件包是同级的 <插件名>-<版本>.jar,升级只覆盖 jar、不会动这个目录;库用内建 user_version 记录 schema 版本,后续表结构变更可按版本追加迁移;库由每天 0 点的「缓存清理计划任务」按「缓存保留天数」清理,避免随同步无限增长;清理只删「超过保留期未再被使用」的记录,仍在被复用的缓存不会被误删)。
  • 缓存备份:媒体缓存库(SQLite)固定每天 1 点自动备份一次(与 0 点的缓存清理错开时段),备份文件放在库文件同级的 <Halo 工作目录>/plugins/plugin-wechat-official-sync/backups/ 目录下,文件名形如 wechat-media-cache-20260922010000.sqlite,只保留最新 3 份、更早的自动删除。备份用 SQLite 的 VACUUM INTO 导出一份一致性快照,而不是直接拷贝库文件——库正在写入时拷贝可能拿到「写了一半」的中间状态,快照则由 SQLite 内部按一致性读事务导出;快照里数据与表结构都在(建表 DDL、唯一索引、schema 版本一应俱全),必要时可直接当库文件打开查看。快照先写成 *.tmp、写完才改名为备份名,因此 backups 里出现的备份文件一定是完整可用的——中途失败只会留下一个可安全删除的 .tmp 文件,不会被误当成有效备份、也不会白占一个保留名额。备份失败(如目录不可写、库被长时间独占)只记日志告警,不影响缓存读写与文章同步。
  • 草稿字段超长时主动截断,避免整次同步被 draft/add 拒绝:标题 64 字、作者 8 字、摘要 120 字,计字统一按公众号编辑器口径(汉字 1 字、半角字符 0.5 字、emoji 2 字);任一字段被截断都会在服务端日志输出 warn,含字段名、原始长度、上限与截断后的值,便于核对该次提交的实际内容。
  • 所有微信接口请求都发往设置的 接口地址(留空为官方 https://api.weixin.qq.com),便于经自建反向代理转发。

安全说明

敏感凭据存储

  • AppSecret 不落明文:AppSecret 通过 Halo 官方的 Secret 组件($formkit: secret)配置,其值保存在独立的 Secret 资源中,插件的设置项(ConfigMap)只保存该 Secret 的名称,不保存也不返回任何明文。
  • 服务端通过 ReactiveExtensionClient 按名称读取该 Secret,在内存中解析出 AppSecret 后仅用于向微信换取 access_token;不写入日志、异常消息、资源状态或任何持久化位置。
  • 面向用户的角色模板(发布到微信公众号)不授予 Secret 的读取权限,读取仅发生在插件服务端内部。
  • 同步任务记录(WechatSyncTask)只保存文章输入(标题、摘要、正文 HTML、封面地址等)与同步状态,不包含 AppSecret 等任何凭据。

日志与错误信息的脱敏

同步相关的失败原因会同时出现在服务端日志、任务记录(文章列表悬停展示)与 MCP 工具返回中,因此插件对可能携带凭据的文本做了统一脱敏:

  • 异常源头脱敏:WebClient 的响应异常默认会把「请求方法 + 完整 URI(含查询串)」写进异常 message,而微信接口的查询串里带着 access_token(获取 token 的请求还带 secret);插件在 WechatMpClient 中把这些异常统一换成脱敏后的消息,因此日志、异常堆栈、任务记录与 MCP 返回里都不会出现凭据(异常类型、errcode 与原始 cause 仍然保留);
  • 落库前再兜一层:SyncRecord.failed(...) 在持久化失败原因前再次脱敏,避免其它来源的文本把凭据写进任务记录;
  • URL 脱敏:图片 / 附件地址(可能带签名参数)在日志与错误消息中按 sign=*** 处理,路径与其它的参数保留,便于定位问题;
  • 正文不入日志:正文 HTML 属用户内容,日志与 MCP 调用日志中只记录长度、不记录内容;
  • 刻意不脱敏的字段:media_id / thumb_media_id 是资源标识而非凭据,保留原值以便排查素材失效;appid、文章标题与 postName 同样保留。

反向代理(Nginx / 1Panel / 网关)自身的访问日志默认会记录含 access_token 的完整查询串,需在代理侧另行处理,见代理侧的日志脱敏。

出站图片下载的 SSRF 防护

封面图与正文图片的地址来自用户可控的请求体与正文 HTML,插件会从 Halo 服务端主动拉取这些地址,属于典型的 SSRF 面。为此在统一下载入口施加了纵深防御:

  1. URL 结构校验:用 URI 解析器处理地址,仅允许 http/https,必须含合法主机名,禁止携带用户名/密码(userinfo)等易被用于绕过的结构。
  2. 解析后的 IP 校验:发起下载前解析目标主机的全部 IPv4/IPv6 地址,逐一拒绝环回、私网(站点本地)、链路本地、组播、未指定地址,以及运营商级 NAT(100.64.0.0/10,含云平台元数据地址)、IPv6 唯一本地地址(fc00::/7)与各类测试网段。
  3. 实际连接目标校验:下载客户端使用自定义地址解析器,在 Netty 真正建立连接前对解析到的每个 IP 再次执行同一套限制,堵住「预检解析到公网、连接时解析到内网」的 DNS rebinding(TOCTOU)时间差。
  4. 禁止重定向:下载客户端关闭自动重定向,遇到 3xx 直接拒绝,防止公网地址跳转到内网绕过校验。
  5. 超时与体积上限:为连接、读写与整体响应设置超时,并限制响应体最大 16 MB,避免出站请求长期占用连接、事件循环或内存。

说明:默认会拒绝内网/环回地址,因此封面与正文图片需为公网可访问的地址。若使用相对路径,请在 Halo「基本设置 - 外部访问地址」中配置可公网访问的站点地址(详见配置)。若 Halo 本身部署在内网、图片也位于内网地址,请按内网部署与图片下载白名单配置白名单。

内网部署与图片下载白名单

防 SSRF 的默认策略会拒绝一切指向环回、内网、链路本地与云元数据的地址。这对公网站点是安全的,但对部署在内网的 Halo(封面/正文图片的绝对地址解析到内网 IP)会造成同步失败。为此插件提供了一个显式、默认关闭的内网白名单,而非「一键放开全部内网」的开关(后者会重新打开审核所指的 SSRF 风险)。

配置位置:插件设置 →「微信公众号」→「图片下载内网白名单」(多行文本框)。

默认行为:留空 = 不放行任何内网地址(等价于开关关闭,最安全,也是过审的默认状态)。

支持的四类条目(每行一个,以 # 开头的行或行内 # 之后的内容为注释):

类型 示例 匹配含义
域名精确 halo.internal 仅放行主机名为 halo.internal 的目标
子域通配 *.example.com 放行 example.com 及其全部子域(如 img.example.com)
单个 IP 192.168.1.10、fd00::1 按 /32、/128 处理,仅放行该 IP
CIDR 网段 192.168.0.0/16、10.0.0.0/8、fd00::/8 放行整个网段

配置示例(内网 Halo,站点地址为 http://192.168.1.20:8090,图片均由该服务器提供):

# 只信任自站内网地址,其余内网/元数据地址仍拒绝
192.168.1.20
# 若整个内网段都有可信图片源,可改用网段(谨慎,范围越大风险越高):
# 192.168.1.0/24

若图片由内网的另一台图床/对象存储提供,则把其域名或 IP/网段加入即可。

安全提示:

  • 白名单仅放宽“允许访问受限网段”这一条,URL 结构校验(仅 http/https、禁 userinfo)、禁止重定向、超时与响应体上限仍然生效。
  • 请按最小必要原则配置:能用单个 IP 就不用 /24,能用 /24 就不用 /8;切勿为方便而加入 0.0.0.0/0 或直接写入云元数据网段。
  • 白名单属全局插件配置,需管理员权限才能修改;面向普通用户的角色无法通过同步请求影响它。
  • 白名单在预检与连接期两处一致生效:即便域名重新解析(DNS rebinding),只要目标不在白名单且为受限地址,连接仍会被拒绝。

接口

插件对外提供以下自定义接口(需登录控制台,随插件权限校验):

方法 路径 说明
POST /apis/api.wechat-sync.halo.run/v1alpha1/sync 提交同步任务,立即返回 202,后台异步执行;同一文章「同步中」时重复提交返回 409
POST /apis/api.wechat-sync.halo.run/v1alpha1/validate 同步前预检:返回 {"errors": [...]},校验微信配置(AppID / AppSecret)、封面图与文章是否正在同步中;空数组表示通过(不调用微信接口、不落库)
POST /apis/api.wechat-sync.halo.run/v1alpha1/preview 按与同步一致的规则美化正文,并返回上传后将使用的标题(截断到 64 字)、作者(8 字)、摘要(120 字)、原文链接与留言设置,以及 truncatedFields(被截断的字段名:title / author / digest),供确认前预览与截断标识(不调用微信接口、不落库)
GET /apis/api.wechat-sync.halo.run/v1alpha1/status 返回全部文章的最近同步状态,键为文章 name

POST /sync 请求体示例:

{
  "postName": "my-post",
  "title": "文章标题",
  "digest": "摘要",
  "content": "<p>渲染后的正文 HTML</p>",
  "cover": "/upload/cover.jpg",
  "author": "作者名",
  "permalink": "/archives/my-post"
}

MCP 工具(可选,需安装 MCP Server 插件)

插件可选地与 Halo MCP Server 集成,向 AI 助手贡献四个工具。未安装 MCP Server 时插件照常安装、启动与使用(依赖在 plugin.yaml 中以 mcp-server? 声明为可选),只是这四个工具不会出现。

工具(本地名) 类型 参数 说明
wechat_sync_preview 只读 postName 获取预览信息:按与同步一致的规则美化正文,返回美化后的正文 HTML(预览所需的样式已内联在元素上,不带 <style> 标签,图片与链接也是完整地址,因此可直接渲染、也可在客户端清洗或 AI 转述 HTML 时保持正确;正文里的相对地址已按站点「外部访问地址」补全为完整链接)、上传后实际使用的标题(64 字上限)、摘要(120 字)、作者(8 字)、原文链接(阅读原文)与留言设置,以及因超过微信长度上限被截断的字段名(长度按微信计字口径折算:汉字 / 全角字符计 1 字、半角字符计 0.5 字、emoji 计 2 字);不调用微信接口、不写任何数据,可反复调用
wechat_sync_submit 写操作 postName 提交同步到微信:先做提交前预检(微信配置、封面图、文章是否正在同步中),通过后创建后台同步任务并立即返回;同步时封面与正文图片会自动转存到微信素材库、正文按公众号排版美化,完成后可在公众号草稿箱看到草稿(开启「重复同步更新草稿」时,同一篇文章重复提交会先校验上次写入的那份草稿是否还在——在则更新它、不在则新建,不会堆出重复草稿;关闭该开关则每次新建)。返回值不含「本次是更新还是新建」:提交时虽会实查上次那份草稿是否还在微信侧(draft/get),但那只是预判——执行阶段还可能回退为新建(草稿在执行前被删除,或更新被微信网关 / WAF 拒绝),回传该字段会让 AI 把预判当结果答复失真。本次到底是新建还是更新,请用 wechat_sync_status 的 draftAction 作答(工具描述与结果文本都写明了这一点)
wechat_sync_status 只读 postName 查询同步状态:返回该文章最近一次同步的状态、说明与实际草稿动作——PENDING(已提交、正在后台执行)/ SUCCESS(已写入公众号草稿箱)/ FAILED(失败,message 为微信返回的失败原因)/ NONE(尚未同步过);SUCCESS 时必须按 draftAction 字段回答「新建还是更新」(工具描述里下了同样指令):update = 更新了该文章已有的那份草稿(草稿箱里没有多出一份),create = 新建了一份草稿(草稿箱里多出一份;原草稿被删除、或更新被微信侧拒绝后回退新建都记它),空串 = 尚未成功同步过;不要自行推断、也不要沿用提交时的预计;只读取任务记录,不调用微信接口、不写任何数据,提交同步后可用它轮询结果
wechat_cache_cleanup 写操作 无 清理素材缓存:立即执行一次素材缓存清理(与每天 0 点的计划任务同一套规则),返回本次删除条数与生效的保留策略——deletedRecords(删除条数)、remainingRecords(剩余记录数)、retentionDays(生效的保留策略:保留天数,如 "30";「全部保留」时为 never)、cutoff(判定时间);只删除「超过保留期且最近未被使用」的记录,仍被复用的缓存不会误删,也不影响每天 0 点的自动清理

前三个工具只接收一个参数 postName(文章的 metadata.name),内部按与 Console 完全一致的规则取用文章字段(标题、摘要、封面、作者、原文链接、渲染后的正文),因此 MCP 调用的效果与在 Console 点「同步到微信公众号」相同;wechat_cache_cleanup 不需要参数。

启用方式

  1. 在 Halo 应用市场安装并启用 MCP Server 插件(mcp-server,>=1.0.0 & <2.0.0):商店页面 https://www.halo.run/store/apps/app-ybv96zol;
  2. 在「工具 → MCP 服务」的访问密钥中,把这四个工具(按需选择)勾选进该密钥可用的工具列表——新贡献的工具不会自动加入已有密钥,需管理员手动选择;
  3. 用该密钥在 MCP 客户端调用 tools/list / tools/call。

说明

  • 工具名由 MCP Server 按插件归属自动拼接为协议名,调用时以 tools/list 返回的名称为准:编码后的插件 ID + __ + 本地工具名,例如 plugin-wechat-official-sync__wechat_sync_preview。其中 __ 是 MCP Server 的固定分隔符(双下划线),与本地工具名里的单下划线(wechat_sync_preview 的 _)是两回事——插件 ID 里的 - 属白名单字符会原样保留,其余字符(下划线、点、中文等)会被转义成 _hhhhhh,因此 __ 在协议名中不会产生歧义;
  • 四个工具都声明了 inputSchema 与 outputSchema:MCP Server 会在调用前校验参数、在成功返回后按 outputSchema 校验结构化结果(失败结果不参与该校验),因此工具的输出字段是稳定契约;
  • wechat_sync_preview 返回的 content 把预览所需的样式内联在元素上(微信原生代码块的行号列与代码行排版、分栏卡片/画廊重建出的布局表格标记)——MCP 客户端(AI 对话界面等)没有 Console 预览的宿主页面样式,而这些内容靠样式表才能立起来(代码块行号原本由 CSS 计数器生成)。不下发 <style> 样式块是有意的:客户端做 HTML 清洗、或 AI 转述时,<style> 标签最容易被丢掉,一丢预览就散架;内联后「元素 + 自身的行内样式」即可正确渲染,代码块行号也改写成了字面数字(不再依赖 CSS 计数器)。这些样式只用于预览渲染,提交到微信的草稿仍是纯行内样式的正文。此外,content 里的图片、链接等相对地址已按站点「外部访问地址」补全为完整链接——Console 预览渲染在站点页面内,相对地址由页面自动解析,而 MCP 客户端拿到的是脱离站点的 HTML 片段,补全后图片才能正常显示(只改预览返回的内容,提交到微信的草稿不受影响);内容已自带全部样式,MCP 客户端(AI 助手)只需原样输出:不要改写、精简、重新排版或另加样式(工具描述、content 字段说明与工具结果说明三处都写明了这一点),否则渲染结果会与 Console 后台预览、最终草稿不一致;
  • wechat_sync_preview 返回的 title / digest / author 就是最终写入草稿的值;完整规则写在工具描述里(预览与提交两个工具都写了:长度按微信计字口径折算——汉字 / 全角字符计 1 字、半角字符计 0.5 字、emoji 等增补字符计 2 字,按整字符取舍,超过上限的部分自动截断),各字段的说明里也带上自己的上限(标题 64 字、作者 8 字、摘要 120 字)与缩短后的同一口径,因此单看字段说明即可解释「为什么预览里的值比文章里的短」,被截断的字段名在 truncatedFields 中列出;
  • 权限回调要求调用方已认证(MCP 访问密钥归属某个 Halo 用户),匿名调用会被拒绝;更细的授权通过「角色 - 微信公众号同步 - 发布到微信公众号」与访问密钥的工具白名单控制(读取 AppSecret 等仍只发生在插件服务端内部);
  • wechat_sync_submit 是异步的:返回 PENDING 表示任务已落库并在后台执行,用 wechat_sync_status 轮询即可拿到最终结果(与文章列表状态列同源);同一篇文章在「同步中」时重复提交会被拒绝(错误码 CONFLICT),预检不通过时返回 PRECONDITION_FAILED 且不会产生任务记录;
  • wechat_sync_submit 不返回草稿动作:为免「任务记录里还留着草稿 media_id、但草稿其实已在公众号后台被删除」这类误判,服务端在提交时会实查该文章上次写入的那份草稿是否还在微信侧(只读的 draft/get;该文章还没有草稿、或关闭了「重复同步更新草稿」时不做这次查询),结论只作为说明文案里的「预计更新 / 预计新建」——执行阶段还会再校验一次(其间草稿仍可能被删除),且更新被微信侧拒绝(网关 / WAF 拦截、封面素材失效 40007)时会改为新建,把它当结论回答就会失真。实际动作由 wechat_sync_status 的 draftAction 给出(update 更新既有草稿 / create 新建一份草稿,含回退新建),AI 助手不需要理解配置项,照这个字段说「本次新建了草稿 / 本次更新了草稿」即可,不要笼统回一句「已同步成功」;为此状态工具的描述里就下了指令(「必须按 draftAction 明说本次是新建还是更新,不要自行推断」),字段说明与结果文本同样写明——MCP 里 outputSchema 的字段说明主要用于结果校验,多数客户端不会把字段描述放进模型的上下文;若客户端又只把工具结果的文本(content)喂给模型、不传 structuredContent,模型连字段都看不到。因此状态查询的结果文本直接带上状态、实际动作(「请告诉用户:本次新建草稿 / 本次更新既有草稿(以 draftAction 为准,不要自行推断)」)与说明,模型照抄即可;字段本身仍照常返回,供结构化消费的客户端使用;
  • wechat_cache_cleanup 与「缓存清理计划任务」共用同一套清理逻辑:「缓存保留天数」在每次执行时读取(与插件设置一致,改完无需重启),把它留空、填 0 或负数(即「全部保留」)时不做删除(返回 deletedRecords: 0、retentionDays: "never"、cutoff: "");
  • 返回给 MCP 客户端的错误使用稳定错误码:INVALID_ARGUMENT(参数不合法)、NOT_FOUND(文章不存在)、CONFLICT(正在同步中)、PRECONDITION_FAILED(预检未通过)、INTERNAL_ERROR(其它异常,详情见服务端日志)。

开发

# 克隆仓库
git clone https://github.com/hcjike/plugin-wechat-official-sync.git
cd plugin-wechat-official-sync

# 启动一个集成了本插件的 Halo 开发实例
./gradlew haloServer

# 前端开发(另开终端)
cd ui
pnpm install
pnpm dev

Windows 下将 ./gradlew 替换为 ./gradlew.bat,pnpm 若无法直接调用可用 pnpm.cmd。

构建

./gradlew build

构建完成后,插件 jar 位于 build/libs/ 目录。该任务会一并构建前端(ui)并把产物打包进插件 jar。

技术栈

  • 后端:Java 21、Spring WebFlux(响应式 WebClient)、Halo Plugin API、jsoup(解析正文 HTML、注入内联样式美化排版)、TwelveMonkeys ImageIO WebP(webp 解码)、SQLite JDBC(素材上传结果缓存)、Halo MCP Server API(run.halo.mcpserver:api,仅编译期依赖,可选集成见 MCP 工具)
  • 前端:Vue 3、TypeScript、Vite、@halo-dev/components、@halo-dev/api-client、unplugin-icons
  • 构建:Gradle + run.halo.plugin.devtools、pnpm

常见问题(FAQ)

Q:同步失败,提示「微信公众号草稿必须包含封面图」? 微信草稿强制要求封面。请为文章设置封面后重试;若封面是相对路径,请确认已在 Halo 配置「外部访问地址」。当前版本在点击「同步到微信公众号」时也会先行预检封面,这类问题通常在打开预览前就会直接提示。

Q:点击「同步到微信公众号」后直接弹出错误提示、没有打开预览窗? 这是提交前的自动预检在提前报告「提交前即可发现」的已知问题(微信配置、封面图、文章是否正在同步中),有问题会直接提示并中止、不会产生同步任务。常见提示与处理:

  • 「插件尚未配置微信公众号信息」/「未找到保存 AppSecret 的 Secret」等:前往插件「设置 - 微信公众号」填写或重新保存 AppID / AppSecret;
  • 「当前文章未设置封面图」:为文章设置封面后再试(微信草稿强制要求封面,无法省略);
  • 「无法解析封面图地址」:封面为相对路径,请先在 Halo「基本设置 - 外部访问地址」配置站点公网地址;
  • 「该文章正在同步中」:等待当前任务完成后再试。

预检只读——不调用微信接口、不写任何记录;通过后才会进入预览与确认同步流程。

Q:提示获取 access_token 失败? 检查 AppID / AppSecret 是否正确,以及是否已在公众平台配置 IP 白名单(Halo 服务器公网出口 IP)。

Q:Halo 服务器没有固定公网 IP,白名单总是失效怎么办? 用一台有固定公网 IP 的服务器做反向代理,把代理服务器的 IP 加入微信白名单,并在插件「接口地址」中填入代理地址。详见接口地址与反向代理。

Q:提示接口无权限 / 48001 等错误? 草稿箱、素材管理等接口需要已认证的公众号并开通对应权限,未认证或个人订阅号可能无法调用。

Q:同步失败,红色 Logo 悬停显示一串 errcode,怎么查? 先记录下 errcode 与 errmsg,再到 微信接口错误码速查 对照:该节按插件用到的 7 个接口整理了微信官方的限制与接口级错误码、通用错误码的常见诱因,以及官方文档未列出但实践中会遇到的错误码(如 40243 AppSecret 被冻结、89503 需管理员确认、1003 multipart 被代理改写等)。若提示里没有 errcode,多半是网络、反向代理或图片下载环节的问题,可对照「非 errcode 类失败」一节排查。注意其中 material/get_material 只用于封面复用前的校验、draft/get 只用于重复同步前的草稿校验,它们的错误不会让同步失败,详见「素材校验接口 material/get_material 的判定规则」与「草稿校验接口 draft/get 的判定规则」。

Q:日志反复出现「校验给不出结论…重新上传」,封面每次都重新上传一份素材? 封面复用前插件会调用 material/get_material 确认缓存里的 media_id 是否还在微信侧;拿不到结论时保守重传(同步不受影响,但每次会多占一份永久素材配额)。若每次同步都出现,多半是「接口地址」代理没有放行 /cgi-bin/material/get_material(也可能被限流、凭证失效或网络异常):按接口地址与反向代理补上该路径即可。反之,若日志提示「微信侧已不存在」,说明该素材确实已被删除(在公众号后台清理过素材),此时重传是预期行为,会顺带刷新缓存。

Q:封面或图片是 webp,能同步吗? 可以。插件会自动把 webp 解码并重编码为微信支持的 png/jpg 再上传。

Q:报 412 Precondition Failed? 这是历史版本的已知问题(微信校验 Content-Length,而 Spring 6.1+ 默认分块传输)。当前版本已通过手动构造 multipart 报文并显式设置 Content-Length 修复。

Q:正文里的图片同步后不显示? 微信会过滤文章正文中的外部图片链接。插件已通过 media/uploadimg 将正文图片转存到微信域名;若个别图片转存失败会保留原地址,请确认这些图片可被 Halo 服务器正常访问。

Q:文章里的附件(pdf、zip 等下载链接)同步后怎么变成一串地址了? 这是有意为之:微信图文里的外链不可点击,非图片文件也无法转存到微信素材库(uploadimg 只收图片,硬传只会换来 40005 / 40113 报错)。插件在提交草稿前会对正文里的附件链接逐个判定——按真实字节能识别为微信支持的图片(jpg/png/gif/bmp,webp 自动转码)就转存为微信图片显示;其余(pdf / zip 等非图片、字节与图片不符、下载失败)不调用微信接口,把链接改为纯文本。纯文本显示原始地址(默认)还是链接自身的文字,可在插件设置 正文美化 → 附件链接显示 中切换。判断为「附件」的依据是站点附件库路径(/upload/...)、URL 末段带文件扩展名或链接带 download 属性;站内文章路由、无文件扩展名的网页链接不受影响,原样保留。

Q:预览里的效果和草稿最终效果一致吗?

预览展示的正文美化结果,以及标题、作者、原文链接、留言设置,均来自与同步流程完全相同的解析规则(标题按微信 64 字上限截断后展示;作者优先插件设置的「默认作者」、留空回退文章作者,两者都截断到 8 字;摘要截断到 120 字;原文链接由站点「外部访问地址」与文章路由拼接;留言设置取插件配置);标题、作者、摘要中任一字段被截断时,预览都会在该字段旁显示「已截断」标识(悬停可看上限与说明),并在草稿元信息下方给出长度检查结论(无截断时显示「均在上限内」,避免误判),版式按手机端图文观感模拟;预览的正文区域采用样式隔离渲染(Shadow DOM),不受 Console 页面自身样式影响、也不会把正文样式带到页面,正文只按自身的行内样式呈现;分栏卡片/画廊在「表格」版式下重建出的布局表格会以浅灰细边框标注(仅预览标记、不会提交到草稿),相邻卡片/画廊之间留出间距,每个分栏卡片/画廊各对应一个独立表格,便于核对并排结构是否生效;分栏或画廊选择「独占一行」版式时该项不会生成布局表格,会按每栏、每图各占一行展示。正文图片与图片型附件在提交后才会真正转存(预览中仍显示原图地址与原链接,其中相对地址已按站点「外部访问地址」补全为完整链接,便于 MCP 等脱离站点的客户端直接加载图片;草稿侧不受影响);确定提交不到微信的附件链接(pdf、zip 等非图片)在预览中就已按「附件链接显示」配置呈现为纯文本,与草稿一致;字体渲染等细节以公众号后台的「发布预览」为准。

Q:同步一直失败,想先在公众号里手动发布怎么办? 用预览弹窗的 复制正文:在文章列表点「同步到微信公众号」进入预览(预览不调用微信接口,与同步是否成功无关),点底部「复制正文」把美化后的正文按富文本复制到剪贴板,再到公众号编辑器正文区直接粘贴——行内样式会一起带过去,标题、引用、代码块、表格、折叠卡片等排版与预览一致。

注意事项:

  • 图片需要自行上传处理:复制的内容里,正文图片仍是原图地址(转存到微信素材库发生在提交同步时),公众号编辑器对粘贴进来的外部图片链接通常不会自动转存、甚至会被过滤,粘贴后请逐张确认——缺失的图片需在微信里手动上传替换;
  • 封面图不在复制内容里:封面是草稿的元信息而非正文,需在公众号编辑器里单独上传(微信图文强制要求封面);
  • 若图片是内网地址、需登录才能访问或已失效,粘贴后同样无法显示,请先确保图片可公网访问,再重试或手动上传;
  • 复制的是美化后的正文,不含标题、作者、摘要、原文链接与留言设置——这些可按预览中列出的值在公众号里手填;
  • 预览中给分栏卡片/画廊加上的浅灰细边框、代码块行号样式等只是预览标记,不会随复制内容带入(粘贴后的效果以公众号后台为准);
  • 想让图片自动转存到微信素材库,仍需同步成功(图片转存发生在同步流程中),复制粘贴只是兜底方案。

Q:状态列一直显示「同步中」? 同步在服务端后台执行,正文图片较多、带宽较低时上传耗时较久属正常情况:列表会自动轮询刷新,任务出结果(成功/失败)后自动更新。若插件在同步过程中重启,未完成的任务会在插件下次启动时自动恢复执行(状态继续显示「同步中」,悬停可看到「正在自动恢复」);任务多次中断仍未能完成时会被标记为失败,手动重新同步即可;长时间仍显示「同步中」时可查看服务端日志确认任务是否异常。

Q:可以同时同步多篇文章吗? 可以。各篇文章的同步任务相互独立、并发执行(同一篇文章在「同步中」时重复提交会被拒绝,返回 409,请等待完成后再试)。注意:并发任务共享服务器出口带宽,且正文图片逐张串行上传,同时同步过多可能相互拖慢,建议视带宽情况控制并发数量。

Q:同一篇文章同步了两次,公众号草稿箱里为什么还是只有一份草稿? 这是有意的:同一篇文章首次同步会新建草稿,之后每次同步都会先校验上次写入的那份草稿是否还在——在则更新它(草稿 media_id 不变、草稿箱里不会多出一份),不在(比如你在公众号后台把它删了)则新建。该行为由插件设置 微信公众号 → 重复同步更新草稿 控制(默认开启);关闭后不做任何校验,每次同步都新建一份草稿,两次结果就都在草稿箱里。若保持开启、但想让两次同步各留一份,可在公众号后台先把草稿另存/复制一份,或直接以公众号后台的版本为准。另外,若日志里出现「校验草稿是否存在…按『不存在』处理,改为新建草稿」,说明校验没拿到结论(多半是「接口地址」代理未放行 /cgi-bin/draft/get),此时会退化为每次新建草稿,按接口地址与反向代理补上该路径即可。

Q:为什么任务记录里留着草稿 media_id? 它记录的是「这篇文章当前对应哪份草稿」,重复同步时据此决定更新还是新建,因此成功同步后一直保留(同步失败也不会被清掉——上次那份草稿多半还在,下次重试应当更新它)。它只是资源标识,不是凭据。

Q:同步后草稿的排版和网站上不一样? 这是微信的限制而非缺陷:微信图文会剥离外部 CSS、<style> 与 class/id 属性,只保留元素上的行内 style。Halo 正文靠主题 class + 外部 CSS 排版,直接塞进草稿会丢样式。插件已在提交前按标签注入微信友好的内联样式做美化;如需完全自定义排版,可在正文编辑器里对元素设置行内样式(其优先级高于插件默认样式)。引用块边框(可开关)与背景色、标题边框、H1–H6 标题颜色与 H1 对齐方式、行内代码配色、折叠块配色均可在设置的 正文美化 标签中调整。

Q:文章里粘贴的 <style>/<script> 会出现在预览或草稿里吗? 不会。从其他平台粘贴或导入的文章,原始 <style>/<script> 常被编辑器转义为纯文本残留在正文里,微信无法渲染、只会显示为源码文本。预览与提交共用同一套正文美化:这类残留块(连同其中的 CSS/JS 内容)在生成预览与提交草稿前都会被整体移除;代码块中的示例代码不受影响。

Q:文章里使用了其他插件生成的内容,能同步到微信吗? 不能。此类内容依赖插件自身的样式与脚本渲染,而微信图文只保留标准 HTML 与行内样式,无法在微信中渲染与显示(兼容与测试均以 Halo 默认编辑器输出的内容为准)。需要同步的正文请使用默认编辑器的标准排版元素(标题、段落、图片、代码块、表格、分栏卡片、画廊、折叠内容等)编写。

Q:日志里出现 Start to initialize indices for type…、Total indexed count、Indexing from @start,是插件出问题了吗?

不是。这是 Halo 核心的扩展索引初始化日志(由 run.halo.app.extension.indexer.DefaultIndicesInitializer 打印,不是本插件输出的),正常且无需处理。

Halo 会为每种自定义模型建立内存索引(metadata.name、创建/删除时间与标签),用于加速 list、字段选择器与排序查询。插件在启动时注册了 WechatSyncTask 模型,Halo 便在注册 Scheme 的那一刻同步扫描库里已存在的该类型记录、灌入索引,然后打印累计条数与耗时。逐行含义:

  • Start to initialize indices for type: …WechatSyncTask, prefix: /registry/api.wechat-sync.halo.run/wechatsynctasks:开始为哪个模型建索引、扫描哪段存储(前缀由模型的 @GVK 推导);
  • Total indexed count: 9:本次扫描并送入索引的任务记录数。任务是一篇文章一条(任务名规则 wechat-sync-<文章 name>),所以这个数字约等于曾经提交过同步的文章数(成功与失败记录都会保留);
  • StopWatch 'Initialize indices for …WechatSyncTask' 与下方的耗时表:Indexing from @start 是第一批(从头扫,每批 100 条),Indexing from /registry/…/wechat-sync-e0248eaf-… 是从上一批最后一条记录之后继续——那串就是本插件的任务名,文章 name 是 Halo 生成的随机串,看起来像 UUID(任务记录里的 spec.postTitle 保存了提交时的文章标题,可据此对应回文章);由于循环要再取一次空批才能确认结束,100 条以内固定会看到 2 行批次。

每次插件启动或热重载(重新注册模型)都会看到这组日志,耗时与任务记录条数相关,通常只有几毫秒。若它随时间明显变长,可从「任务记录条数」入手排查——文章被永久删除时对应任务会被自动清理、任务落到终态后也会清空正文快照,因此不会无限增长。

许可证

GPL-3.0 © 宏尘极客(hcjike)

About

一款Halo插件:无需离开 Halo 控制台,即可把已写好的文章推送到微信公众号的草稿箱,封面与正文图片会自动转存到微信素材库,同步结果实时显示在文章列表。

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages