Compare commits

...
4 Commits
Author SHA1 Message Date
nomdn e8ec3757fd fix: 更新文档以支持多个中间件地址同时连接的说明 2026-08-24 22:57:30 +08:00
nomdn d398c4085c Fix link formatting in README.md 2026-08-24 22:37:44 +08:00
nomdn d04a90b20c Update README with community IPW version links
Add community links for IPW versions in README.
2026-08-24 22:37:15 +08:00
nomdn 92a8c1f308 feat:CURL查询IP接口 2026-08-23 22:01:37 +08:00
10 changed files with 351 additions and 23 deletions

No files matched your search

+5
View File
@@ -32,6 +32,11 @@ ipw-cn/
├── docs/ # 文档站
└── install.sh # 一键安装脚本
```
## 友情链接
### 来自社区的IPW版本
[ZAKOFLARE/ipw-cn-php](https://github.com/ZAKOFLARE/ipw-cn-php) 一个基于PHP的IPW节点
[ZAKOFLARE/ipw-cn-rust](https://github.com/ZAKOFLARE/ipw-cn-rust) 一个生锈的IPW节点
[KFCV50TK/ipw-cn](https://github.com/KFCV50TK/ipw-cn) 一个有许多扩展功能的IPW分支
## 技术栈
+2 -2
View File
@@ -24,7 +24,7 @@
- `ipdb`:IP 数据库开关(首次启动自动下载约 200MB,之后每 24h 更新)
- `cors`:允许的请求来源(逗号分隔)
- `access-token`:API 访问令牌,留空则不启用鉴权
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多备**(第一个为主,主故障自动切换下一个);留空 = 不启用 WS 客户端,走原 HTTP 接口
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多个中间件,同时连接全部(多活)**,任一断开只重连自己,不影响其他连接;留空 = 不启用 WS 客户端,走原 HTTP 接口
- `node-id`:WS 节点 id(与中间件 `apiKeys`/`wsKeys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
- `node-key`:WS 注册 key(与中间件 `wsKeys[节点id]` 一致;**必填**——节点未配置该 key 时中间件拒绝注册并返回 401)
@@ -181,7 +181,7 @@ WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两
|------|--------|----------|------|
| 中间件(服务端) | `ws-port` | `WS_PORT` | WS 监听端口,默认 `8092`,`0`=关闭;节点连 `ws(s)://<中间件>:<ws-port>/ws` |
| 中间件(服务端) | `wsKeys` | `WSKEYS` | WS 注册校验表:`{"<node-id>": "<key>"}`,节点 `register` 的 key 必须匹配 |
| 节点(客户端) | `ws-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多备主备切换 |
| 节点(客户端) | `ws-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多个则**同时连接全部(多活)** |
| 节点(客户端) | `node-id` | `NODE_ID` | 节点 id,与中间件 `apiKeys`/`wsKeys` 键一致(建议 UUID) |
| 节点(客户端) | `node-key` | `NODE_KEY` | 注册 key,与中间件 `wsKeys[节点id]` 一致,**必填** |
+1 -1
View File
@@ -88,7 +88,7 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
### 节点侧接入(WS 客户端)
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册)
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册;`WS_URL` 支持逗号分隔多个中间件地址,节点会**同时连接全部(多活)**,任一断开只重连自己)
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `wsKeys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
+2 -2
View File
@@ -84,7 +84,7 @@ sudo bash install.sh
**WS 接入特殊规则**:
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址时须为**完整路径**(含 `wss://` 前缀与 `/ws` 路径,如 `wss://host:8092/ws`),逗号分隔多备
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址时须为**完整路径**(含 `wss://` 前缀与 `/ws` 路径,如 `wss://host:8092/ws`),逗号分隔多个则**同时连接全部(多活)**
- `NODE_ID` **强制自动生成 UUID**(无需输入,如 `4580ea9d-2a16-4b8e-8090-5f2e7f0a2229`),适合作为节点唯一标识
- `NODE_KEY` **必填**:不加 key 禁止启用 WS(中间件 `wsKeys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `wsKeys`
@@ -118,7 +118,7 @@ NODE_KEY=<注册key,与中间件 wsKeys[节点id] 一致;节点未配置 key
./lemonipw
```
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://主:8092/ws,ws://备:8092/ws`),第一个为主,主故障自动切换下一个
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://中间件1:8092/ws,ws://中间件2:8092/ws`),**同时连接全部(多活)**:每个地址独立注册并保持连接,任一断开只重连自己,不影响其他中间件;注意多个地址指向同一中间件实例时,同 `nodeId` 后注册的连接会顶掉先前的
- **双向心跳**:节点每 10s 发 ping,中间件回 pong;心跳发送连续失败 3 次判定中间件不可用,断开后 3s 重试(注册被拒 30s 重试)
- 收到 `probe` 后节点直调探针函数(带缓存)并回 `probe_result`,结果与 HTTP 通道一致
- 节点注册带 `NODE_KEY`:中间件 `wsKeys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
+1 -1
View File
@@ -48,7 +48,7 @@ sudo bash install.sh
**WS 通道接入规则**(选 `y` 时):
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址须为完整路径(`wss://host:8092/ws` 形式)
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址须为完整路径(`wss://host:8092/ws` 形式);逗号分隔多个则**同时连接全部(多活)**
- `NODE_ID` **强制自动生成 UUID**(无需输入)
- `NODE_KEY` **必填**:不加 key 禁止启用 WS;安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 `wsKeys`
+152
View File
@@ -0,0 +1,152 @@
import { defineEventHandler, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
import { config } from '../../../config/index'
import { useMiddlewareFetch } from '../../../utils/middleware'
// GET /ip/<ip> → text/plain 输出指定 IP 的归属地(支持 IPv4 / IPv6,如 /ip/2001:db8::1)
//
// IP: 2001:db8::1
// bilibili: 中国 江苏 南京 电信
// geocn: 江苏省 南京市 江宁区 电信 宽带
// ip2region: 中国 江苏 南京 电信
// maxmind_city: 中国 江苏 南京
// maxmind_asn: AS4134 中国电信
// qqwry: 中国 江苏省 南京市 电信
//
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string, limit: number): boolean {
const now = Date.now()
const entry = rateLimitMap.get(ip)
if (!entry || entry.resetAt <= now) {
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
// 顺带清理过期条目,避免 Map 无限增长
if (rateLimitMap.size > 1000) {
for (const [k, v] of rateLimitMap) {
if (v.resetAt <= now) rateLimitMap.delete(k)
}
}
return true
}
entry.count++
return entry.count <= limit
}
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
function objectToText(obj: any): string {
const parts: string[] = []
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
const v = obj[k]
if (typeof v === 'string' && v) parts.push(v)
}
// ASN 编号(如 "AS4134")
if (typeof obj.asn === 'string' && obj.asn) {
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
}
// 运营商 / 组织 / 类型
for (const k of ['isp', 'org', 'type']) {
const v = obj[k]
if (typeof v === 'string' && v) parts.push(v)
}
return [...new Set(parts)].join(' ')
}
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
function formatLocationText(data: any, ip: string): string {
const lines: string[] = [`IP: ${ip}`]
const sources: Array<[string, any]> = [
['bilibili', data?.bilibili],
['geocn', data?.geocn],
['ip2region', data?.ip2region],
['maxmind_city', data?.maxmind_city],
['maxmind_asn', data?.maxmind_asn],
['dbip_city', data?.dbip_city],
['dbip_asn', data?.dbip_asn],
['qqwry', data?.qqwry],
]
for (const [name, value] of sources) {
if (value === undefined || value === null) continue
if (typeof value === 'string') {
// ip2region 为 "国家|省份|城市|运营商" 管道分隔格式
if (value.includes('|')) {
const text = value.split('|').filter(Boolean).join(' ')
if (text) lines.push(`${name}: ${text}`)
} else if (value && value !== 'not loaded') {
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
lines.push(`${name}: ${value}`)
}
continue
}
if (typeof value === 'object') {
const text = objectToText(value)
if (text) lines.push(`${name}: ${text}`)
}
}
return lines.join('\n')
}
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
async function locate(event: any, ip: string) {
const backends = config.IPLocationAPI
if (!backends || backends.length === 0) {
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
}
let lastError: any = null
for (const backend of backends) {
const path = `/middleware/${backend.id}/location/${ip}`
const { data, error, execute } = useMiddlewareFetch(path)
await execute()
if (error.value) {
lastError = error.value
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
continue
}
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
const res = data.value
if (res && typeof res === 'object' && (res as any).ip) {
return formatLocationText(res, ip)
}
lastError = new Error('invalid location response')
}
// 全部后端失败:透传最后一次错误(text/plain)
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
const status = typeof errStatus === 'number' ? errStatus : 502
setResponseStatus(event, status)
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
}
export default defineEventHandler(async (event) => {
// 0) 统一输出 text/plain(不是 JSON)
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
const siteOrigin = new URL(config.siteUrl).origin
const origin = getRequestHeader(event, 'origin')
const referer = getRequestHeader(event, 'referer')
if (origin && origin !== siteOrigin) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
}
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
}
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
const rateLimit = config.rateLimitPerMinute || 0
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
}
// 3) 取路径参数 IP
const ipParam: string = event.context.params?.ip as any
let ip = ipParam ? ipParam.trim() : ''
if (!ip) {
throw createError({ statusCode: 400, statusMessage: 'Missing IP parameter' })
}
return locate(event, ip)
})
+156
View File
@@ -0,0 +1,156 @@
import { defineEventHandler, getQuery, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
import { config } from '../../../config/index'
import { useMiddlewareFetch } from '../../../utils/middleware'
// GET /ip → text/plain 输出 IP 归属地(?ip=<ip> 优先,否则查请求方 IP)
//
// IP: 1.2.3.4
// bilibili: 中国 江苏 南京 电信
// geocn: 江苏省 南京市 江宁区 电信 宽带
// ip2region: 中国 江苏 南京 电信
// maxmind_city: 中国 江苏 南京
// maxmind_asn: AS4134 中国电信
// qqwry: 中国 江苏省 南京市 电信
//
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string, limit: number): boolean {
const now = Date.now()
const entry = rateLimitMap.get(ip)
if (!entry || entry.resetAt <= now) {
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
// 顺带清理过期条目,避免 Map 无限增长
if (rateLimitMap.size > 1000) {
for (const [k, v] of rateLimitMap) {
if (v.resetAt <= now) rateLimitMap.delete(k)
}
}
return true
}
entry.count++
return entry.count <= limit
}
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
function objectToText(obj: any): string {
const parts: string[] = []
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
const v = obj[k]
if (typeof v === 'string' && v) parts.push(v)
}
// ASN 编号(如 "AS4134")
if (typeof obj.asn === 'string' && obj.asn) {
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
}
// 运营商 / 组织 / 类型
for (const k of ['isp', 'org', 'type']) {
const v = obj[k]
if (typeof v === 'string' && v) parts.push(v)
}
return [...new Set(parts)].join(' ')
}
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
function formatLocationText(data: any, ip: string): string {
const lines: string[] = [`IP: ${ip}`]
const sources: Array<[string, any]> = [
['bilibili', data?.bilibili],
['geocn', data?.geocn],
['ip2region', data?.ip2region],
['maxmind_city', data?.maxmind_city],
['maxmind_asn', data?.maxmind_asn],
['dbip_city', data?.dbip_city],
['dbip_asn', data?.dbip_asn],
['qqwry', data?.qqwry],
]
for (const [name, value] of sources) {
if (value === undefined || value === null) continue
if (typeof value === 'string') {
// ip2region 为 "国家|省份|城市|运营商" 管道分隔格式
if (value.includes('|')) {
const text = value.split('|').filter(Boolean).join(' ')
if (text) lines.push(`${name}: ${text}`)
} else if (value && value !== 'not loaded') {
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
lines.push(`${name}: ${value}`)
}
continue
}
if (typeof value === 'object') {
const text = objectToText(value)
if (text) lines.push(`${name}: ${text}`)
}
}
return lines.join('\n')
}
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
async function locate(event: any, ip: string) {
const backends = config.IPLocationAPI
if (!backends || backends.length === 0) {
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
}
let lastError: any = null
for (const backend of backends) {
const path = `/middleware/${backend.id}/location/${ip}`
const { data, error, execute } = useMiddlewareFetch(path)
await execute()
if (error.value) {
lastError = error.value
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
continue
}
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
const res = data.value
if (res && typeof res === 'object' && (res as any).ip) {
return formatLocationText(res, ip)
}
lastError = new Error('invalid location response')
}
// 全部后端失败:透传最后一次错误(text/plain)
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
const status = typeof errStatus === 'number' ? errStatus : 502
setResponseStatus(event, status)
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
}
export default defineEventHandler(async (event) => {
// 0) 统一输出 text/plain(不是 JSON)
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
const siteOrigin = new URL(config.siteUrl).origin
const origin = getRequestHeader(event, 'origin')
const referer = getRequestHeader(event, 'referer')
if (origin && origin !== siteOrigin) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
}
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
}
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
const rateLimit = config.rateLimitPerMinute || 0
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
}
// 3) 确定要查询的 IP:?ip=<ip> 优先,否则查请求方 IP
const query = getQuery(event)
let ip = typeof query.ip === 'string' ? query.ip.trim() : ''
if (!ip) {
ip = getRequestIP(event, { xForwardedFor: true }) || ''
}
ip = ip.trim()
if (!ip) {
throw createError({ statusCode: 400, statusMessage: 'Unable to determine IP address' })
}
return locate(event, ip)
})
+4 -1
View File
@@ -64,9 +64,12 @@ export function useMiddlewareFetch<T = any>(url: MaybeRefOrGetter<string>, optio
try {
for (let i = 0; i < candidates.value.length; i++) {
if (id !== requestId) return undefined // 已被更新的请求取代
const candidate = candidates.value[i]
// noUncheckedIndexedAccess 下索引访问为 string | undefined,防御性跳过
if (candidate === undefined) continue
try {
// $fetch 为 Nuxt 全局注入;候选失败会抛 FetchError(含 status)
const res = await $fetch<T>(candidates.value[i], fetchOptions)
const res = await $fetch<T>(candidate, fetchOptions)
if (id !== requestId) return undefined
data.value = res
return res
+1 -1
View File
@@ -102,7 +102,7 @@ NODE_KEY=""
WS_USED_DEFAULT=""
case "${WS_CHOICE,,}" in
y|yes)
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=默认 wss://middleware-1.api-ipw.wsmdn.top/ws;逗号分隔可多备): " WS_URL
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=贡献节点给柠檬 wss://middleware-1.api-ipw.wsmdn.top/ws;逗号分隔多个将同时连接全部): " WS_URL
if [ -z "$WS_URL" ]; then
WS_URL="wss://middleware-1.api-ipw.wsmdn.top/ws"
WS_USED_DEFAULT="true"
+27 -15
View File
@@ -18,7 +18,7 @@ import (
//
// 配置(readConfig 解析,环境变量优先,其次 setting.json,远端配置可覆盖):
//
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,主失败自动切换下一个;不配置 = 不启用)
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,同时连接全部,多活;不配置 = 不启用)
// NODE_ID 节点 id(与中间件 wsKeys 键 / 前端配置 id 一致)
// NODE_KEY 注册 key(与中间件 wsKeys[节点id] 一致;中间件未配置该节点 key 时留空)
//
@@ -26,7 +26,7 @@ import (
// 返回处理在本文件内完成,结果经 probe_result 上报。节点 HTTP 接口完全不变。
//
// 心跳:NODE 与 MIDDLEWARE 双向心跳。本端每 10s 发一次 ping,发送失败重试,连续失败 3 次
// 判定当前中间件不可用,断开并切换下一个 WS_URL;收到中间件 ping 回 pong。
// 判定当前中间件不可用,断开该连接并按同一 URL 独立重连(不影响其他中间件连接)。
// wsProbe 学 HTTP handler:从函数拿数据 + 缓存,返回 (status, body)。
// raw 与 query 来自中间件 probe 消息(等价于路由参数)。
@@ -360,8 +360,8 @@ const (
wsHeartbeatTimeout = 5 * time.Second
)
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长:
// - 连接/读循环断开 → 3s(切换下一个中间件)
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长(由 per-URL 连接循环调用):
// - 连接/读循环断开 → 3s(重试同一 URL)
// - 注册被拒(register_error)→ 30s(多为 key 配置错误,加大间隔避免空转刷日志)
// - 心跳连续失败 wsHeartbeatRetry 次 → 主动断开,3s
// - 返回 0 = 配置缺失(不再重试)
@@ -399,7 +399,7 @@ func wsClientOnce(url string) time.Duration {
}
slog.Info("ws client registered", "nodeId", WS_NODE_ID, "url", url)
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(上层切换下一个中间件)
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(per-URL 循环重连同一中间件)
stopHeartbeat := make(chan struct{})
go func() {
fail := 0
@@ -413,7 +413,7 @@ func wsClientOnce(url string) time.Duration {
fail++
slog.Warn("ws heartbeat send failed", "url", url, "fail", fail, "error", err)
if fail >= wsHeartbeatRetry {
slog.Warn("ws heartbeat failed too many times, switching middleware", "url", url)
slog.Warn("ws heartbeat failed too many times, closing connection (will retry same url)", "url", url)
c.Close(websocket.StatusPolicyViolation, "heartbeat timeout")
return
}
@@ -445,23 +445,35 @@ func wsClientOnce(url string) time.Duration {
}
}
// wsClientLoop 主循环:依次尝试所有配置的中间件 URL(逗号分隔,第一个为主),
// 当前中间件连接失败或心跳超时 → 3s 后切换下一个;注册被拒 → 30s;全部失败循环重试。
// wsClientLoop 同时连接所有配置的中间件 URL(逗号分隔,多活):
// 每个 URL 由独立 goroutine 负责,各自注册并保持连接,任一断开只重连自己,不影响其他中间件。
// 注意:多个 URL 指向同一中间件实例时,中间件按 nodeId 单连接,后注册的连接会顶掉先前的。
func wsClientLoop() {
urls := splitWSURLs(WS_URL)
if len(urls) == 0 {
slog.Info("ws client disabled (WS_URL not set)")
return
}
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
wsClientOnceLoop(u)
}(url)
}
wg.Wait()
}
// wsClientOnceLoop 单个中间件 URL 的持续连接循环:连接 → 注册 → 心跳/读循环 → 断开重试
func wsClientOnceLoop(url string) {
for {
for _, url := range urls {
retryDelay := wsClientOnce(url)
if retryDelay == 0 {
slog.Info("ws client stopped")
return
}
time.Sleep(retryDelay)
retryDelay := wsClientOnce(url)
if retryDelay == 0 {
slog.Info("ws client stopped", "url", url)
return
}
time.Sleep(retryDelay)
}
}