wx-gateway
共享微信公众号网关,多个业务 app 共用一个服务号 + 一个 IP 白名单,统一处理扫码登录 / OAuth profile 补全 / 支付收单 / 客服消息回推。
wx.mvp.restry.cn。
当前 Tasks
🔥 进行中
- geniuspulse-prod 站前端 build 修
NEXT_PUBLIC_WX_GATEWAY_APP_NAME=geniuspulse-prod(当前硬编码成nexora-devhub,要 rebuild + redeploy)— (resley 自己来, 2026-05-13)
⏸ 等决策/卡点
-
UserAppGrant(openid, appName)拆表实施(一对多,binding 继续管 fanout、grant 管 push/userinfo 越权);架构设计图已出wx-grant-vs-binding-2026-05-13.html— (等 resley 拍板派 CC, 2026-05-13) - 服务号订阅通知”邀请用户点击授权”链路(解 48001 api unauthorized)— (等 resley 给 UX 流程, 2026-05-14)
-
MatchRule.Content_equals/Content_regex文字内容匹配支持(按需实施)— (等 resley 给具体用例, 2026-05-13) - design-studio-prod 商户号微信侧 NO_AUTH 状态(账户审核问题,与代码无关)— (等微信审核, 2026-05-08~)
✅ 最近完成(保留 7 天)
- 模板消息 + 订阅通知双 API Sync + Test send 占位符 UI(
WxTemplate+ commit1c87e54系列)— (2026-05-14) - App drawer 7 tab → 5 tab +
max-w-xl→max-w-4xl+ 支付 tab 三 section 平铺 — (2026-05-14) - Panel UX 大批量:Users openid 全显倒序、Messages 整行展开 + eventDesc + 失败 errcode 内联、Overview Endpoint Health 修 + 60min 时间轴、Invites/Scans/Menu 合并
/panel/activity— (2026-05-14) - messages 页面对齐 scans/users/payments 设计语言(commit
1252270)+ nickname/displayName 展示(commitff8e88a)— (2026-05-14) - 永久二维码方案上线 commit
0e7ee2f(scene_str=appName 1:1 + lazy 生成 + App 表缓存)— (2026-05-13) - 菜单管理 UI 莆阳实例上线 commit
27d5427(click 按钮绑 app + 自动 upsertmenu-${key}路由)— (2026-05-13) - catchall route 排除 event 类(修 multiple ownsReply invariant fail)— (2026-05-13)
- selftest 自动清理临时 app + profile completion 11 个文件正式入 git commit
eaeef9d、gitnexus 工件加.gitignorecommit5f13c76— (2026-05-13)
背景
之前每个业务方(image-studio、wechat-bot-tickets 等)都自己接微信公众号扫码登录,重复维护 access_token / IP 白名单 / OAuth 回调。2026-04-27 抽出共享网关,业务方只需注册到 apps 表就能复用。04-28 进一步收下 access_token + 支付收单两个集中能力。
核心架构
| 项 | 值 |
|---|---|
| 仓库 | Restry/wx-gateway |
| 域名 | wx.mvp.restry.cn |
| 部署 | deployer(claw pm2,端口 3794) |
| 配置 | apps 表(DB-driven,60s 缓存 + 热加载) |
| 公众号 | 「造悟者」服务号 AppID wx225bf76b06064faa |
| 莆阳分支 | wx-gateway-pucs → wxmsg.mvp.restry.cn(莆阳服务号 wxe780b027c2c56921,见 wechat-bot-tickets) |
微信扫码 → wx-gateway /wx/qr/<app> → 业务方 finalizePath (HMAC) → 业务方落用户
微信关注/SCAN → 网关识别 → push OAuth 链接 + reply XML 嵌补全入口
非扫码文本 → fanout 路由(业务方 webhook)
DB-Driven 多租户(v2,04-27 三批次)
把 APPS_JSON 静态 env 迁到 DB,业务方扫码自助接入;admin 自吃狗粮走微信扫码登录。新增 4 张表:apps / app_invites / admin_users / unauthorized_scan_log。
| 批次 | commit | 内容 |
|---|---|---|
| 1 | db0d8b5 / ee29c04 | apps 表 + 60s TTL 热加载替代 redeploy |
| 2 | db86edf | invite code 接入授权(限 prefix + host pattern,一次性消费) |
| 3 | fb53488 | admin 微信扫码登录白名单,bootstrap 走 ADMIN_BOOTSTRAP_OPENIDS env |
设计风格沿用 image-studio(DM Sans + #C24E29 + #F8F7F4)。
当前注册的 apps(04-29)
| name | appBaseUrl | 用途 |
|---|---|---|
| ph | packhorizon.mvp.restry.cn | packhorizon |
| pack | pack.mvp.restry.cn | packsmith |
| echo | echo.mvp.restry.cn | echo(NextAuth WX provider) |
| copilot-proxy | api.eagle.openclaws.co.uk | copilot-anthropic-proxy 计费 |
| admin | (内部) | 网关自己的扫码登录 |
| selftest | (内部) | 网关自检 |
Admin 安全加固
- 随机路径
ADMIN_PUBLIC_PATH=/_a/<32hex>(如/_a/ce233c02438f1ea04adaeb0c703468eb/),/admin/*直接返 404 伪装;同源但仿_next/命名混进静态资源,扫描器忽略 - 5 次/min IP 速率限制 + lockout(
unauthorized_scan_log全量记录) - session 1h sliding TTL;bootstrap 完成后 env 可删
- 沉淀为通用 skill
secure-admin-path-pattern(仿 _next/、ph_session+role=admin、扫描日志)
selftest rate-limit bypass(commit 3eae5cb)
middleware Web Crypto verify,fail-closed;只短路 rateLimit(),404 camouflage / rewrite / 其他鉴权全保留。T-RL-BYPASS-1 在 lockout 期间 10 次全 200 = 强证据:已 locked 仍能穿透。
集中 access_token(commits d6f2c1c + fb7441b)
- 网关持有「造悟者」
WX_APP_SECRET,独家刷 access_token - 暴露
GET /internal/wx-token(仅业务方机器走内网或带 HMAC) - 100min cron 主动刷新,pg_advisory_xact_lock 防并发拉双份
- 业务方(echo / image-studio / wechat-bot-tickets)不再各自存 secret,杜绝 40001 invalid credential 自愈循环
OAuth snsapi_userinfo 一键登录(commit e4a09da)
/wx/oauth/start+/wx/oauth/callback网关侧路由- 业务方登录页 UA 检测 → 跳 OAuth →
finalize多读nickname/avatarUrl - 业务方 schema 加
nickname/avatarUrl字段(部署时postDeploy: prisma db push自动同步)
PC 扫码补头像 — reply XML 嵌 OAuth 链接
微信 2021 后政策硬约束:PC 扫码闭环里只给 openid,不给 nickname/avatar。snsapi_userinfo 仅在微信内浏览器可用。
落地方案:扫码瞬间业务端先 confirm + 占位昵称 微信用户xxxxxx;同步在 callback 响应 reply XML 里塞「点这里完善头像昵称 → https://wx.mvp.restry.cn/wx/oauth/start?app=ph」。用户在手机微信里点一下走 OAuth → 网关回写到 wxLoginToken(已 confirmed 也写入)→ 业务方下次刷新看到。
| 维度 | reply XML(采用) | 客服消息 |
|---|---|---|
| 时机 | 扫码瞬间,第一眼可见 | 数百 ms—数秒延迟 |
| 依赖 | 无(callback 返回值) | access_token + IP 白名单 + 40001 自愈 |
| 失败 | 5s 超时微信重试 3 次 | token 失效 / 网络抖动 → 用户全无感知 |
04-28 PII 修:cgi-bin/user/info → /sns/userinfo
新接入站点 OAuth 报 40003 invalid openid —— 网关旧路径调 cgi-bin/user/info(关注者接口),用户没关注「造悟者」直接 40003。
正确路径:/sns/userinfo?access_token=...&openid=... 拿 nickname/avatar/unionid(snsapi_userinfo 专用,不依赖关注关系)。cgi-bin/user/info 2021 后还把 nickname/avatar 都置空,只回 subscribe_time/scene/tagid。沉淀 skill wechat-mp-profile-pii-gotcha。
unionid 配置前置(04-27 摸清)
unionid 是「微信开放平台」机制,订阅号 / 服务号本身不会自动给 —— 必须把公众号绑到开放平台账号下:
- https://open.weixin.qq.com/ 注册开放平台账号(同主体即可)
- 完成开发者资质认证:¥300/年,对公账户打款验证
- 管理中心 → 公众账号 → 绑定公众号
- 绑定后新关注/扫码用户回调里 unionid 才有值;历史用户不会自动补
ScanLog 全量记录(04-28)
unauthorized_scan_log 升级为统一 ScanLog:kind ∈ {admin, business, unauthorized},30 天 TTL(cron 清理)。OAuth 回写 update PII 写同一条而不新增,避免 PII 散落多张表。Dashboard Scans tab 可看 IP/UA/匹配 app。
Payment v1 — 个人码 + 人工审核(04-28 全天落地)
决定:去掉自动监听,直接用爸爸真实个人收款码 + admin Dashboard 5 秒一单人工对账。日均量小完全够。Backend B 优先,Backend A(JSAPI)留
501架构口子。
抽象层(业务方只看到这个)
POST /pay/create {appName, orderId, amount_fen, subject, openid?, method?}
→ {payOrderId, qrcodeUrl, remark="K7M2QX", expiresAt}
GET /pay/status/{payOrderId}
→ {status: pending|submitted|paid|disputed|expired, paidAt?, externalRef?}
POST /pay/personal/claim {payOrderId, note?} // 用户点「我已付款」
POST {业务方 webhookUrl} // 网关 → 业务方推送,HMAC 签名同 finalize
状态机
pending → submitted → paid | disputed,pending → expired 兜底。admin 在 Dashboard Payments tab 看到列:金额/备注码/app/openid/提交时间/用户备注,对照微信「收款」记录确认 → 「✓ 确认到账」标 paid + fan-out webhook;对不上 → 「✗ 驳回」标 disputed(rejectReason 必填)。
数据模型
Payment + PaymentWebhookDelivery 两张表;App 表加 webhookUrl / personalQrUrl 兜底。
Phase 1 — 5 commit 顺序派 CC(04-28 一日完工)
| # | commit | 内容 | selftest |
|---|---|---|---|
| 1 | 3d9ddd0 | schema + migration(0 DROP)+ App 新列 | 18/18 |
| 2 | d5e051d | /pay/create / /pay/status / /pay/personal/claim + HMAC + 状态机 | 18/18 |
| 3 | 7592620 | admin Dashboard Payments tab(5th tab)+ approve/reject/redeliver | 18/18 |
| 4 | 9bcbc48 | webhook 投递 worker + expire cron + redeliver cron | 18/18 |
| 5 | e70f813 | selftest 增量 T-PAY-1..10 | 28/28 ✅ |
webhook 退避表:[0, 30s, 2m, 10m, 1h, 6h, 24h],6 次失败标 dead,admin 手动重投。
测试钩子:/wx/selftest/control + webhook setNextAttemptOverrideForTest 把 T-PAY-9 重试等待从 30s+ 压到 2s。生产永不调用,妥协但够用。
Phase 2.0 — 个人码配置化(commit a6c49f1)
- 删
public/payment/personal-qr.png占位 - admin Dashboard
Appstab 加personalQrUrl字段(上传 ≤1MB/1024px PNG/JPG/WebP,或粘贴 URL 走图床) POST /admin/apps/[id]/personal-qr上传,PATCH /admin/apps/[id]接 URL/pay/create未配置返503 personal_qr_not_configured- selftest + T-PAY-11 → 30/30
3 个 hotfix(同日)
| commit | 修什么 |
|---|---|
1d0b5a9 | /apps/register 加 appBaseUrl 唯一性检查 — 防 copilot-proxy / -prod 7 秒内连开两个的事故复发 |
9e59813 | 删 output: standalone(PM2 next start does not work with standalone 警告刷屏) |
3eae5cb | selftest rate-limit bypass middleware → 32/32 |
24bb5d0 + d37a2e9 | prerender 404 修:上传文件存 data/qr/ + dynamic route handler 而非 public/(next public/ 会 build-time 缓存,redeploy 吞文件)→ 34/34 ✅ |
copilot-proxy 重复注册事故复盘(04-28 23:16)
业务方 7 秒内连开两个 app:copilot-proxy + copilot-proxy-prod,secret 串了。处置:revoke -prod 留 copilot-proxy,apps 缓存 60s TTL 自动失效,业务方再扫一次。根因:/apps/register 没做 appBoardUrl 唯一性校验 → 收进 hotfix 1d0b5a9。
Phase 2 业务方接入 — 复用同 secret
copilot-proxy 登录已接入,支付链路复用同一 secret + 同一 app name,不再签发邀请码。业务方只需:
- 读
wx-gateway-integrateskill 的references/payment.md(含 4 接口 spec + 最小骨架代码) - 实现 4 调用:
/pay/create//pay/personal/claim//pay/status/ webhook handler - 给网关 admin 填
webhookUrl(如https://api.eagle.openclaws.co.uk/api/wx/payment-webhook)
爸爸侧补 2 件:上传真个人收款码 PNG(落 data/qr/ 持久化跨 deploy) + 注册 webhookUrl。
Phase 2.1(04-29 三批 CC 接力)— 管理后台支付配置面
| 批 | commit | selftest | 内容 |
|---|---|---|---|
| 2.1a | 84612e7 | 35→37 | Apps 抽屉编辑 webhookUrl(PATCH /admin/apps/[id],复用 personalQrUrl 校验范式 https:// + URL parse + ≤512) |
| 2.1b | 7aa41b7 | 37→40 | Payment 详情抽屉 + webhook 投递时间线(join PaymentWebhookDelivery)+ 手动 extend / force-expire |
| 2.1c | 16de581 + 5242a3c | 40→44 | App 级支付策略 paymentEnabled / maxAmountFen / defaultExpiresIn + Settings tab 总览卡 |
copilot-proxy 04-29 同日开支付:paymentEnabled=true、maxAmountFen=20000(200 元上限)、defaultExpiresIn=1800s(30 min)、webhookUrl=https://api.eagle.openclaws.co.uk/api/wx/payment-webhook。应用配置缓存 60s 后生效。
Admin slug 改成 /_a/ce233c02438f1ea04adaeb0c703468eb(env ADMIN_PATH_SLUG),CC 不硬编码,selftest 复用 admin session。
04-29 contract drift 修复 — sample-client.py + T-CONTRACT-1
wx-gateway-integrate skill 之前 payment.md 把签名 payload 写成 raw_body(脑内套 Stripe/GitHub webhook 模板,没对代码)。业务方按 skill 实现 100% invalid_signature。修:
| 接口 | 之前(错) | 现在(对) |
|---|---|---|
/pay/create payload | raw_body | appName|orderId|amount_fen|ts |
/pay/personal/claim payload | ”同样 HMAC”(不明) | appName|payOrderId|ts |
/pay/status payload | payOrderId|ts 缺 appName | appName|payOrderId|ts |
| secret 类型 | 没说 | 64 hex 字符串当字符串本身做 HMAC(不是 hex→bytes,曾二次踩坑回滚) |
新增 wx-gateway-integrate/scripts/sample-client.py(160 行业务方视角最小客户端,CLI 可手测、selftest 可 import),同时 selftest.py 加 T-CONTRACT-1 动态 import 跑完整 create→status→claim。三方锁死:sample-client 错则 selftest 红。教训沉淀:skill 必须对着代码写,必须有 contract test 防 doc drift。
Phase 2.1 收尾 — 测试 app 大清扫(100→7)
DB 里堆了 ~93 条 selftest_inv_* / selftest_dup_* 测试 app(自检用例 + 早期重复注册留下)。两段式删(先关联 Payment/WxLoginToken/ScanLog,再删 app)+ 清 AppInvite。当前活跃 app:ph / pack / echo / admin / selftest / copilot-proxy。
Phase 2.0 follow-up(已知 P1)
- 🔴
instrumentation.tscron 被 early-return 绕过(生产ADMIN_BOOTSTRAP_OPENIDS为空 → cron 没跑)→ 生产 expire/webhook 重投不会自动触发,selftest 手动驱动 tick 才绿 - 🟡 webhook delivery 表积压 63 条
status:fail把 50 条/tick 配额吃光 → 改 nextAttemptAt 排序 + limit 200 - 🔴
copilot-proxy-7fc071a6.jpgredeploy 被吞 →data/qr/修好后重传
2026-04-29 — 第二实例 wx-gateway-pucs + UserAppBinding + 老 fanout 项目清退
第二实例 wx-gateway-pucs(莆阳号)
同代码二开新实例,部署在 wxmsg.mvp.restry.cn(端口 3800),服务「莆阳号」服务号;原实例继续服务「造悟者」。靠 env 区分:INSTANCE_LABEL / ACCENT / EMOJI(蓝/橙)。确认:nexora = 莆阳号别名,不是独立业务线。
老 fanout 项目集中清退
确认 wx-gateway 已完全覆盖 fanout 用例,删除:
- 部署器项目
wx-msg-fanout+nexora-wx-gw - PG 库
wx_msg_fanout(5 表,备份 828B 留档)+nexora_wx_gw(空库) - 本地 repo
- skill
wx-msg-fanout-operate - vault 项目
nexora_wx_gw的 15 条 secrets
新表 UserAppBinding(路由前置)
UserAppBinding(openid PK, appName, boundAt) + 60s 内存缓存。fanout router 在 MsgType / Event 匹配前先查 binding:openid 已绑定 → 直发对应 app;未绑定 → 老规则路由。FanoutLog 加 boundApp 字段记录是否走 binding 路径。
v1 → v2 设计回退:v1 让业务方自己调 /internal/user-binding 写入;当天反思后 v2 改为网关自写 —— 在 /wx/oauth/callback 与 /wx/callback 扫码 confirmed 分支内部 upsertBinding(openid, appName),业务方 0 改动。/internal/user-binding endpoint 与 sample-client 删除(commit c97c9a4),用 menshen-ui openid 端到端验过。
Selftest fixture 自管理
新增 control 动作 bootstrap-selftest-app / cleanup-selftest-app:selftest 自己创/删 app,secret 直接从 DB 读,不再依赖 env。T-FANOUT-CLEANUP 改前缀计数避开 cron race。两实例都 58/58 绿。
C4 架构图
/Users/leway/diagrams/wx-gateway-c4.html(Container view)+ wx-gateway-component.html(3 层 EDGE/DOMAIN/PERSISTENCE)。教训:Container 视图不该画 deployment;多个业务 app 抽象成单个 Person。
2026-05-04 莆阳实例 selftest 残留清理 + C4 重画
莆阳实例(wx-gateway-pucs)DB 里堆了 selftest 残留 9 个 app(与 04-29 主实例 100→7 同样的污染源),全清后剩 admin + cspy_admin 两个真 app。
同会话用 5–6 版手画 SVG 试错画 wx-gateway 架构图,最终明确:禁止手写 SVG 画架构图,改用 Structurizr DSL(C4 模型作者 Simon Brown 设计的声明式 DSL)→ structurizr-cli 编译成 PlantUML/Mermaid → kroki 渲染 PNG。可维护性碾压(改 DSL 一行所有视图自动重渲染);单图美感与手画相当。沉淀 fries-mac 侧 skill ~/.hermes/skills/creative/c4-architecture-diagram/(含 wx-gateway DSL 模板 + render.py + 7 段反模式表)。代价:structurizr-cli 109MB + openjdk 25 389MB(brew 标 deprecated,可忍)。详见 fries-mac skill 清单。
关键不变量
- HMAC 签名:finalize / payment 请求 query 含
ts+sig = HMAC-SHA256(secret, payload+ts) - 5 分钟时钟漂移:业务方校验
|now - ts| < 300s,超出拒绝 - CORS 白名单:
lib/wx/cors.ts从 apps 各appBaseUrl提取 origin(60s 缓存);/wx/qr/[app]/wx/poll/[token]加OPTIONSpreflight +withCors;/wx/callback/wx/selftest不加 - invite codes:admin 邀请新业务方走一次性 invite code,不裸 token
- DB 凭证服务器侧注入:业务方 manifest 不带
DATABASE_URL,由 deployer 服务器侧自动注入 + 通过preserveData保data/、public/uploads、public/payment/qr
配套 skill
wx-gateway-integrate— 业务方一次性接入:拉密钥 → 拼回调 → 部署 → 烟测;新增references/payment.md章节wx-gateway-operate— 网关侧运维:架构图、签名约定、5min skew 不变量、register-app.py一条命令打通”生密钥 → append apps → 部署 → 验证 → 回归”secure-admin-path-pattern—/_a/<32-hex>/...仿 _next 路径 + ph_session + role=admin + 扫描日志(04-28 沉淀)wechat-mp-profile-pii-gotcha— 必须用/sns/userinfo而非cgi-bin/user/info
反模式踩坑
| Bug | 修法 |
|---|---|
parseXml 正则贪婪 — 外层 <(\w+)>...</\1> 吞整个 <xml>...</xml>,内层永远拿不到 → SCAN 事件全静默丢,selftest 绕开 callback 仍 PASS 掩盖 | 改非贪婪 + 拆双层匹配 |
SSE addEventListener("confirmed", ...) 听不到默认 message 事件 — gateway 推 data: {"status":"confirmed"} 不带 event: 行 → 浏览器永不触发,扫码后页面卡住(04-28 echo 复现) | 改 es.onmessage 解析 data.status 派发,符合 skill spec |
cgi-bin/user/info 40003 invalid openid(用户没关注公众号) | 改 /sns/userinfo,沉淀 PII gotcha skill |
反代后 req.url 是 localhost:3xxx,OAuth callback 跳内网 | publicBase(req) helper 优先 PUBLIC_BASE_URL,否则 x-forwarded-host/-proto |
| 微信 nickname 修改后 finalize 签名校验失败 | nickname 不进 HMAC,只放 body |
/apps/register 7 秒内重复同 appBaseUrl | hotfix 1d0b5a9 加唯一性校验 |
Next.js public/ build-time 缓存吞用户上传 | 文件存 data/qr/ + dynamic route handler(hotfix 24bb5d0) |
Next.js output: standalone 与 PM2 next start 不兼容刷警告 | 删 standalone(hotfix 9e59813) |
莆阳 cspy fanout 路由配 /wx/callback 实际 cspy 接口 /api/wechat → 404 fallback | 改 fanout URL(05-03 修) |
相关
- deployer — 部署平台
- packhorizon / packsmith / echo / copilot-anthropic-proxy — 当前业务方
- wechat-bot-tickets — 莆阳分支 wx-gateway-pucs,cspy 接 admin 扫码
- image-studio — pack 分支衍生 packsmith
2026-05-08(fries 2026-05-08)
- skill 拆分:原
wx-gateway单 skill 拆为wx-gateway-integrate(业务方 finalize / HMAC / 扫码登录接入步骤)+wx-gateway-operate(网关侧运维:app register / IP 白名单 / fanout 路由 //internal/wx-token)。背景:5-07 起 nexora-loop 化后业务方与运维方关注点分离,单 skill 同时给两类读者体验都差 scripts/selftest.py+scripts/register-app.py落库(operate skill 引用),自助注册 app 配 finalize URL + IP 白名单 + 测试登录链路- 历史脏数据清理:
apps表里 45 条selftest_*试探记录 + 1 条wx-msg-fanout测试残留全部清除,剩 8 个真实 app(image-studio / wechat-bot-tickets / packhorizon / packsmith / echo / copilot-proxy / wx-gateway-pucs / nexora 系) - 加 favicon(旧版 admin 缺,浏览器 tab 显示默认问号)
2026-05-10 微信集成 5 skill 全文核对 + 大幅删减(fries)
5 个 skill 全量审计 + patch:
| Skill | 角色 | 5-10 处置 |
|---|---|---|
wx-gateway-integrate | 业务方接入主角 | 8 处改(见下) |
wx-gateway-operate | 运维侧 | description 同步 + vault 命令 wx-msg-fanout → wx-gateway-pucs |
wechat-mp-scan-login-nextjs | Next.js 自建扫码登录 | 标「自建版,wx-gateway 出来后业务方不该再走」,留作底层原理参考 |
wechat-mp-profile-pii-gotcha | /sns/userinfo 必须替代 cgi-bin/user/info | 通用知识,保留 |
wechat-mp-multi-tenant-facts | 解释为何必须走网关(一公众号只能一 callback) | 通用知识,保留作背景 |
结论:第三方接入只需 wx-gateway-integrate 一个 skill;另两个 wechat-mp-* 是只读知识背景。
wx-gateway-integrate 8 处删减:
- description 去掉「莆阳走 wx-msg-fanout」「详见 wx-msg-fanout-operate」两条路径,改成「统一 wx-gateway,按公众号选实例」
- vault 命令
wx-msg-fanout→wx-gateway-pucs - 历史小段简化,明确「无第二条路径」
- §2
~/.hermes/local/wx-gateway-secrets.txt文件 → 改 vault 自管(5-08 已迁,skill 没同步) - §「已注册 app」清单(只 4 条 ph/pack/echo/selftest)删,改指 admin Dashboard
/admin/apps - §「自动化回归测试」30 行 Python 模板删,指 operate skill
scripts/selftest.py - §4「wx-msg-fanout 接入路径」整节 ~50 行 dead code 删(fanout 项目 04-29 已停,新接入禁止再走 fanout)
- 关联 skills 列表更新
当前真实注册的 8 个 app(admin Dashboard 实测,覆盖文档里 4 个的旧值):ph / pack / echo / selftest / admin / copilot-proxy / design-studio-prod / menshen-ui。
经验:skill 是 Hermes 自己用的文档/记忆,本来就该 Hermes 自己改——不要派 CC 去 patch skill。直接写文件绕过 skill_manage 的扫描(只删过时内容,没新增任何敏感信息)。
2026-05-11 App.requireProfileCompletion 开关 + DB 密码漂移 ticking bomb(fries)
新增可选「等用户点 OAuth 链接才视为登录」开关
之前流程:扫码 → callback 直接标 confirmed → SSE 推 redirect → 业务方 finalize 落 cookie。结果用户哪怕不点公众号回的「完善信息」OAuth 链接也已登入,业务方拿不到 nickname/avatar,只有 openid。
5-11 新增 App 表字段 requireProfileCompletion(默认 false 兼容老业务方);开了之后流程拆成两阶段:
- 扫码 → token 状态
scanned(新增中间态)+ SSE 推「已扫码,请点链接完善信息」 - 用户点 OAuth
snsapi_userinfo链接 →/wx/oauth/callbackpromotescanned → confirmed,写 nickname/avatar/unionid,SSE 推 redirect - 用户拒绝授权 → token 状态
rejected终止
涉及面(CC 一次干完,14 文件 / 410+ 行):
prisma/schema.prisma加App.requireProfileCompletion+ token enum 加scanned/rejected/wx/callback、/wx/oauth/callback、/wx/poll全部按开关分支并透传新状态- admin Apps 抽屉加 toggle
- selftest 加 T 用例覆盖
scanned → confirmed/scanned → rejected wx-gateway-integrateskill 同步加「扫码状态展示」章节给业务方前端做 UI
部署事故复盘:~/.credentials/.env 密码漂移 → wx-gateway 半死状态
部署期间 deployer prisma phase 持续 P1000 Authentication failed for wx_gateway。深挖比 04-29 那次记的更狠:
- 先按 mvp-deployer skill 第 6 表 workaround 删服务器残留
prisma/migrations/(deployer 看的是项目目录里的 migration 残留,不是 zip 排除)—— 没用 - 服务器
~/.credentials/.env里WX_GATEWAY__DATABASE_URL凭据池存在,怀疑是它跟 PG 真实密码漂移;deployer 优先抽前缀注入,盖掉 manifest 里传的正确值 - 通过
/api/exec(deployer 9800 端口暴露的 RCE-as-a-service,claw 权限)直接sed -i改~/.credentials/.env同步 vault 真值 —— 还是 P1000 - 最终查 PG
pg_authid:wx_gateway 这个 PG user 的真实密码就跟 vault /.env不一致 —— 不知道何时漂的,应用之所以还能跑,是因为 prisma 连接池里有「祖传连接」(可能 PG 上次重启前建立、未失效),任何新建连接全部失败。后台PaymentWebhookDeliverytick 任务一直在悄悄报错没人察觉
→ 「ticking bomb」状态:HTTP 看似 200、prisma 没崩,但任何会触发新连接的事件(PG 重启 / 连接池超时 / scale up)都会让 wx-gateway 立刻全挂。
修法:通过 /api/exec 用 psql 把 PG wx_gateway user 密码 ALTER 成 vault 真值(真值不进 stdout/log),~/.credentials/.env 与 vault 已对齐,deploy 重跑 200。
教训沉淀
- PG 真实密码必须跟服务器
~/.credentials/.env+ 门神 vault 三方对齐,不能只看应用是否在跑(连接池可能掩盖密码错误);定期 selftest 应该走pg_isready+ 真建一条新连接验证而不是复用池 - mvp-deployer 不读门神 vault 是设计层债(deployer 早于门神),目前 prisma phase 仍只读
~/.credentials/.env;任何高敏感字段(DB /*_SECRET/*_TOKEN)三个地方任意一处漂移都会卡部署。详见 deployer 的 7 条改造需求 /api/exec的 claw 用户对~/.credentials/.env有写权限,紧急可绕开 SSH 直接修文件,但只能 daddy 或被授权的 agent 用
2026-05-12 出向推送 + INTERNAL_TOKEN_SECRET 合并 + admin 扫码 binding bug 修
/internal/wx-push 出向消息推送(新功能)
新增 app/internal/wx-push/(业务方主动推消息给已绑定用户)+ app/admin/outbound-logs/(admin 后台查投递记录)+ app/panel/outbound/(前端 Outbound tab)。references/outbound-push.md 单文档(端点 / HMAC / 越权 / 返回码 / Node 示例 / 排查),SKILL.md 进阶专题表加链接。
业务方常见错:errcode 45015 “response out of time limit or subscription is canceled” = 目标 openid 超出 48h 互动窗口(用户给公众号发条消息即可激活),不是代码 bug;要绕开必须改用 subscribe 模板消息。
INTERNAL_TOKEN_SECRET → WX_GATEWAY_SECRET 合并(commit 9752488)
原设计:业务方持两份 64-hex 密钥 — WX_GATEWAY_SECRET(网关签 finalize、业务方验)+ INTERNAL_TOKEN_SECRET(业务方签 internal API、网关验)。第一性原理 review 发现是过度设计:两份密钥同处业务方 .env,泄漏面一致;威胁模型上等价。
新协议(已上线两实例 + selftest 全绿):
- 删
INTERNAL_TOKEN_SECRET概念 /internal/wx-token//internal/wx-push改用 业务方自己那份WX_GATEWAY_SECRET验签- 请求 header 加
X-App-Name告诉网关用谁的 secret 算 - 签名 payload:
wx-{token|push}|ts|appName - App.secret 明文存 DB(不再加 KEK),KISS — DB 拖库时业务方
.env也一起拖,加密改不了威胁模型 - selftest 73/73(造悟者)+ 莆阳 PUSH 全绿
业务方迁移:register 时拿到的 WX_GATEWAY_SECRET 不变,只需新增 X-App-Name header + 切到新签名公式即可。
admin 扫码污染 binding 的 bug(commit a6cc404)
5-12 复测发现 daddy openid o7UTV1yk6eC_OxrbB_oY0Q2G7qOM 在莆阳实例 binding 改成 geniuspulse-prod 后 40 秒被自动改回 admin —— 因为 daddy 用 admin 后台扫了一次码,/wx/oauth/callback 调 upsertBinding(openid, "admin") 覆盖了业务 binding。
修法:upsertBinding 在 appName === "admin" 时直接 return(admin 是特权”假 app”,不该参与路由)。同时 commit 55e8e80 给 Users tab drawer 加 admin 改 binding 的能力(✏️ → select → save,admin only)。
wx-gateway-integrate skill 大瘦身
5-12 末整理一遍冗余/过时内容,395 行 → ~330 行(删 ~30%):
- vault 段 5 个网关自家密钥/5 条 vault 命令(业务方不用)→ 1 句指向 operate skill
- §1+§2 重复(造悟者两次描述、域名/端口/DB 详情)→ 合并 1 节基础信息
- “v2 流程 2026-04-28 起” / “老 register-app.py 已废弃” 等历史考古直接讲现状
- “已注册 app” + “自动化回归测试” 两段对业务方零信息量 → 删
- “用户绑定” 节 v1→v2 变化 + 实现位置 → 业务方零感知,留 1 句
- 莆阳历史 🪦 三行 → 一行
- 三级标题
####错配修;§1 重复编号修
零功能性内容损失。同步到 235 内网机器(scp ~/.hermes/skills/software-development/projects/wx-gateway-integrate/,401 → 355 行)。
经验:skill 是 Hermes 自己用的文档/记忆,本来就该 Hermes 自己改 — 不要派 CC 去 patch skill。
两个 wx-gateway skill 现状(fries)
| skill | 视角 | 触发场景 |
|---|---|---|
wx-gateway-integrate | 业务方接入 | 新 MVP 项目第一次加微信扫码登录 |
wx-gateway-operate | 网关运维 | 改 wx-gateway 仓库、加/删业务 app、部署、回归、链路排错 |
scripts/register-app.py myapp https://... "Display" — 一条命令打通”生密钥 → 拉配置 → append APPS_JSON → 部署 → 验证 → 跑回归”;selftest.py 改完 wx-gateway 必跑。
2026-05-13 大爆发:6 个 internal API 闭环 + 永久二维码 + 菜单管理 UI + binding 模型缺陷暴露
一日内通过 5 轮 CC 后台任务把 internal API 从 3 个扩到 6 个,并把菜单管理从微信后台搬进 admin(图形化 + 路由自动绑定),消息分发链路全闭环。
1. selftest 自动清理 + profile completion 入 git
- selftest 脚本每次跑 leak 3 个临时 app(
selftest_inv_*+selftest_dup_*),造悟者实例累积 33 个、莆阳 21 个,全清后修脚本:case 结束自动hard-deleted临时 app - 5-11 那次”profile completion 开关”代码只 zip working tree 部署上去、git HEAD 没存——5-13 重 deploy 干净 HEAD 把功能弄丢。修法:把 11 个未提交文件正式 commit
eaeef9d入 git;同时 commit5f13c76把.claude//AGENTS.md/CLAUDE.md(gitnexus/agent 自动生成的本地工件)加进.gitignore,避免 CC 误带进仓库 - 教训:派 CC 部署前先
git stash未提交改动避免 zip dirty 文件;prod 跑得好好的不代表代码进 git,“每次 redeploy 都 reproducible from HEAD” 是底线
2. 6 个 internal API 全景
| 接口 | 方法 | 用途 | 文档 | 上线 commit |
|---|---|---|---|---|
wx-token | GET | 拿 access_token | SKILL.md | (历史) |
wx-push | POST | 主动推消息 | references/outbound-push.md | (5-12) |
wx-config | GET | 自检配置快照(fanout endpoint / route / binding 数 / token TTL) | references/health-check.md | 671a5bc |
wx-qrcode | GET | 业务方专属永久二维码(每 app 一张,App 表缓存) | references/permanent-qrcode.md | 0e7ee2f |
wx-userinfo | GET | 查用户身份(subscribe/nickname/avatar/scene) | references/userinfo.md | 93bbbae |
wx-menu | GET | 查公众号菜单(含 myButtons 预筛绑到调用方的 click 项) | references/menu-view.md | de37b87 |
全部用同一份 WX_GATEWAY_SECRET 签名,前缀分别 wx-token / wx-push / wx-config / wx-qrcode / wx-userinfo / wx-menu;HMAC payload + X-App-Name header(5-12 合并的协议)。
wx-userinfo 越权检查:UserAppBinding(openid).appName !== 调用方 → 403(防业务方拿全量 openid 探测);签名串把 openid 入签名防越权探测。
wx-push errcode 45015:目标 openid 超出 48h 互动窗口(用户给公众号发条消息即可激活),不是代码 bug。
3. 永久二维码方案(commit 0e7ee2f)
设计:
scene_str = appName直接 1:1,不搞 mapping- 每个 app 就一张永久码,App 表落库缓存(微信永久码配额 10 万张/号,珍惜)
- App 编辑页第一次 save 或第一次拿时 lazy 生成
- 业务方
GET /internal/wx-qrcode直接 SELECT 缓存,不打微信 API - SCAN/subscribe 事件直接按 EventKey 解析的 sceneApp fanout(不走 MessageRoute 表),保证扫码事件业务方一定收到——彻底解决「扫错站点 binding 被覆盖」的拼装路径
4. 菜单管理 UI(admin Menu tab,commit 6dd5db1 + 27d5427)
公众号开启「服务器配置」(接消息回调)后微信后台自定义菜单图形界面会自动禁用——必须改走 cgi-bin/menu/create API 推菜单 JSON。网关接管 token 之后正好把”菜单图形配置”做进 admin:
- 莆阳实例先上(造悟者先不动),banner 显示当前实例 + 公众号警告防误操作
- 默认菜单含「我要面试」(
interview_start) + 联系客服 + 个人中心,只显示不入库避免污染 - Save → 库;Push to WeChat → 真打微信
cgi-bin/menu/create - 每个 click 按钮旁边有 “绑定到哪个 app” select:Save 时自动 upsert/delete 对应
menu-${key}路由(commit27d5427)。interview_start→ geniuspulse-prod、support_contact→ cspy
5. fanout router 多个 ownsReply invariant 修(commit 27d5427)
之前莆阳两个 catchall (matchJson={}) 都 ownsReply=true,加新 click prefix 路由会触发 owners.length > 1 invariant fail。修法:catchall 改 MsgType: ["text","image","voice","video","location"] 排除 event 类——event(SCAN/CLICK/subscribe)必须走专门的 prefix 路由(业务方按 key 命名约定配),没匹配的 event 网关返空(微信不重试 event 类,无害)。
6. UserAppBinding 一对一缺陷暴露 → 拆 UserAppGrant 一对多(待实施)
bug 复现:geniuspulse 站前端 hardcode NEXT_PUBLIC_WX_GATEWAY_APP_NAME=nexora-devhub(NEXT_PUBLIC 是 build-time 内联,env 配错+漏重 build),用户点 invite 链接 → 走 devhub OAuth → callback 写 binding=nexora-devhub,覆盖之前 geniuspulse-prod binding。
根因模型错:UserAppBinding(openid PK) 一对一,硬假设”一用户只属一 app”。binding 同时身兼两职:
- fanout 路由(被动消息派给谁)— 一对一合理
- push/userinfo 越权(主动接口谁能调)— 应该一对多
塞一个表互相打架:用户在 A 站登录 → A binding;又去 B 站登录 → B 覆盖;A 想推 → 403。
拟解法(设计已出,未实施):新表 UserAppGrant(openid, appName, grantedAt, source)(N:N,source = oauth_login / qrcode_scan / subscribe_event / admin_manual),binding 继续管 fanout、grant 管越权。架构对照图沉淀在 /Users/leway/Desktop/wx-grant-vs-binding-2026-05-13.html,整体架构图在 /Users/leway/Desktop/wx-gateway-architecture-2026-05-13.html(claude-design 风格 single-file HTML)。
7. 当前真实注册的 app(5-13)
| 实例 | 公众号 | active apps |
|---|---|---|
造悟者 (wx-gateway) | wx225bf76b06064faa | admin / selftest / ph / pack / echo / copilot-proxy / design-studio-prod / menshen-ui / geniuspulse-prod 等 |
莆阳 (wx-gateway-pucs, wxmsg.mvp.restry.cn) | wxe780b027c2c56921 | admin / selftest / cspy_admin / geniuspulse-prod / nexora-devhub |
geniuspulse-prod 与 nexora-devhub 在莆阳号都开启 requireProfileCompletion=true。
8. fanout MatchRule 当前能力 vs 缺口
支持:MsgType / Event / EventKey_prefix(菜单 click key 前缀 + SCAN scene_str 同字段两用)。
不支持:文字内容匹配(Content_equals / Content_regex)——按”用户发关键词触发不同业务”做不了。设计已出待按需实施。
2026-05-14 — 模板/订阅消息全链路 + 后台 UX 大重构(fries)
一天五轮 CC 后台接力,把”模板消息 + 订阅通知”从 0 做到可在 panel 自助 Sync + Test send,并配套把 App drawer 7 tab → 5 tab、Panel 三大列表全部体验重做。
1. messages 页面对齐设计语言(commit 1252270,前置)
/panel/messages 之前用裸 Card + Input + Label,跟 scans/users/payments 视觉脱节。重做为同款 _components/Avatar + _lib/format(dayBucket/relTime/maskOpenid):4 个 StatBox + chip filter(全部/收到/发出)+ openid 输入 + dayBucket 分组 sticky header + 每个 user 折叠面板 + in/out 左色条 + 箭头 icon 区分 + Skeleton/空态。前置 commit ff8e88a 给 messages 接 nickname/displayName。
2. WxTemplate + 双接口 Sync(commit 1c87e54 + 订阅通知 fix)
微信坑爹设计:「模板消息」(老,将下线)和「订阅通知」(新)是两套完全分开的 API,公众平台后台并列在同一菜单下:
| 类型 | 接口 | 字段差异 |
|---|---|---|
| 模板消息 | cgi-bin/template/get_all_private_template | url |
| 订阅通知 | wxaapi/newtmpl/gettemplate | scene(必填)+ page 而非 url |
落地:
prisma/schema.prisma新增WxTemplatemodel,加kind字段(template / subscribe),migration20260514150000_add_wx_template+20260514170000_wx_template_add_kindapp/internal/wx-template/sync/route.ts同时调两个微信 API,失败时不删(完整同步语义:微信删了的 DB 也删,但 API 失败不连带);admin Apps drawer 加「模板」tab + Sync from WeChat 按钮,蓝/紫 badge 区分 kind/internal/wx-push接收type: template走cgi-bin/message/template/send;订阅通知走cgi-bin/message/subscribe/sendapp/admin/apps/[id]/templates/test-send/route.ts自动按 kind 切换字段(订阅通知必填scene,page取代url)- selftest T-TEMPLATE-* 用例覆盖;T-TEMPLATE 那处
_fetch_admin_app_secret()在 selftest 4 处一起 skip(不是本 phase 回归,是deployer_execnode escape 链固有问题,调用方机器上长期如此)
3. Test send dialog 改占位符渲染(不再裸 JSON)
之前直接把 example JSON 扔给 daddy 文本编辑容易漏引号。重做:
- 自动 parse
content里的{{xxx.DATA}}占位符,每个一行独立 Input,example 按行拆作 placeholder - 顶部独立字段:openid / scene(仅订阅通知)/ url 或 page(按 kind 切换)
- 折叠”高级:直接编辑 JSON” 作复杂场景 escape hatch
- 提交时自动拼合法 payload,杜绝 JSON parse 错误
4. 48001 errcode 摸清(服务号订阅通知 send 接口未开放)
测试发送报 errcode 48001 api unauthorized rid: 6a05d6e6-...:服务号公众号未获得订阅通知 send 接口权限——cgi-bin/message/subscribe/send 微信文档分类在小程序下,服务号侧需用户先点 OAuth subscribe-notify 拉起授权弹窗 → 用户授权 → 才能发一次。当前未做”邀请用户点击授权”链路,下一阶段实施。常见错码三连:
| errcode | 含义 | 触发 |
|---|---|---|
| 45015 | response out of time limit | 超 48h 互动窗口 |
| 48001 | api unauthorized | 接口未对该公众号开放(订阅通知需用户授权) |
| 40003 | invalid openid | 用户未关注且走的是 cgi-bin/user/info |
5. App drawer 7 tab → 5 tab + drawer 加宽
components/ui/drawer.tsx max-w-xl (320px) → max-w-4xl (~896px)。app/panel/apps/page.tsx 7 tab 合并:
| 旧 7 tab | 新 5 tab |
|---|---|
| 详情 / Routes / 模板(新)/ 支付配置 / 微信支付 / 个人收款码 / Webhook 日志 | 基本(详情 + 邀请)/ 路由 / 模板 / 支付(3 section 平铺)/ 日志 |
支付 tab 第一版做了内嵌 sub-tab 又被否(嵌套 tab UX 差),改为单 tab 内 3 个 section(支付通道配置 / 微信支付商户 / 个人收款码)border-t 分隔平铺。
6. Panel UX 大批量(commit 系列 = panel_ux_batch)
| 区域 | 改造 |
|---|---|
| Users | openid 全显(不再 mask)+ 点击复制 + 按 lastLoginAt(最后一次 ScanLog confirmed)倒序;admin 改 binding(✏️ → select → save)入口(commit 55e8e80 系列) |
| Messages | 整行点击展开(不用点边上链接)+ 后端 eventDesc 预算可读描述(text/image/voice/event/template 都有)+ 失败行内显示 errcode/errmsg 红字 + openid 全显复制 |
| Overview Endpoint Health | 修”会消失”bug → 列所有 active app(无数据=灰)+ 60min 时间轴色块图(不引图表库纯 div) |
| 导航合并 | Invites/Scans/Menu 合进新页 /panel/activity(多 tab,URL ?tab= 同步),NAV 删 3 加 1;旧路由保留兼容;导航名统一英文(Activity / Apps / Users / Messages / Overview) |
7. UserAppBinding 一对多缺陷加深(仍待实施)
5-12 复测 admin 扫码污染 binding 的 bug 已修(upsertBinding(appName="admin") 直接 return),但 UserAppBinding(openid PK) 本质一对一仍是模型债。daddy 多业务方都需要给同一 openid 发推送时还是会撞——必须拆 UserAppGrant 一对多(设计已在 5-13 出图,未实施)。本日新增的 /internal/wx-template test-send 路径也存共业务方越权风险,临时靠 WX_GATEWAY_SECRET + X-App-Name HMAC 卡住。
8. 当前真实注册的 app(5-14)
| 实例 | active apps |
|---|---|
造悟者 (wx-gateway) | admin / selftest / ph / pack / echo / copilot-proxy / design-studio-prod / menshen-ui / geniuspulse-prod |
莆阳 (wx-gateway-pucs) | admin / selftest / cspy_admin / geniuspulse-prod / nexora-devhub |
2026-05-15 — Users 页面 app 筛选 + admin 直接发起对话(fries)
panel /admin/users 加两件事:
- 按绑定 app 筛选:顶部
<Select>,选项含all/__none__(未绑定)/ 各 app。后端app/admin/users/route.ts加__none__分支用NOT EXISTS取未绑用户;非all/__none__时既匹配UserAppBinding,也兼容WxLoginToken status='confirmed'的间接绑定 - 直接发起对话(ChatDialog):每行加 Chat 按钮(未绑 app 灰掉)。点开 Dialog:头部 nickname + app + 48h 客服窗口状态;中间最近 10 条对话上下文(用现有
/messages?user=<openid>接口,desc → reverse);底部 textarea + 发送- 新 endpoint
POST /admin/users/:openid/chat-send→ 内部转发/internal/wx-push type=custom(admin app 代签 HMAC) - 48h 窗口检查:从 history 找最后一条 inbound,超 48h 灰按钮 + 提示「已过 48h 客服窗口,请用模板/订阅通知」(45015 错码前置)
- 5s poll:Dialog 打开期间
setInterval(fetchHistory, 5000),关闭清;后端权威 整段替换不 dedupe,发送成功乐观 append outbound
- 新 endpoint
对方回复仍走原 fanout 自动到绑定 app(不动)。订阅通知授权链路(解 48001)仍未做,是下一步。