架构与边界:Pages / Node Functions / Edge Functions / KV
做“推送服务”最容易掉进一个坑:你以为难点是“发消息”,实际上难点是 在一个受限的运行环境里,把状态、鉴权、外部 API 的不确定性收敛住。
这一篇不讲具体接口细节,只回答三个问题:
- 这套系统在 EdgeOne 上到底怎么跑起来?
- 为什么一定要多一层 Edge Functions 代理 KV?
- 哪些边界一旦没划清,后期会极难维护?
先讲清楚目标(以及我刻意不做什么)
我想要的是:
- 0 成本可运行(尽量不引入长期服务器)。
- 一行 webhook 调用就能推送(脚本友好)。
- 有 UI 管理(不想每次改配置都上 KV 控制台)。
- 数据自托管(配置、绑定关系、历史都归自己)。
我刻意不做的是:
- 不把它做成“强安全边界”的公网 API(例如 OAuth、复杂权限模型)。
- 不追求“消息必达”的可靠投递(至少在当前版本,失败记录下来就够了)。
约束:边缘环境里,很多“理所当然”都不成立
在传统服务里,我们很习惯:
- 后端直接连 Redis / DB
- 进程内缓存 token
- 用固定域名回调
但在 EdgeOne 的组合里,这些假设会频繁失效。你必须接受:
- 运行形态分裂:Pages / Node Functions / Edge Functions
- 请求可能来自不同域名(自定义域名、默认域名、甚至同源推断失败)
- 外部 API(微信)会带来 token 失效、错误码、以及“成功但没送达”的灰度状态
这也是为什么,这个项目的“架构”不是为了炫技,而是为了把约束显式化。
一张图:三层运行形态 + 一条 KV 生命线
flowchart TB
subgraph UI[前端(Pages)]
WEB[管理后台 UI]
end
subgraph NF[Node Functions]
API["管理 API
/channels /apps /openids /messages"] SEND["推送入口
/send/appKey"] SVC["Services
push/app/channel/openid/message"] end subgraph EF[Edge Functions] KVAPI["/api/kv/*
KV 代理 API"] end KV[("EdgeOne KV
CONFIG/CHANNELS/APPS/OPENIDS/MESSAGES")] WX["微信/企业微信 API"] WEB --> API API --> SVC SEND --> SVC SVC -->|HTTP| KVAPI KVAPI <--> KV SVC -->|HTTPS| WX
/channels /apps /openids /messages"] SEND["推送入口
/send/appKey"] SVC["Services
push/app/channel/openid/message"] end subgraph EF[Edge Functions] KVAPI["/api/kv/*
KV 代理 API"] end KV[("EdgeOne KV
CONFIG/CHANNELS/APPS/OPENIDS/MESSAGES")] WX["微信/企业微信 API"] WEB --> API API --> SVC SEND --> SVC SVC -->|HTTP| KVAPI KVAPI <--> KV SVC -->|HTTPS| WX
读图的方式:
- 所有业务请求最终都落在 Node Functions。
- Node Functions 不能直接碰 KV,于是所有 KV 操作都绕一圈到 Edge Functions。
- 推送调用与管理调用共用同一套 service,只是鉴权边界不同。
关键决策 1:Node Functions 不直接访问 KV(而是通过 Edge Functions 代理)
这是这个项目最重要的一个决策。
在代码里,它落在:
node-functions/shared/kv-client.ts
这里的思路不是“写一个 SDK”,而是把 KV 当成一个 HTTP 服务:
- Node Functions 只负责拼 URL、带上内部鉴权 header
- Edge Functions 负责真正的 KV
get/put/delete/list
这样做的收益:
- 运行环境差异被隔离:业务层不需要知道 KV 的访问细节。
- 排障路径清晰:KV 问题就盯住
/api/kv/*这一跳。 - 安全边界更清楚:KV 代理 API 只对“内部调用”开放。
代价也很明确:
- 多一次网络 hop(但在边缘上通常可接受)
- 必须处理 baseUrl/域名推断问题
关键决策 2:KV_BASE_URL —— 用显式配置替代隐式推断
边缘环境最怕“我以为你知道我是谁”。
当请求直接打到 /send/* 时,Node Functions 在某些场景下并不能稳定推断出正确的“对外域名”,于是它拼出来的 /api/kv/* 地址可能指向错误的 host。
所以这里不赌运气,直接引入:
KV_BASE_URL=https://your-domain.com
这是一种很工程化的做法:
- 让配置暴露在部署层
- 让不确定性停止蔓延到业务逻辑
一条典型请求链路(推送)
推送看起来是 curl 一下,但内部有不少步骤:
sequenceDiagram
autonumber
participant C as Caller
participant N as Node Functions (/send)
participant K as Edge Functions (/api/kv)
participant V as KV
participant W as WeChat API
C->>N: GET/POST /send/appKey
N->>K: get(appKey->appId)
K->>V: KV get
V-->>K: appId
K-->>N: appId
N->>K: get(app, channel, openids)
K->>V: KV get/list
V-->>K: records
K-->>N: records
N->>W: send message (token cached)
W-->>N: result
N->>K: put(message history)
K->>V: KV put
V-->>K: ok
K-->>N: ok
N-->>C: {code:0,data:{success/failed}}
鉴权边界:两个世界,两种规则
这个项目有两个“外部入口”,它们的安全策略完全不同:
- 管理端 API:需要管理员令牌(控制面,必须收紧)。
- 推送入口 send:通过
appKey做弱认证(数据面,追求接入简单)。
把这两个边界混在一起,会导致:
- 你要么把 send 做得很难用(强鉴权、脚本不好接入)
- 要么把管理 API 暴露得很危险(弱鉴权、容易被扫)
所以架构上就应该明确:
- 业务编排可以复用
- 鉴权策略不要复用
踩坑与排障(这类问题通常不是代码 bug)
- KV 访问失败:优先查
KV_BASE_URL是否为完整 https 域名,其次查/api/kv/*是否可访问。 - 内部鉴权失败:
X-Internal-Key不一致时,表现通常像“所有 KV 操作都报错”。 - 微信 token 相关错误:不要只看“发送失败”,要看 token 状态缓存(它决定你是凭证错还是网络错)。
启发:把约束显式化,比“写得优雅”更重要
- 显式边界能减少认知负担:Node 不碰 KV,把复杂度集中到一处。
- 显式配置能减少线上玄学:
KV_BASE_URL这种看似“多此一举”的参数,能节省大量排障时间。 - 把外部不确定性翻译成内部可观测状态:token status、message history 都是典型手段。
