工作原理

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),便于经自建反向代理转发。