资讯详情

资讯详情

Go+React+WebSocket自托管实时消息推送服务

简介面向需要自托管消息推送与实时通知的开发者这份服务器项目提供基于 REST-API 发送、WebSocket 实时接收的轻量方案并配套简洁 Web 管理界面可用于管理用户、客户端与应用适合作为私有部署通知服务的参考实现。压缩包共 226 个文件约 1.11MB以 Go 源码113 个构成服务端核心TypeScript 与 TSX 文件共 56 个支撑前端界面另含 PNG 界面素材、Markdown 文档、JSON 配置、YAML 流程及多架构 Dockerfile便于理解前后端结构与容器化部署。已有 222 人学习下载。读者可从中获得较完整的实时消息服务工程样本包括消息发送与 WebSocket 接收链路、用户与客户端管理逻辑、Web UI 组织方式以及面向 arm64、armv7 的部署配置适合学习 Go 后端与 React 前端的协作也可作为二次开发自托管通知工具的基础。1. 服务器能干什么一条消息从 API 打进浏览器只要几十毫秒某开发者手里有台常年开着的小机器上面跑着定时脚本、构建任务和几个监控探针出问题时他不想靠翻邮箱才发现。于是他需要一个服务器对外只暴露一个简单的 HTTP 接口收到消息后用 WebSocket 实时推给所有在线连接前端再配一个时尚的 Web UI打开网页就能看到消息流。这个项目干的就是这件事。Go 写后端React 写界面消息落库、连接广播、令牌鉴权一条龙编译完是单个可执行文件扔到机器上就能自托管。它适合在意数据不外流、又不想为一条通知去接第三方云服务的人CI 挂了、磁盘快满、脚本跑完一条 curl 过去网页和手机客户端同时响。2. 架构与目录Go 后端、React 前端和 WebSocket 通道怎么拼先把整体骨架捋清楚后面不管是跑起来还是改代码心里都有张地图。这套服务的心智模型其实很小一个 HTTP 入口负责收消息和管配置一个内存里的广播中心负责把消息分发给所有活着的连接一个 SQLite 文件负责兜住历史前端只做两件事——订阅和渲染。2.1 一条消息从 API 到浏览器的完整链路拿一条「构建完成」的通知走一遍链路是这样的客户端POST /message?token应用令牌带上 title、message、priority 三个字段路由层先过认证中间件把令牌换成对应的应用实体拿到这个应用属于哪个用户handler 组装消息结构体写进数据库拿到自增 ID 和时间戳写库成功后把这条消息投递到广播中心的 channel 里注意这里是非阻塞投递广播中心把消息复制给每个在线连接各自的发送缓冲每个连接有独立的写协程从自己的缓冲里取消息序列化成 JSON 写进 WebSocket 帧React 端onmessage收到后追加到列表头部同时刷新未读数。这条链路上有三个地方最容易出问题我在实际部署时都撞过第 4 步如果是阻塞投递一个卡住的客户端能把整个 API 拖死第 5 步如果缓冲太小突发消息会丢第 6 步如果没有写超时半死的 TCP 连接会一直占着 goroutine。阶段关键动作常见失败点接收认证中间件解析令牌令牌过期、应用被删落库插入消息表回填 IDSQLite 写锁冲突广播非阻塞投递到 hub慢客户端导致丢弃推送每连接写协程发帧反代不转发 Upgrade 头渲染前端插入列表、去重重连后重复消费提示理解这条链路的价值在于任何「消息没收到」的问题都能按这五步二分定位而不是一上来就怀疑代码。2.2 目录结构与模块职责项目大致分两半后端是典型的 Go 分层结构前端是标准的 React 单页应用构建产物用embed打进二进制。这是我按这套服务的设计思路还原出来的目录形态实际拿到手基本大同小异. ├── app/ # Go 后端 │ ├── api/ # 路由注册与各个 handler │ │ ├── message.go # 收发消息 │ │ ├── application.go # 应用管理 │ │ ├── client.go # 客户端管理 │ │ └── user.go # 登录与用户 │ ├── auth/ # 令牌解析、JWT 签发与校验 │ ├── database/ # 存储接口与 SQL 实现 │ │ ├── sqlite/ │ │ └── mysql/ │ ├── model/ # Message / Application / Client / User │ ├── router/ # 中间件链、静态资源挂载 │ ├── websocket/ # hub、连接、读写协程 │ └── config/ # 配置加载与默认值 ├── ui/ # React 前端 │ ├── src/ │ │ ├── api/ # 与后端对齐的请求封装 │ │ ├── components/ # 消息卡片、列表、表单 │ │ └── store/ # 状态管理 │ └── build/ # 构建产物编译时嵌入 ├── docs/ ├── Makefile └── config.example.ymlwebsocket目录是整套服务里最值得读的部分它只做一件事管理一个 hubhub 里维护连接集合对外暴露注册、注销、广播三个方法。database目录用接口抽象了存储SQLite 和 MySQL 各一套实现好处是测试时可以塞内存实现进去。ui/build被打进二进制这点很关键——部署时不用再单独跑一个 nginx 托管静态文件一个文件搞定。2.3 技术选型为什么是单二进制加 WebSocketGo 的编译产物是静态链接的单文件交叉编译到 ARM 小机器毫无压力对自托管场景几乎是必选项。默认数据库选 SQLite 也很务实绝大多数人就是给自己用消息量一天几百条SQLite 完全扛得住还省掉了额外跑一个数据库容器的麻烦等真有压力了改配置切到 MySQL 或 Postgres 就行存储层接口已经把差异吃掉了。WebSocket 而不是 SSE 或者轮询主要是双向考虑。虽然消息推送方向主要是服务端到客户端但客户端连上之后要能即时收到「应用被删除」「令牌失效」这类控制事件长连接天然合适。而且浏览器的 WebSocket 实现足够简单断线重连逻辑自己写五十行就够了。前端选 React 加 TypeScript配合一个轻量状态管理好处是组件复用和类型约束。这个项目的 UI 算不上复杂消息列表、应用管理、客户端管理三个页面但「时尚的 Web UI」这个定位说明它在交互上花了心思暗色主题、消息卡片按优先级着色、未读数在标题栏跳动这些细节对日常使用的体验提升比功能本身还大。3. 跑起来从零到收到第一条推送的完整步骤这一章的目标很明确让你在十分钟内看到一个能收消息的页面。我会按「先跑起来、再改配置、最后调接口」的顺序走中间穿插几个必须提前知道的参数含义。3.1 两种起法裸二进制与容器最省事的是容器一条命令搞定数据卷挂出来就不怕重启丢消息# 起容器把数据和配置都挂到宿主机 docker run -d \ --name push-server \ -p 8080:80 \ -v /opt/push/data:/app/data \ -v /opt/push/config.yml:/etc/push/config.yml \ --restart unless-stopped \ push-server:latest这里有几个参数值得说清楚。-p 8080:80是端口映射容器内部默认监听 80宿主机换到 8080 避免和已有服务打架。-v /opt/push/data:/app/data是数据卷SQLite 的库文件、上传的附件都在这这个目录丢了就等于所有历史消息没了。--restart unless-stopped让机器重启后服务自动回来自托管场景基本必加。如果不想用容器直接跑二进制也行适合资源紧张的小机器# 下载对应架构的发行包解压后直接执行 tar -xzf push-server-linux-amd64.tar.gz cd push-server chmod x ./push-server ./push-server \ -config ./config.yml \ -port 8080 \ -data ./data-config指配置文件路径-port覆盖监听端口-data指数据目录。命令行参数的优先级高于配置文件调试时用命令行临时改端口很方便不用去动配置文件。3.2 配置文件逐项拆开看配置文件决定了部署形态值得逐项过server: listenaddr: 0.0.0.0 # 监听地址容器里必须是 0.0.0.0 port: 80 # 容器内端口 ssl: enabled: false # TLS 交给反代终结这里关掉 cors: alloworigins: # 允许跨域的前端地址 - https://push.example.internal database: dialect: sqlite3 # sqlite3 / mysql / postgres connection: data/push.db # SQLite 是文件路径MySQL 是 DSN maxopenconnections: 10 # 连接池上限 defaultuser: name: admin # 首次启动自动创建的管理员 pass: change-me-now # 务必第一次登录后改掉 registration: enabled: false # 关闭公开注册只允许管理员建号配置里最容易翻车的是listenaddr。在容器里写127.0.0.1端口映射就是死的外面怎么连都连不上我第一次部署在这上面耗了半小时。ssl.enabled一般设 falseTLS 交给前面那层反向代理做证书续期也省事。registration.enabled建议关掉公网暴露的服务开着公开注册等于给自己挖坑。参数作用建议值server.listenaddr监听网卡容器内0.0.0.0server.port服务端口与容器映射对齐database.dialect存储引擎个人用sqlite3database.maxopenconnections连接池上限SQLite 保持 110defaultuser.pass初始管理员密码部署后立刻改registration.enabled是否开放注册false3.3 用 curl 打通第一条消息服务起来之后先登录拿用户令牌这一步会返回一个 JWT后续管理操作都靠它# 用默认管理员登录拿用户令牌 USER_TOKEN$(curl -s -X POST http://127.0.0.1:8080/user/login \ -H Content-Type: application/json \ -d {name:admin,pass:change-me-now} | jq -r .token) echo 用户令牌: ${USER_TOKEN:0:20}...jq -r .token是从 JSON 响应里抠字段没装 jq 的话手动复制也行。这个令牌有有效期过期后重新登录即可。接着建一个「应用」。应用是消息的归口一个应用对应一个令牌你可以给 CI 建一个、给监控建一个出问题好排查是谁发的# 创建一个应用返回的 token 就是发消息用的凭证 APP_TOKEN$(curl -s -X POST http://127.0.0.1:8080/application \ -H X-User-Token: ${USER_TOKEN} \ -H Content-Type: application/json \ -d {name:ci-bot,description:构建与部署通知} | jq -r .token) echo 应用令牌: ${APP_TOKEN}拿到应用令牌就能发消息了用表单方式最直观中文不用自己处理编码# 发一条消息priority 决定前端展示的颜色和是否触发系统通知 curl -s -X POST http://127.0.0.1:8080/message \ -H X-App-Token: ${APP_TOKEN} \ -F title构建完成 \ -F message分支 main 耗时 3m12s产物已上传 \ -F priority5title是消息标题前端列表里加粗显示message是正文支持 Markdownpriority取值 0 到 10低于 3 的一般静默处理7 以上前端会标红并触发浏览器通知。这个分级设计用久了会觉得很顺手——不是所有通知都值得打断你。3.4 浏览器端的接入与 WebSocket 握手打开 Web UI登录后前端立刻发起一条长连接形如wss://你的域名/stream?token客户端令牌。注意这里用的是客户端令牌而不是应用令牌这是两条完全不同的认证路径应用令牌只能发消息客户端令牌只能收消息职责分开。握手成功后服务端会先补发最近的历史消息默认按时间倒序取一页然后进入实时推送状态。前端在onopen里把状态标记为已连接在onclose里启动重连计时器这部分逻辑后面第 4 章会展开。4. 核心代码拆解数据模型、令牌鉴权与广播中心这一章挑三个最要害的部分存储模型决定了你能查什么令牌鉴权决定了谁能干什么广播中心决定了消息能不能稳定送到。三段代码看完这套服务的黑匣子基本就打开了。4.1 数据模型与存储层消息表的设计很朴素但几个字段的取舍有讲究-- 消息主表id 自增保证顺序application_id 用于按应用过滤 CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, application_id INTEGER NOT NULL, message TEXT NOT NULL, title TEXT, priority INTEGER NOT NULL DEFAULT 0, extras TEXT, -- JSON 字符串放 client::display 之类的元信息 date DATETIME NOT NULL, -- 服务端生成不信任客户端时间 FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE ); -- 时间倒序查询是最高频的操作这个索引必须有 CREATE INDEX idx_messages_application_date ON messages (application_id, date DESC);date由服务端生成这点很重要客户端传的时间不可信多设备时钟漂移会让你排序全乱。extras用 JSON 字符串存是为了将来加字段不用改表结构比如前端渲染需要的图标地址、点击跳转的 URL 都塞这里。ON DELETE CASCADE保证删应用时消息一起清掉不然数据库会越滚越大。对应的 Go 结构体大致长这样// model/message.go type Message struct { ID uint json:id ApplicationID uint json:applicationid Message string json:message Title string json:title Priority int json:priority Extras Extras json:extras // 自定义类型实现 Scanner/Valuer Date time.Time json:date } // Extras 用 map 存任意元信息读写时序列化成 JSON 字符串 type Extras map[string]interface{} func (e Extras) Value() (driver.Value, error) { b, err : json.Marshal(e) return string(b), err }结构体字段名和 JSON 标签的对应关系要留意前端拿到的字段名是标签里那个改标签等于改接口契约别随手改。Extras实现了driver.Valuer和sql.Scanner两个接口读写数据库时自动在 map 和字符串之间转换业务代码里就不用关心序列化细节了。4.2 两级令牌应用令牌与客户端令牌这套服务的权限模型是两层用户令牌管配置应用令牌管发送客户端令牌管接收。三个令牌的校验逻辑走同一个中间件靠请求路径和请求头区分// auth/middleware.go func TokenAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 优先从请求头取兼容部分客户端只能放 query 的情况 token : r.Header.Get(X-App-Token) if token { token r.URL.Query().Get(token) } if token { http.Error(w, missing token, http.StatusUnauthorized) return } // 令牌以 A 开头表示应用令牌C 开头表示客户端令牌 scope : app if strings.HasPrefix(token, C) { scope client } // 查库校验并解析出对应实体失败直接短路 entity, err : store.ResolveToken(r.Context(), scope, token) if err ! nil { http.Error(w, invalid token, http.StatusUnauthorized) return } // 把实体塞进 context下游 handler 直接取避免重复查库 ctx : context.WithValue(r.Context(), tokenKey{}, entity) next.ServeHTTP(w, r.WithContext(ctx)) }) }这段代码的关键在最后三行认证结果放进context下游 handler 就不用再查一次库一次请求只查一次令牌量大了差别很明显。scope的区分方式看着简陋但很实用——令牌有固定前缀看一眼就知道该查哪张表省掉了「先查应用表再查客户端表」的两次 IO。注意令牌一定要用随机数生成而不是时间戳或自增 ID 拼接否则等于把权限送人。常见做法是 32 字节 crypto/rand 转成 URL 安全编码。4.3 Hub连接注册、广播与慢客户端剔除广播中心是并发正确性最容易出问题的地方核心就三件事注册、注销、广播。我见过不少实现死在「阻塞发送」上// websocket/hub.go type Hub struct { mu sync.RWMutex clients map[*Client]struct{} } type Client struct { send chan []byte // 每个连接独立的发送缓冲 conn *websocket.Conn } // 广播复制一份订阅者列表逐个非阻塞投递 func (h *Hub) Broadcast(msg []byte) { h.mu.RLock() targets : make([]*Client, 0, len(h.clients)) for c : range h.clients { targets append(targets, c) } h.mu.RUnlock() for _, c : range targets { select { case c.send - msg: default: // 缓冲区满说明这个客户端收不动了直接踢掉 h.Remove(c) c.conn.Close() } } } // 每个连接一个写协程从自己的缓冲里取消息发帧 func (c *Client) writePump() { ticker : time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case msg, ok : -c.send: if !ok { c.conn.WriteMessage(websocket.CloseMessage, nil) return } c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err : c.conn.WriteMessage(websocket.TextMessage, msg); err ! nil { return } case -ticker.C: // 定期发 ping探活半死连接 c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err : c.conn.WriteMessage(websocket.PingMessage, nil); err ! nil { return } } } }Broadcast里先加读锁复制一份列表再释放是为了避免在遍历过程中调用Remove触发写锁导致的死锁这是并发编程里很经典的血泪经验。select加default保证投递永不阻塞缓冲区满就直接断开连接——宁可丢一个收不动的客户端也不能让整个广播卡住。writePump里的SetWriteDeadline是另一个容易漏的点。没有写超时一个网络已经断但 TCP 还没感知的连接会永远阻塞在WriteMessage上goroutine 越积越多最后 OOM。30 秒一次的 ping 就是探活用的写失败就退出由 defer 清理资源。4.4 React 端的订阅与重连前端这块最容易写错的是重连很多人图省事用setInterval每三秒重连一次网络抖动时会疯狂刷请求// ui/src/hooks/useMessageStream.js export function useMessageStream(token, onMessage) { const retryRef useRef(0); useEffect(() { let ws; let timer; let closed false; const connect () { const proto location.protocol https: ? wss : ws; ws new WebSocket(${proto}://${location.host}/stream?token${token}); ws.onopen () { retryRef.current 0; // 连上就重置退避计数 }; ws.onmessage (evt) { const msg JSON.parse(evt.data); onMessage(msg); // 交给上层追加到列表 }; ws.onclose () { if (closed) return; // 指数退避1s, 2s, 4s... 最多 30s避免抖动时打爆服务端 const delay Math.min(1000 * 2 ** retryRef.current, 30000); retryRef.current 1; timer setTimeout(connect, delay); }; }; connect(); return () { closed true; clearTimeout(timer); ws ws.close(); }; }, [token, onMessage]); }退避计数在onopen里清零是关键连接成功后重新计算下次断线还是从 1 秒开始不会因为历史失败次数一直等 30 秒。closed标志防止组件卸载后重连定时器还在跑这个坑在路由切换频繁的页面里特别常见表现出来就是后台不断有请求但界面上什么都没有。上层拿到消息后要做去重因为重连补发历史消息时可能和内存里已有的重叠用消息 ID 做 key 塞进 Set 判断一下就行成本极低。5. 避坑与常见问题那些让我半夜爬起来看日志的问题部署这类服务出问题的往往不是业务逻辑而是网络层和配置层。下面五条都是我实际踩过或者帮别人排查过的按「现象 → 原因 → 解决」写清楚遇到了直接对号入座。5.1 反向代理下 WebSocket 一秒断一次现象页面打开连接状态反复在「已连接」和「重连中」之间跳浏览器控制台每隔几秒就报一次连接关闭服务端日志看不到任何报错。原因反向代理默认按普通 HTTP 请求处理没有转发Upgrade和Connection这两个头WebSocket 握手被降级成普通请求服务端返回 101 失败后连接立刻关闭。有些代理还会在 60 秒空闲后主动断掉长连接表现为不定时掉线。解决代理配置里显式开启协议升级并把读超时调大。Nginx 大致这样写location /stream { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; # 关键透传协议升级头 proxy_set_header Connection upgrade; # 关键连接类型改为 upgrade proxy_set_header Host $host; proxy_read_timeout 3600s; # 长连接别被空闲超时切断 proxy_send_timeout 3600s; }改完记得nginx -t nginx -s reload。如果前置还有一层云厂商的负载均衡那边也有连接空闲超时通常默认 60 秒需要一起调大否则改了 Nginx 照样断。5.2 消息偶发丢失客户端毫无察觉现象批量发十条消息前端只收到七八条没有报错也没有断线记录。原因广播时用的是非阻塞投递某个客户端的发送缓冲满了select走到default分支这条消息对这个连接就直接丢了。缓冲区满通常不是网络慢而是前端主线程卡住导致onmessage处理不过来或者浏览器标签页被切到后台被限流。解决先分清是哪种。如果是单条大消息比如带长 Markdown超过了 WebSocket 帧大小限制把服务端的读写缓冲调大如果是高频消息打满缓冲把单连接缓冲从 16 提到 64同时在前端做批量渲染而不是每条都触发一次 React 重渲染。真正要保证不丢的场景前端重连后应该主动拉一次最近的历史消息做补偿而不是只依赖推送。5.3 应用令牌被拿来查管理员接口现象某次排查发现用一个只该发消息的应用令牌居然能调通「列出所有用户」的接口。原因认证中间件只校验了令牌有效性没有校验令牌的 scope 和当前路由是否匹配。发消息的接口和用户管理接口走了同一套中间件等于把权限边界抹平了。解决把 scope 校验前置到路由注册那一层管理类路由统一挂要求用户令牌的中间件消息类路由只接受应用令牌。别指望在业务 handler 里逐个判断漏一个就是一个洞。改完之后写一条集成测试固定住这个行为防回归。5.4 SQLite 的写锁把请求卡死现象日常很快一到定时任务集中发消息的时段比如整点接口响应从几十毫秒涨到好几秒日志里偶尔冒出database is locked。原因SQLite 同一时刻只允许一个写事务连接池开了多个写连接时会互相等锁默认的 busy timeout 又很短等不到就报错。整点批量发消息正好撞上这个场景。解决把连接池的写连接限制到 1读操作走单独的只读连接再给 busy timeout 设成 5 秒。写法上把连接串改成file:data/push.db?_journal_modeWAL_busy_timeout5000WAL 模式下读写不互斥日常卡顿会明显好转。数据量真的上来了直接切 MySQL长痛不如短痛。5.5 容器重建后历史消息全没了现象docker compose down再up所有消息清空账号也得重新建。原因数据目录没有挂出来或者挂的是data/相对路径但容器的WORKDIR不在预期位置SQLite 文件写进了容器内部的可写层容器一删就没了。解决容器启动一律用绝对路径挂卷并且启动后进容器确认一下库文件确实在挂载点里# 确认库文件在挂载点下而不是容器内部 docker exec push-server ls -lh /app/data/ # 顺便看一下配置里的路径是不是和挂载点一致 docker exec push-server cat /etc/push/config.yml | grep connection这两条命令花不了十秒钟能省掉一次数据丢失的后悔药。挂载点权限也要留意容器里跑的不是 root 时宿主目录的属主得对得上否则表现为能启动但一写库就报 permission denied。6. 二次开发给广播中心挂一个外部转发通道最实用的改造是加一条外部通道本地收到的消息除了推给浏览器再转发一份给某个外部 Webhook这样能和企业内部的群机器人打通。改动量很小因为 hub 的广播点只有一个。思路是给 hub 加一个订阅者接口而不是在 handler 里硬编码转发逻辑这样将来加第二条通道不用再改 handler// websocket/hub.go 追加订阅者机制 type Subscriber interface { Deliver(msg model.Message) } func (h *Hub) Subscribe(s Subscriber) { h.mu.Lock() defer h.mu.Unlock() h.subs append(h.subs, s) } // 在收消息的 handler 里落库成功后先广播给连接再通知订阅者 func onMessageCreated(msg model.Message) { payload, _ : json.Marshal(msg) hub.Broadcast(payload) // 订阅者用独立 goroutine 投递慢也不能拖累主链路 for _, s : range hub.subs { go s.Deliver(msg) } }这里的取舍是订阅者投递放在 goroutine 里异步做失败了就记日志不影响消息入库和实时推送。如果你要求「转发失败必须重试」那就要引入队列改造量会上去一个量级取决于业务对可靠性的要求。具体的转发实现大约三十行核心是构造请求和限流// forward/webhook.go type Webhook struct { url string client *http.Client limiter *rate.Limiter // 防止下游抖动时把请求打爆 } func (w *Webhook) Deliver(msg model.Message) { if !w.limiter.Allow() { log.Printf(webhook 限流丢弃, msgID%d, msg.ID) return } body, _ : json.Marshal(map[string]string{ title: msg.Title, text: msg.Message, }) req, _ : http.NewRequest(POST, w.url, bytes.NewReader(body)) req.Header.Set(Content-Type, application/json) ctx, cancel : context.WithTimeout(context.Background(), 5*time.Second) defer cancel() resp, err : w.client.Do(req.WithContext(ctx)) if err ! nil { log.Printf(webhook 投递失败, msgID%d, err%v, msg.ID, err) return } defer resp.Body.Close() if resp.StatusCode 300 { log.Printf(webhook 非预期状态, msgID%d, code%d, msg.ID, resp.StatusCode) } }验证方法要分层做别只测单一场景验证项做法期望结果正常投递发一条 priority5 的消息外部通道秒级收到下游超时把 webhook 地址指向一个不响应的端口5 秒后记错误日志主链路不受影响下游 5xx故意返回 500日志里有 code500消息仍入库高频冲击循环发 200 条触发限流日志有丢弃记录无 goroutine 堆积重启恢复重启服务后再发订阅者正常注册历史消息不重复转发最后一项容易被忽略如果转发逻辑写在启动流程里但注册晚于消息接收重启后的头几条消息会漏。我现在的习惯是任何新增的订阅者都必须在服务就绪的同一阶段注册完毕并且在启动日志里打一行「subscriber registered: webhook」这样出了问题一眼能看出是没注册还是没投递。从那以后我每次给这套服务加副通道都强制走一遍上表的五项验证尤其是超时和重启这两条它们不写在文档里但一定会发生。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →