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 + commit 1c87e54 系列)— (2026-05-14)
  • App drawer 7 tab → 5 tab + max-w-xlmax-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 展示(commit ff8e88a)— (2026-05-14)
  • 永久二维码方案上线 commit 0e7ee2f(scene_str=appName 1:1 + lazy 生成 + App 表缓存)— (2026-05-13)
  • 菜单管理 UI 莆阳实例上线 commit 27d5427(click 按钮绑 app + 自动 upsert menu-${key} 路由)— (2026-05-13)
  • catchall route 排除 event 类(修 multiple ownsReply invariant fail)— (2026-05-13)
  • selftest 自动清理临时 app + profile completion 11 个文件正式入 git commit eaeef9d、gitnexus 工件加 .gitignore commit 5f13c76 — (2026-05-13)

背景

之前每个业务方(image-studiowechat-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-pucswxmsg.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内容
1db0d8b5 / ee29c04apps 表 + 60s TTL 热加载替代 redeploy
2db86edfinvite code 接入授权(限 prefix + host pattern,一次性消费)
3fb53488admin 微信扫码登录白名单,bootstrap 走 ADMIN_BOOTSTRAP_OPENIDS env

设计风格沿用 image-studio(DM Sans + #C24E29 + #F8F7F4)。

当前注册的 apps(04-29)

nameappBaseUrl用途
phpackhorizon.mvp.restry.cnpackhorizon
packpack.mvp.restry.cnpacksmith
echoecho.mvp.restry.cnecho(NextAuth WX provider)
copilot-proxyapi.eagle.openclaws.co.ukcopilot-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 是「微信开放平台」机制,订阅号 / 服务号本身不会自动给 —— 必须把公众号绑到开放平台账号下:

  1. https://open.weixin.qq.com/ 注册开放平台账号(同主体即可)
  2. 完成开发者资质认证:¥300/年,对公账户打款验证
  3. 管理中心 → 公众账号 → 绑定公众号
  4. 绑定后新关注/扫码用户回调里 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 | disputedpending → 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
13d9ddd0schema + migration(0 DROP)+ App 新列18/18
2d5e051d/pay/create / /pay/status / /pay/personal/claim + HMAC + 状态机18/18
37592620admin Dashboard Payments tab(5th tab)+ approve/reject/redeliver18/18
49bcbc48webhook 投递 worker + expire cron + redeliver cron18/18
5e70f813selftest 增量 T-PAY-1..1028/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 Apps tab 加 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/registerappBaseUrl 唯一性检查 — 防 copilot-proxy / -prod 7 秒内连开两个的事故复发
9e59813output: standalone(PM2 next start does not work with standalone 警告刷屏)
3eae5cbselftest rate-limit bypass middleware → 32/32
24bb5d0 + d37a2e9prerender 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 -prodcopilot-proxy,apps 缓存 60s TTL 自动失效,业务方再扫一次。根因/apps/register 没做 appBoardUrl 唯一性校验 → 收进 hotfix 1d0b5a9

Phase 2 业务方接入 — 复用同 secret

copilot-proxy 登录已接入,支付链路复用同一 secret + 同一 app name,不再签发邀请码。业务方只需:

  1. wx-gateway-integrate skill 的 references/payment.md(含 4 接口 spec + 最小骨架代码)
  2. 实现 4 调用:/pay/create / /pay/personal/claim / /pay/status / webhook handler
  3. 给网关 admin 填 webhookUrl(如 https://api.eagle.openclaws.co.uk/api/wx/payment-webhook

爸爸侧补 2 件:上传真个人收款码 PNG(落 data/qr/ 持久化跨 deploy) + 注册 webhookUrl。

Phase 2.1(04-29 三批 CC 接力)— 管理后台支付配置面

commitselftest内容
2.1a84612e735→37Apps 抽屉编辑 webhookUrl(PATCH /admin/apps/[id],复用 personalQrUrl 校验范式 https:// + URL parse + ≤512)
2.1b7aa41b737→40Payment 详情抽屉 + webhook 投递时间线(join PaymentWebhookDelivery)+ 手动 extend / force-expire
2.1c16de581 + 5242a3c40→44App 级支付策略 paymentEnabled / maxAmountFen / defaultExpiresIn + Settings tab 总览卡

copilot-proxy 04-29 同日开支付:paymentEnabled=truemaxAmountFen=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 payloadraw_bodyappName|orderId|amount_fen|ts
/pay/personal/claim payload”同样 HMAC”(不明)appName|payOrderId|ts
/pay/status payloadpayOrderId|ts 缺 appNameappName|payOrderId|ts
secret 类型没说64 hex 字符串当字符串本身做 HMAC(不是 hex→bytes,曾二次踩坑回滚)

新增 wx-gateway-integrate/scripts/sample-client.py(160 行业务方视角最小客户端,CLI 可手测、selftest 可 import),同时 selftest.pyT-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.ts cron 被 early-return 绕过(生产 ADMIN_BOOTSTRAP_OPENIDS 为空 → cron 没跑)→ 生产 expire/webhook 重投不会自动触发,selftest 手动驱动 tick 才绿
  • 🟡 webhook delivery 表积压 63 条 status:fail 把 50 条/tick 配额吃光 → 改 nextAttemptAt 排序 + limit 200
  • 🔴 copilot-proxy-7fc071a6.jpg redeploy 被吞 → 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;未绑定 → 老规则路由。FanoutLogboundApp 字段记录是否走 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]OPTIONS preflight + withCors/wx/callback /wx/selftest 不加
  • invite codes:admin 邀请新业务方走一次性 invite code,不裸 token
  • DB 凭证服务器侧注入:业务方 manifest 不带 DATABASE_URL,由 deployer 服务器侧自动注入 + 通过 preserveDatadata/public/uploadspublic/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.urllocalhost:3xxx,OAuth callback 跳内网publicBase(req) helper 优先 PUBLIC_BASE_URL,否则 x-forwarded-host/-proto
微信 nickname 修改后 finalize 签名校验失败nickname 不进 HMAC,只放 body
/apps/register 7 秒内重复同 appBaseUrlhotfix 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 修)

相关

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-fanoutwx-gateway-pucs
wechat-mp-scan-login-nextjsNext.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 处删减:

  1. description 去掉「莆阳走 wx-msg-fanout」「详见 wx-msg-fanout-operate」两条路径,改成「统一 wx-gateway,按公众号选实例」
  2. vault 命令 wx-msg-fanoutwx-gateway-pucs
  3. 历史小段简化,明确「无第二条路径」
  4. §2 ~/.hermes/local/wx-gateway-secrets.txt 文件 → 改 vault 自管(5-08 已迁,skill 没同步)
  5. §「已注册 app」清单(只 4 条 ph/pack/echo/selftest)删,改指 admin Dashboard /admin/apps
  6. §「自动化回归测试」30 行 Python 模板删,指 operate skill scripts/selftest.py
  7. §4「wx-msg-fanout 接入路径」整节 ~50 行 dead code 删(fanout 项目 04-29 已停,新接入禁止再走 fanout)
  8. 关联 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/callback promote scanned → confirmed,写 nickname/avatar/unionid,SSE 推 redirect
  • 用户拒绝授权 → token 状态 rejected 终止

涉及面(CC 一次干完,14 文件 / 410+ 行):

  • prisma/schema.prismaApp.requireProfileCompletion + token enum 加 scanned / rejected
  • /wx/callback/wx/oauth/callback/wx/poll 全部按开关分支并透传新状态
  • admin Apps 抽屉加 toggle
  • selftest 加 T 用例覆盖 scanned → confirmed / scanned → rejected
  • wx-gateway-integrate skill 同步加「扫码状态展示」章节给业务方前端做 UI

部署事故复盘:~/.credentials/.env 密码漂移 → wx-gateway 半死状态

部署期间 deployer prisma phase 持续 P1000 Authentication failed for wx_gateway。深挖比 04-29 那次记的更狠:

  1. 先按 mvp-deployer skill 第 6 表 workaround 删服务器残留 prisma/migrations/(deployer 看的是项目目录里的 migration 残留,不是 zip 排除)—— 没用
  2. 服务器 ~/.credentials/.envWX_GATEWAY__DATABASE_URL 凭据池存在,怀疑是它跟 PG 真实密码漂移;deployer 优先抽前缀注入,盖掉 manifest 里传的正确值
  3. 通过 /api/exec(deployer 9800 端口暴露的 RCE-as-a-service,claw 权限)直接 sed -i~/.credentials/.env 同步 vault 真值 —— 还是 P1000
  4. 最终查 PG pg_authidwx_gateway 这个 PG user 的真实密码就跟 vault / .env 不一致 —— 不知道何时漂的,应用之所以还能跑,是因为 prisma 连接池里有「祖传连接」(可能 PG 上次重启前建立、未失效),任何新建连接全部失败。后台 PaymentWebhookDelivery tick 任务一直在悄悄报错没人察觉

→ 「ticking bomb」状态:HTTP 看似 200、prisma 没崩,但任何会触发新连接的事件(PG 重启 / 连接池超时 / scale up)都会让 wx-gateway 立刻全挂。

修法:通过 /api/execpsql 把 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/callbackupsertBinding(openid, "admin") 覆盖了业务 binding。

修法:upsertBindingappName === "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;同时 commit 5f13c76.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-tokenGET拿 access_tokenSKILL.md(历史)
wx-pushPOST主动推消息references/outbound-push.md(5-12)
wx-configGET自检配置快照(fanout endpoint / route / binding 数 / token TTL)references/health-check.md671a5bc
wx-qrcodeGET业务方专属永久二维码(每 app 一张,App 表缓存)references/permanent-qrcode.md0e7ee2f
wx-userinfoGET查用户身份(subscribe/nickname/avatar/scene)references/userinfo.md93bbbae
wx-menuGET查公众号菜单(含 myButtons 预筛绑到调用方的 click 项)references/menu-view.mdde37b87

全部用同一份 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} 路由(commit 27d5427)。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 同时身兼两职:

  1. fanout 路由(被动消息派给谁)— 一对一合理
  2. 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)wx225bf76b06064faaadmin / selftest / ph / pack / echo / copilot-proxy / design-studio-prod / menshen-ui / geniuspulse-prod 等
莆阳 (wx-gateway-pucs, wxmsg.mvp.restry.cn)wxe780b027c2c56921admin / 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_templateurl
订阅通知wxaapi/newtmpl/gettemplatescene(必填)+ page 而非 url

落地:

  • prisma/schema.prisma 新增 WxTemplate model,加 kind 字段(template / subscribe),migration 20260514150000_add_wx_template + 20260514170000_wx_template_add_kind
  • app/internal/wx-template/sync/route.ts 同时调两个微信 API,失败时不删(完整同步语义:微信删了的 DB 也删,但 API 失败不连带);admin Apps drawer 加「模板」tab + Sync from WeChat 按钮,蓝/紫 badge 区分 kind
  • /internal/wx-push 接收 type: templatecgi-bin/message/template/send;订阅通知走 cgi-bin/message/subscribe/send
  • app/admin/apps/[id]/templates/test-send/route.ts 自动按 kind 切换字段(订阅通知必填 scenepage 取代 url
  • selftest T-TEMPLATE-* 用例覆盖;T-TEMPLATE 那处 _fetch_admin_app_secret() 在 selftest 4 处一起 skip(不是本 phase 回归,是 deployer_exec node 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含义触发
45015response out of time limit超 48h 互动窗口
48001api unauthorized接口未对该公众号开放(订阅通知需用户授权)
40003invalid 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)

区域改造
Usersopenid 全显(不再 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 加两件事:

  1. 按绑定 app 筛选:顶部 <Select>,选项含 all / __none__(未绑定)/ 各 app。后端 app/admin/users/route.ts__none__ 分支用 NOT EXISTS 取未绑用户;非 all/__none__ 时既匹配 UserAppBinding,也兼容 WxLoginToken status='confirmed' 的间接绑定
  2. 直接发起对话(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

对方回复仍走原 fanout 自动到绑定 app(不动)。订阅通知授权链路(解 48001)仍未做,是下一步。