[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"site-config":3,"post-openclaw-wechat-agent-publish":14,"comments-openclaw-wechat-agent-publish":50},{"contact.email":4,"contact.wechat":5,"contact.github":5,"contact.location":6,"site.title":7,"site.tagline":8,"site.description":9,"hero.name":10,"hero.role":11,"hero.summary":12,"about.body":13},"superheroic@aliyun.com","","中国","周代光 | 技术博客","系统 · 架构 · 半导体制造信息化","周代光的技术博客。半导体行业信息化实践，C\u002FC++\u002FJava\u002FC#\u002FPython 工程经验，系统架构与项目管理。","周代光","Software Engineer \u002F Project Manager","半导体行业软件工程师，兼具研发与项目管理背景。持 PMP、信息系统项目管理师（高级）。","十余年软件工程经验，主要方向为系统级开发与企业信息化。熟练使用 C \u002F C++ \u002F Java \u002F C# \u002F Python，具备完整的项目管理能力（PMP、信息系统项目管理师高级）。当前专注于半导体行业的信息化系统建设，包括生产数据采集、制程分析与系统集成。",{"id":15,"slug":16,"title":17,"summary":18,"cover":19,"status":20,"source":21,"readingMinutes":22,"views":23,"publishedAt":24,"createdAt":25,"updatedAt":26,"categorySlug":27,"categoryName":28,"contentHtml":29,"contentMd":30,"tags":31},8,"openclaw-wechat-agent-publish","让 AI 助理拥有发文权：OpenClaw 接微信 + 自动投稿到自建博客","用 OpenClaw 把个人微信接成入口，让 AI 助理直接投稿到自建 Nuxt 博客。记录整条链路的实现、为什么 agent token 默认只发草稿，以及 nginx 1.24 语法、FTS5 trigram 中文搜索失效、3.6G 内存打包这几个实测踩坑。",null,"published","agent",5,21,1787037044,1787036062,1787037050,"engineering","软件工程","\u003Cp>我给自己的博客加了一个 Agent 投稿接口，然后让一个跑在服务器上的 AI 助理通过微信接单、写稿、投稿。这篇是这套链路的实现记录，包括几个只有真跑一遍才会撞上的坑。\u003C\u002Fp>\n\u003Cp>先说结论：\u003Cstrong>能跑，但权限闸门必须留在人这一侧。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Ch2 id=\"%E4%B8%80%E3%80%81%E6%95%B4%E4%BD%93%E9%93%BE%E8%B7%AF\" tabindex=\"-1\">一、整体链路 \u003Ca class=\"header-anchor\" href=\"#%E4%B8%80%E3%80%81%E6%95%B4%E4%BD%93%E9%93%BE%E8%B7%AF\">#\u003C\u002Fa>\u003C\u002Fh2>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark-default\" style=\"--shiki-light:#24292e;--shiki-dark:#e6edf3;--shiki-light-bg:#fff;--shiki-dark-bg:#0d1117\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan>微信（个人号）\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>   ↓  @tencent-weixin\u002Fopenclaw-weixin 插件\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>OpenClaw Gateway  ──→  Agent（Claude Code）\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>                          ↓  Bearer token\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>                    博客 \u002Fapi\u002Fagent\u002Fposts\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>                          ↓\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>                    SQLite（status=draft）\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>                          ↓  人工审核\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>                       线上发布\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>四段各自独立，任何一段挂掉不会污染其他段。这个解耦不是我设计的，是 OpenClaw 的通道插件契约本来就这么划的，值得单独说一下。\u003C\u002Fp>\n\u003Ch2 id=\"%E4%BA%8C%E3%80%81%E5%BE%AE%E4%BF%A1%E8%BF%99%E4%B8%80%E6%AE%B5%EF%BC%9A%E6%A0%B8%E5%BF%83%E4%BB%93%E5%BA%93%E9%87%8C%E6%B2%A1%E6%9C%89%E5%BE%AE%E4%BF%A1%E4%BB%A3%E7%A0%81\" tabindex=\"-1\">二、微信这一段：核心仓库里没有微信代码 \u003Ca class=\"header-anchor\" href=\"#%E4%BA%8C%E3%80%81%E5%BE%AE%E4%BF%A1%E8%BF%99%E4%B8%80%E6%AE%B5%EF%BC%9A%E6%A0%B8%E5%BF%83%E4%BB%93%E5%BA%93%E9%87%8C%E6%B2%A1%E6%9C%89%E5%BE%AE%E4%BF%A1%E4%BB%A3%E7%A0%81\">#\u003C\u002Fa>\u003C\u002Fh2>\n\u003Cp>OpenClaw 接微信走的是腾讯维护的外部插件 \u003Ccode>@tencent-weixin\u002Fopenclaw-weixin\u003C\u002Fcode>，通道 id 是 \u003Ccode>openclaw-weixin\u003C\u002Fcode>（\u003Ccode>weixin\u003C\u002Fcode> \u002F \u003Ccode>wechat\u003C\u002Fcode> 是别名）。\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark-default\" style=\"--shiki-light:#24292e;--shiki-dark:#e6edf3;--shiki-light-bg:#fff;--shiki-dark-bg:#0d1117\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#6F42C1;--shiki-dark:#FFA657\">openclaw\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\"> plugins\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\"> install\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\"> \"@tencent-weixin\u002Fopenclaw-weixin\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#6F42C1;--shiki-dark:#FFA657\">openclaw\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\"> config\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\"> set\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\"> plugins.entries.openclaw-weixin.enabled\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79C0FF\"> true\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#6F42C1;--shiki-dark:#FFA657\">openclaw\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\"> channels\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\"> login\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79C0FF\"> --channel\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\"> openclaw-weixin\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#6A737D;--shiki-dark:#8B949E\">   # 扫码\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>启动顺序是：Gateway 发现插件 manifest → 加载入口 → 插件注册通道 id → 扫码登录、凭据落在状态目录（默认 \u003Ccode>~\u002F.openclaw\u003C\u002Fcode>）→ Gateway 启动时插件为每个账号拉起 Weixin monitor → 入站消息经通道契约归一化后路由给指定 agent，出站再走插件。\u003C\u002Fp>\n\u003Cp>关键点在于：\u003Cstrong>微信登录、iLink API 调用、媒体上下行、上下文 token、账号监控，全部由外部插件持有，核心保持通道无关。\u003C\u002Fstrong> 这带来一个直接的工程收益——我升级博客、改 agent 逻辑、换模型，都不用碰微信这一层；反过来插件更新也不会波及我的业务代码。\u003C\u002Fp>\n\u003Cp>一个必须说清的边界：\u003Cstrong>这条通道是个人微信，插件的 capability metadata 只声明 direct chat，不支持群聊，也和微信公众号没有任何关系。\u003C\u002Fstrong> 公众号自动发文是另一套东西（appid\u002Fsecret、素材上传、\u003Ccode>draft\u002Fadd\u003C\u002Fcode>、\u003Ccode>freepublish\u002Fsubmit\u003C\u002Fcode>、后台 IP 白名单），本文不涉及，我也没跑过。\u003C\u002Fp>\n\u003Ch2 id=\"%E4%B8%89%E3%80%81%E5%8D%9A%E5%AE%A2%E8%BF%99%E4%B8%80%E6%AE%B5%EF%BC%9A%E7%BB%99-agent-%E5%BC%80%E4%B8%80%E4%B8%AA%E5%86%99%E6%9D%83%E9%99%90\" tabindex=\"-1\">三、博客这一段：给 Agent 开一个写权限 \u003Ca class=\"header-anchor\" href=\"#%E4%B8%89%E3%80%81%E5%8D%9A%E5%AE%A2%E8%BF%99%E4%B8%80%E6%AE%B5%EF%BC%9A%E7%BB%99-agent-%E5%BC%80%E4%B8%80%E4%B8%AA%E5%86%99%E6%9D%83%E9%99%90\">#\u003C\u002Fa>\u003C\u002Fh2>\n\u003Cp>博客是 Nuxt 4 + better-sqlite3 + drizzle 自建的。给 agent 单独开了一组 \u003Ccode>\u002Fapi\u002Fagent\u002F*\u003C\u002Fcode> 接口，走 Bearer token，和管理端的 JWT cookie 完全分离。\u003C\u002Fp>\n\u003Cp>token 校验只认 sha256，明文只在生成那一刻出现一次：\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark-default\" style=\"--shiki-light:#24292e;--shiki-dark:#e6edf3;--shiki-light-bg:#fff;--shiki-dark-bg:#0d1117\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan>export function requireAgent(event: H3Event) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>  const raw = getRequestHeader(event, 'authorization') || ''\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>  const m = raw.match(\u002F^Bearer\\s+(.+)$\u002Fi)\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>  if (!m) throw createError({ statusCode: 401, statusMessage: 'Missing bearer token' })\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>  const db = useDb()\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>  const row = db.select().from(schema.apiTokens)\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    .where(and(\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>      eq(schema.apiTokens.tokenHash, hashToken(m[1].trim())),\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>      eq(schema.apiTokens.revoked, 0),\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    ))\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    .get()\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>  if (!row) throw createError({ statusCode: 401, statusMessage: 'Invalid token' })\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>  db.update(schema.apiTokens)\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    .set({ lastUsedAt: Math.floor(Date.now() \u002F 1000) })\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    .where(eq(schema.apiTokens.id, row.id)).run()\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>  return { tokenId: row.id, name: row.name, allowPublish: row.allowPublish === 1 }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>revoked\u003C\u002Fcode> 和 \u003Ccode>lastUsedAt\u003C\u002Fcode> 是刻意加的：token 泄露时要能单点吊销，而不是全站改密；\u003Ccode>lastUsedAt\u003C\u002Fcode> 让&quot;这个 token 还在被谁用&quot;可观测。\u003C\u002Fp>\n\u003Ch2 id=\"%E5%9B%9B%E3%80%81%E6%9C%80%E9%87%8D%E8%A6%81%E7%9A%84%E4%B8%80%E4%B8%AA%E5%86%B3%E5%AE%9A%EF%BC%9A%E9%BB%98%E8%AE%A4%E5%8F%AA%E5%8F%91%E8%8D%89%E7%A8%BF\" tabindex=\"-1\">四、最重要的一个决定：默认只发草稿 \u003Ca class=\"header-anchor\" href=\"#%E5%9B%9B%E3%80%81%E6%9C%80%E9%87%8D%E8%A6%81%E7%9A%84%E4%B8%80%E4%B8%AA%E5%86%B3%E5%AE%9A%EF%BC%9A%E9%BB%98%E8%AE%A4%E5%8F%AA%E5%8F%91%E8%8D%89%E7%A8%BF\">#\u003C\u002Fa>\u003C\u002Fh2>\n\u003Cp>这是全套设计里我唯一坚持没让步的地方。\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark-default\" style=\"--shiki-light:#24292e;--shiki-dark:#e6edf3;--shiki-light-bg:#fff;--shiki-dark-bg:#0d1117\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan>let status = body.status === 'published' ? 'published' : 'draft'\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>let downgraded = false\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>if (status === 'published' &#x26;&#x26; !agent.allowPublish) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>  status = 'draft'\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>  downgraded = true\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>注意这里不是&quot;拒绝请求&quot;，是\u003Cstrong>静默降级 + 显式告知\u003C\u002Fstrong>：请求照常成功，但落库状态被强制改成草稿，响应里带 \u003Ccode>downgraded: true\u003C\u002Fcode>、说明文案和 \u003Ccode>reviewUrl\u003C\u002Fcode>。\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark-default\" style=\"--shiki-light:#24292e;--shiki-dark:#e6edf3;--shiki-light-bg:#fff;--shiki-dark-bg:#0d1117\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">{\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#7EE787\">  \"id\"\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">: \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79C0FF\">12\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">, \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#7EE787\">\"slug\"\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">: \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\">\"...\"\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">, \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#7EE787\">\"status\"\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">: \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\">\"draft\"\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">, \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#7EE787\">\"downgraded\"\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">: \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79C0FF\">true\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#7EE787\">  \"note\"\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">: \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\">\"该 token 无直接发布权限，已保存为草稿，请在管理端审核后发布\"\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#7EE787\">  \"reviewUrl\"\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">: \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#A5D6FF\">\"\u002Fadmin\u002Fposts\u002F12\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E6EDF3\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>为什么不直接 403？因为 403 会让调用方以为整件事失败了，于是重试、于是改参数绕。降级 + reviewUrl 是一条更诚实的路径：活干完了，只是最后一步在人手里。\u003C\u002Fp>\n\u003Cp>而背后的真实理由跟技术无关——\u003Cstrong>署名风险是不对称的。\u003C\u002Fstrong> AI 写的东西挂我的名字给客户看，写对了收益有限，写错了信誉是我自己的。所以 \u003Ccode>allowPublish=0\u003C\u002Fcode> 是默认值，不是可选项。\u003C\u002Fp>\n\u003Cp>这条约束我做了实测：带 \u003Ccode>status: &quot;published&quot;\u003C\u002Fcode> 发请求，落库确实是 \u003Ccode>draft\u003C\u002Fcode>，响应带 \u003Ccode>downgraded: true\u003C\u002Fcode>，草稿的公开详情页返回 404。闸门有效。\u003C\u002Fp>\n\u003Ch2 id=\"%E4%BA%94%E3%80%81%E5%87%A0%E4%B8%AA%E7%9C%9F%E6%92%9E%E4%B8%8A%E7%9A%84%E5%9D%91\" tabindex=\"-1\">五、几个真撞上的坑 \u003Ca class=\"header-anchor\" href=\"#%E4%BA%94%E3%80%81%E5%87%A0%E4%B8%AA%E7%9C%9F%E6%92%9E%E4%B8%8A%E7%9A%84%E5%9D%91\">#\u003C\u002Fa>\u003C\u002Fh2>\n\u003Ch3 id=\"nginx-1.24-%E4%B8%8D%E8%AE%A4-http2-on%3B\" tabindex=\"-1\">nginx 1.24 不认 \u003Ccode>http2 on;\u003C\u002Fcode> \u003Ca class=\"header-anchor\" href=\"#nginx-1.24-%E4%B8%8D%E8%AE%A4-http2-on%3B\">#\u003C\u002Fa>\u003C\u002Fh3>\n\u003Cp>\u003Ccode>http2 on;\u003C\u002Fcode> 是 1.25.1+ 的语法。1.24 必须写老形式：\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark-default\" style=\"--shiki-light:#24292e;--shiki-dark:#e6edf3;--shiki-light-bg:#fff;--shiki-dark-bg:#0d1117\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan>listen 443 ssl http2;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>nginx -t\u003C\u002Fcode> 拦住了，没造成线上事故。这也是为什么 reload 前必须 \u003Ccode>-t\u003C\u002Fcode>——不是流程洁癖，是它真的能救你。\u003C\u002Fp>\n\u003Ch3 id=\"sqlite-fts5-trigram-%E5%AF%B9%E4%B8%AD%E6%96%87%E6%90%9C%E7%B4%A2%E5%9F%BA%E6%9C%AC%E4%B8%8D%E5%8F%AF%E7%94%A8\" tabindex=\"-1\">SQLite FTS5 trigram 对中文搜索基本不可用 \u003Ca class=\"header-anchor\" href=\"#sqlite-fts5-trigram-%E5%AF%B9%E4%B8%AD%E6%96%87%E6%90%9C%E7%B4%A2%E5%9F%BA%E6%9C%AC%E4%B8%8D%E5%8F%AF%E7%94%A8\">#\u003C\u002Fa>\u003C\u002Fh3>\n\u003Cp>搜索用了 FTS5 的 trigram 分词器。上线前一测就傻了：\u003Cstrong>「良率」「制程」「架构」这类两个汉字的查询恒定返回空。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp>原因不复杂——trigram 按 3 个字符切分，2 字符的查询根本构不出一个 trigram。另外含 \u003Ccode>+\u003C\u002Fcode> \u003Ccode>#\u003C\u002Fcode> 的词（C++、C#）会直接抛 syntax error。对一个技术博客来说，这两类恰好是高频词。\u003C\u002Fp>\n\u003Cp>解法是混合策略，写在 \u003Ccode>server\u002Futils\u002Fposts.ts\u003C\u002Fcode> 的 \u003Ccode>searchIds()\u003C\u002Fcode> 里：\u003C\u002Fp>\n\u003Cul>\n\u003Cli>查询长度 &lt; 3 或含特殊符号 → 直接走 \u003Ccode>LIKE\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>长词 → 走 FTS，\u003Cstrong>空结果再用 LIKE 兜一次\u003C\u002Fstrong>\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>第二条兜底很关键，因为 FTS 命中率对中文本来就不稳。附一句版本信息：better-sqlite3 13.0.2 捆的 SQLite 是 3.53.4，所以这不是老版本 bug，是 trigram 的固有行为。\u003C\u002Fp>\n\u003Ch3 id=\"3.6g-%E5%86%85%E5%AD%98%E7%9A%84%E6%9C%BA%E5%99%A8%E4%B8%8A%E6%89%93%E5%8C%85-nuxt\" tabindex=\"-1\">3.6G 内存的机器上打包 Nuxt \u003Ca class=\"header-anchor\" href=\"#3.6g-%E5%86%85%E5%AD%98%E7%9A%84%E6%9C%BA%E5%99%A8%E4%B8%8A%E6%89%93%E5%8C%85-nuxt\">#\u003C\u002Fa>\u003C\u002Fh3>\n\u003Cp>整机 3.6G \u002F 2 vCPU，Gateway 常驻 718M，还有个 chromium 吃掉约 550M。Nuxt 构建期是纯内存杀手。\u003C\u002Fp>\n\u003Cp>三件事解决：\u003C\u002Fp>\n\u003Col>\n\u003Cli>打包前停 chromium。\u003Cstrong>注意它由 systemd user unit 托管且 \u003Ccode>Restart=always\u003C\u002Fcode>，\u003Ccode>kill\u003C\u002Fcode> 完 3 秒内原地复活，PID 变了但进程数不变，极易误判成杀掉了。\u003C\u002Fstrong> 正确姿势：\u003Ccode>systemctl --user stop lighthouse-chromium.service\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>构建加 \u003Ccode>NODE_OPTIONS=--max-old-space-size=1024\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>运行时 systemd 配 \u003Ccode>MemoryMax=512M\u003C\u002Fcode> 兜底\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>结果：产物 30.9MB，运行时实际只占 37M，离 512M 预算很远。\u003Cstrong>构建期和运行时的内存画像可以差一个数量级\u003C\u002Fstrong>，按构建峰值去估算服务器规格是浪费钱。\u003C\u002Fp>\n\u003Ch3 id=\"%E4%B8%8A%E4%BC%A0%E6%8E%A5%E5%8F%A3%EF%BC%9A%E4%B8%8D%E8%A6%81%E4%BF%A1-content-type\" tabindex=\"-1\">上传接口：不要信 Content-Type \u003Ca class=\"header-anchor\" href=\"#%E4%B8%8A%E4%BC%A0%E6%8E%A5%E5%8F%A3%EF%BC%9A%E4%B8%8D%E8%A6%81%E4%BF%A1-content-type\">#\u003C\u002Fa>\u003C\u002Fh3>\n\u003Cp>顺手说一个跟 agent 无关但同批做的。图片上传按 \u003Cstrong>magic bytes\u003C\u002Fstrong> 判类型，不信 Content-Type 也不信扩展名，只放 PNG\u002FJPEG\u002FGIF\u002FWebP。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>SVG 是故意拒绝的\u003C\u002Fstrong>——它能内嵌 \u003Ccode>&lt;script&gt;\u003C\u002Fcode>，是一个现成的存储型 XSS 面。实测返 415。\u003C\u002Fp>\n\u003Cp>文件名完全由服务端生成（时间戳 + 随机），不复用任何用户输入，路径穿越和同名覆盖一起免掉。\u003C\u002Fp>\n\u003Ch2 id=\"%E5%85%AD%E3%80%81%E8%BF%99%E5%A5%97%E4%B8%9C%E8%A5%BF%E5%80%BC%E4%B8%8D%E5%80%BC\" tabindex=\"-1\">六、这套东西值不值 \u003Ca class=\"header-anchor\" href=\"#%E5%85%AD%E3%80%81%E8%BF%99%E5%A5%97%E4%B8%9C%E8%A5%BF%E5%80%BC%E4%B8%8D%E5%80%BC\">#\u003C\u002Fa>\u003C\u002Fh2>\n\u003Cp>我的判断：\u003Cstrong>值，但价值不在&quot;AI 能写文章&quot;。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp>真正的收益是把&quot;想到一件事&quot;到&quot;它落到线上有个 URL&quot;之间的摩擦压到接近零。以前一个想法要经过：想 → 记 → 找时间 → 打开编辑器 → 排版 → 发布，每一步都是流失点。现在是微信里说一句，剩下的自动跑到草稿箱，我只做最后一次判断——发或不发。\u003C\u002Fp>\n\u003Cp>代价是你必须接受最后那道人工闸门。想全自动的话，这套设计就不适合你，但我也不建议那么干。\u003C\u002Fp>\n\u003Cp>顺手记一条工程教训，跟主题无关但吃过亏：\u003Cstrong>批量编辑工具的多个 replace block 通常是原子的，一个匹配不上就全部回滚。\u003C\u002Fstrong> 我曾因一个字符写错，以为前面几处已生效，实际全没落地。改完一定 \u003Ccode>grep\u003C\u002Fcode> 复核，别信返回值。\u003C\u002Fp>\n","我给自己的博客加了一个 Agent 投稿接口，然后让一个跑在服务器上的 AI 助理通过微信接单、写稿、投稿。这篇是这套链路的实现记录，包括几个只有真跑一遍才会撞上的坑。\n\n先说结论：**能跑，但权限闸门必须留在人这一侧。**\n\n## 一、整体链路\n\n```\n微信（个人号）\n   ↓  @tencent-weixin\u002Fopenclaw-weixin 插件\nOpenClaw Gateway  ──→  Agent（Claude Code）\n                          ↓  Bearer token\n                    博客 \u002Fapi\u002Fagent\u002Fposts\n                          ↓\n                    SQLite（status=draft）\n                          ↓  人工审核\n                       线上发布\n```\n\n四段各自独立，任何一段挂掉不会污染其他段。这个解耦不是我设计的，是 OpenClaw 的通道插件契约本来就这么划的，值得单独说一下。\n\n## 二、微信这一段：核心仓库里没有微信代码\n\nOpenClaw 接微信走的是腾讯维护的外部插件 `@tencent-weixin\u002Fopenclaw-weixin`，通道 id 是 `openclaw-weixin`（`weixin` \u002F `wechat` 是别名）。\n\n```bash\nopenclaw plugins install \"@tencent-weixin\u002Fopenclaw-weixin\"\nopenclaw config set plugins.entries.openclaw-weixin.enabled true\nopenclaw channels login --channel openclaw-weixin   # 扫码\n```\n\n启动顺序是：Gateway 发现插件 manifest → 加载入口 → 插件注册通道 id → 扫码登录、凭据落在状态目录（默认 `~\u002F.openclaw`）→ Gateway 启动时插件为每个账号拉起 Weixin monitor → 入站消息经通道契约归一化后路由给指定 agent，出站再走插件。\n\n关键点在于：**微信登录、iLink API 调用、媒体上下行、上下文 token、账号监控，全部由外部插件持有，核心保持通道无关。** 这带来一个直接的工程收益——我升级博客、改 agent 逻辑、换模型，都不用碰微信这一层；反过来插件更新也不会波及我的业务代码。\n\n一个必须说清的边界：**这条通道是个人微信，插件的 capability metadata 只声明 direct chat，不支持群聊，也和微信公众号没有任何关系。** 公众号自动发文是另一套东西（appid\u002Fsecret、素材上传、`draft\u002Fadd`、`freepublish\u002Fsubmit`、后台 IP 白名单），本文不涉及，我也没跑过。\n\n## 三、博客这一段：给 Agent 开一个写权限\n\n博客是 Nuxt 4 + better-sqlite3 + drizzle 自建的。给 agent 单独开了一组 `\u002Fapi\u002Fagent\u002F*` 接口，走 Bearer token，和管理端的 JWT cookie 完全分离。\n\ntoken 校验只认 sha256，明文只在生成那一刻出现一次：\n\n```ts\nexport function requireAgent(event: H3Event) {\n  const raw = getRequestHeader(event, 'authorization') || ''\n  const m = raw.match(\u002F^Bearer\\s+(.+)$\u002Fi)\n  if (!m) throw createError({ statusCode: 401, statusMessage: 'Missing bearer token' })\n\n  const db = useDb()\n  const row = db.select().from(schema.apiTokens)\n    .where(and(\n      eq(schema.apiTokens.tokenHash, hashToken(m[1].trim())),\n      eq(schema.apiTokens.revoked, 0),\n    ))\n    .get()\n\n  if (!row) throw createError({ statusCode: 401, statusMessage: 'Invalid token' })\n\n  db.update(schema.apiTokens)\n    .set({ lastUsedAt: Math.floor(Date.now() \u002F 1000) })\n    .where(eq(schema.apiTokens.id, row.id)).run()\n\n  return { tokenId: row.id, name: row.name, allowPublish: row.allowPublish === 1 }\n}\n```\n\n`revoked` 和 `lastUsedAt` 是刻意加的：token 泄露时要能单点吊销，而不是全站改密；`lastUsedAt` 让\"这个 token 还在被谁用\"可观测。\n\n## 四、最重要的一个决定：默认只发草稿\n\n这是全套设计里我唯一坚持没让步的地方。\n\n```ts\nlet status = body.status === 'published' ? 'published' : 'draft'\nlet downgraded = false\nif (status === 'published' && !agent.allowPublish) {\n  status = 'draft'\n  downgraded = true\n}\n```\n\n注意这里不是\"拒绝请求\"，是**静默降级 + 显式告知**：请求照常成功，但落库状态被强制改成草稿，响应里带 `downgraded: true`、说明文案和 `reviewUrl`。\n\n```json\n{\n  \"id\": 12, \"slug\": \"...\", \"status\": \"draft\", \"downgraded\": true,\n  \"note\": \"该 token 无直接发布权限，已保存为草稿，请在管理端审核后发布\",\n  \"reviewUrl\": \"\u002Fadmin\u002Fposts\u002F12\"\n}\n```\n\n为什么不直接 403？因为 403 会让调用方以为整件事失败了，于是重试、于是改参数绕。降级 + reviewUrl 是一条更诚实的路径：活干完了，只是最后一步在人手里。\n\n而背后的真实理由跟技术无关——**署名风险是不对称的。** AI 写的东西挂我的名字给客户看，写对了收益有限，写错了信誉是我自己的。所以 `allowPublish=0` 是默认值，不是可选项。\n\n这条约束我做了实测：带 `status: \"published\"` 发请求，落库确实是 `draft`，响应带 `downgraded: true`，草稿的公开详情页返回 404。闸门有效。\n\n## 五、几个真撞上的坑\n\n### nginx 1.24 不认 `http2 on;`\n\n`http2 on;` 是 1.25.1+ 的语法。1.24 必须写老形式：\n\n```nginx\nlisten 443 ssl http2;\n```\n\n`nginx -t` 拦住了，没造成线上事故。这也是为什么 reload 前必须 `-t`——不是流程洁癖，是它真的能救你。\n\n### SQLite FTS5 trigram 对中文搜索基本不可用\n\n搜索用了 FTS5 的 trigram 分词器。上线前一测就傻了：**「良率」「制程」「架构」这类两个汉字的查询恒定返回空。**\n\n原因不复杂——trigram 按 3 个字符切分，2 字符的查询根本构不出一个 trigram。另外含 `+` `#` 的词（C++、C#）会直接抛 syntax error。对一个技术博客来说，这两类恰好是高频词。\n\n解法是混合策略，写在 `server\u002Futils\u002Fposts.ts` 的 `searchIds()` 里：\n\n- 查询长度 \u003C 3 或含特殊符号 → 直接走 `LIKE`\n- 长词 → 走 FTS，**空结果再用 LIKE 兜一次**\n\n第二条兜底很关键，因为 FTS 命中率对中文本来就不稳。附一句版本信息：better-sqlite3 13.0.2 捆的 SQLite 是 3.53.4，所以这不是老版本 bug，是 trigram 的固有行为。\n\n### 3.6G 内存的机器上打包 Nuxt\n\n整机 3.6G \u002F 2 vCPU，Gateway 常驻 718M，还有个 chromium 吃掉约 550M。Nuxt 构建期是纯内存杀手。\n\n三件事解决：\n\n1. 打包前停 chromium。**注意它由 systemd user unit 托管且 `Restart=always`，`kill` 完 3 秒内原地复活，PID 变了但进程数不变，极易误判成杀掉了。** 正确姿势：`systemctl --user stop lighthouse-chromium.service`\n2. 构建加 `NODE_OPTIONS=--max-old-space-size=1024`\n3. 运行时 systemd 配 `MemoryMax=512M` 兜底\n\n结果：产物 30.9MB，运行时实际只占 37M，离 512M 预算很远。**构建期和运行时的内存画像可以差一个数量级**，按构建峰值去估算服务器规格是浪费钱。\n\n### 上传接口：不要信 Content-Type\n\n顺手说一个跟 agent 无关但同批做的。图片上传按 **magic bytes** 判类型，不信 Content-Type 也不信扩展名，只放 PNG\u002FJPEG\u002FGIF\u002FWebP。\n\n**SVG 是故意拒绝的**——它能内嵌 `\u003Cscript>`，是一个现成的存储型 XSS 面。实测返 415。\n\n文件名完全由服务端生成（时间戳 + 随机），不复用任何用户输入，路径穿越和同名覆盖一起免掉。\n\n## 六、这套东西值不值\n\n我的判断：**值，但价值不在\"AI 能写文章\"。**\n\n真正的收益是把\"想到一件事\"到\"它落到线上有个 URL\"之间的摩擦压到接近零。以前一个想法要经过：想 → 记 → 找时间 → 打开编辑器 → 排版 → 发布，每一步都是流失点。现在是微信里说一句，剩下的自动跑到草稿箱，我只做最后一次判断——发或不发。\n\n代价是你必须接受最后那道人工闸门。想全自动的话，这套设计就不适合你，但我也不建议那么干。\n\n顺手记一条工程教训，跟主题无关但吃过亏：**批量编辑工具的多个 replace block 通常是原子的，一个匹配不上就全部回滚。** 我曾因一个字符写错，以为前面几处已生效，实际全没落地。改完一定 `grep` 复核，别信返回值。\n",[32,35,38,41,44,46,48],{"slug":33,"name":34},"sqlite","SQLite",{"slug":36,"name":37},"nuxt","Nuxt",{"slug":39,"name":40},"ai","AI",{"slug":42,"name":43},"openclaw","OpenClaw",{"slug":21,"name":45},"Agent",{"slug":47,"name":47},"nginx",{"slug":49,"name":49},"实践记录",{"items":51},[]]