feat: 测活覆盖全部代理(含死记录),拨活即复活

ListProxiesDue 去掉 alive=1 限制:死代理每小时也重测,协议级握手
成功就 MarkProxyAlive 复活(fail_count 清零),不再只能等同步时
重新出现在源列表才复活。

附带:提取页加「带协议前缀」开关,复制链接自动带 fmt=url。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-08 19:54:19 +08:00
co-authored by Devin
parent c0a24faf8e
commit 70022f15d6
5 changed files with 12 additions and 8 deletions
+2 -2
View File
@@ -187,9 +187,9 @@ make build # go build + vite build
- 同步:启动时若无数据或上次超过 20h 补一次,之后每日 00:00(本地时区)全量 diff—— - 同步:启动时若无数据或上次超过 20h 补一次,之后每日 00:00(本地时区)全量 diff——
源上消失即删、已存在刷新指标复活、新增入库;`proxy_syncs` 记录 added/removed/updated 与明细 源上消失即删、已存在刷新指标复活、新增入库;`proxy_syncs` 记录 added/removed/updated 与明细
(各最多 200 个地址); (各最多 200 个地址);
- 测活:每小时一轮,对距上次检测 >55min 的存活代理做**协议级握手**(SOCKS5 greeting+CONNECT / - 测活:每小时一轮,对距上次检测 >55min 的**全部**代理做**协议级握手**(SOCKS5 greeting+CONNECT /
SOCKS4 / HTTP GET 转发——裸 TCP 连通不算数,防透明网关误判),并发 128、4s 超时; SOCKS4 / HTTP GET 转发——裸 TCP 连通不算数,防透明网关误判),并发 128、4s 超时;
失败标 alive=0,死记录 7 天后物理清除; 失败标 alive=0,拨活即复活(alive=1、fail_count 清零),死记录 7 天后物理清除;
- 页面 `/proxies`:左栏大洲→地区分类(存活数),右侧筛选/搜索/分页列表,底部同步记录可展开变更明细; - 页面 `/proxies`:左栏大洲→地区分类(存活数),右侧筛选/搜索/分页列表,底部同步记录可展开变更明细;
- `ProxySvc`:List/Regions/Stats 登录即可,Syncs/SyncNow/CheckNow 仅 admin。 - `ProxySvc`:List/Regions/Stats 登录即可,Syncs/SyncNow/CheckNow 仅 admin。
+1 -1
View File
@@ -208,7 +208,7 @@ WHERE id = ?;
DELETE FROM proxies WHERE id = ?; DELETE FROM proxies WHERE id = ?;
-- name: ListProxiesDue :many -- name: ListProxiesDue :many
SELECT id, ip, port, protocols, fail_count FROM proxies WHERE alive = 1 AND last_checked_at < ? ORDER BY last_checked_at LIMIT 20000; SELECT id, ip, port, protocols, fail_count FROM proxies WHERE last_checked_at < ? ORDER BY last_checked_at LIMIT 20000;
-- name: MarkProxyAlive :exec -- name: MarkProxyAlive :exec
UPDATE proxies SET last_checked_at = ?, alive = 1, fail_count = 0, last_alive_at = ?, latency = ? WHERE id = ?; UPDATE proxies SET last_checked_at = ?, alive = 1, fail_count = 0, last_alive_at = ?, latency = ? WHERE id = ?;
+1 -1
View File
@@ -1887,7 +1887,7 @@ func (q *Queries) ListPendingPayOrders(ctx context.Context) ([]PayOrder, error)
} }
const listProxiesDue = `-- name: ListProxiesDue :many const listProxiesDue = `-- name: ListProxiesDue :many
SELECT id, ip, port, protocols, fail_count FROM proxies WHERE alive = 1 AND last_checked_at < ? ORDER BY last_checked_at LIMIT 20000 SELECT id, ip, port, protocols, fail_count FROM proxies WHERE last_checked_at < ? ORDER BY last_checked_at LIMIT 20000
` `
type ListProxiesDueRow struct { type ListProxiesDueRow struct {
+2 -2
View File
@@ -297,8 +297,8 @@ func runProxySync(ctx context.Context, st *store.Store) {
sync.Total, sync.Added, sync.Updated, sync.Removed, failed) sync.Total, sync.Added, sync.Updated, sync.Removed, failed)
} }
// runProxyCheck 测活一轮:取距上次检测超过阈值的存活代理,并发 TCP 拨测; // runProxyCheck 测活一轮:取距上次检测超过阈值的全部代理(含死记录,拨活即复活),
// 连不上即标记为死(alive=0),死记录超过保留期后物理清除。 // 并发协议级拨测;连不上即标记为死(alive=0),死记录超过保留期后物理清除。
func runProxyCheck(ctx context.Context, st *store.Store) { func runProxyCheck(ctx context.Context, st *store.Store) {
if !proxyChecking.CompareAndSwap(false, true) { if !proxyChecking.CompareAndSwap(false, true) {
return return
+6 -2
View File
@@ -61,8 +61,10 @@ async function create() {
} }
} }
const withScheme = ref(false)
function extractUrl(k: proxyKeyView): string { function extractUrl(k: proxyKeyView): string {
return `${location.origin}/api/v1/proxy?key=${k.key}` return `${location.origin}/api/v1/proxy?key=${k.key}${withScheme.value ? '&fmt=url' : ''}`
} }
async function copyUrl(k: proxyKeyView) { async function copyUrl(k: proxyKeyView) {
@@ -100,6 +102,7 @@ function regionLabel(k: proxyKeyView): string {
<div class="panel"> <div class="panel">
<div class="panel-title"> <div class="panel-title">
提取密钥 <span class="hint">{{ keys.length }} 个</span> 提取密钥 <span class="hint">{{ keys.length }} 个</span>
<span class="fmt-toggle">带协议前缀 <n-switch v-model:value="withScheme" size="small" /></span>
<n-button size="small" type="primary" style="margin-left:auto" @click="openCreate"> <n-button size="small" type="primary" style="margin-left:auto" @click="openCreate">
<template #icon><n-icon><AddOutline /></n-icon></template>生成密钥 <template #icon><n-icon><AddOutline /></n-icon></template>生成密钥
</n-button> </n-button>
@@ -128,7 +131,7 @@ function regionLabel(k: proxyKeyView): string {
<div class="usage panel-inset"> <div class="usage panel-inset">
<b>使用方式</b> <b>使用方式</b>
<div class="mono usage-line">GET /api/v1/proxy?key=&lt;密钥&gt;</div> <div class="mono usage-line">GET /api/v1/proxy?key=&lt;密钥&gt;{{ withScheme ? '&fmt=url' : '' }}</div>
<div class="dim">每次请求实时探活,返回一个可用的 <span class="mono">ip:port</span>(纯文本);加 <span class="mono">&amp;fmt=url</span> 返回带协议的 <span class="mono">scheme://ip:port</span>(socks5h/http/socks4)。地区由密钥绑定,「全部地区」密钥随机返回任意国家。</div> <div class="dim">每次请求实时探活,返回一个可用的 <span class="mono">ip:port</span>(纯文本);加 <span class="mono">&amp;fmt=url</span> 返回带协议的 <span class="mono">scheme://ip:port</span>(socks5h/http/socks4)。地区由密钥绑定,「全部地区」密钥随机返回任意国家。</div>
</div> </div>
@@ -175,6 +178,7 @@ function regionLabel(k: proxyKeyView): string {
.key-stat { font-size: 12px; font-weight: 700; text-align: right; flex: none; min-width: 90px; } .key-stat { font-size: 12px; font-weight: 700; text-align: right; flex: none; min-width: 90px; }
.mono { font-family: Consolas, monospace; } .mono { font-family: Consolas, monospace; }
.dim { color: var(--cb-ink-3); font-weight: 500; } .dim { color: var(--cb-ink-3); font-weight: 500; }
.fmt-toggle { display: inline-flex; align-items: center; gap: 6px; margin-left: 14px; font-size: 12px; font-weight: 700; color: var(--cb-ink-2); }
.usage { margin-top: 16px; } .usage { margin-top: 16px; }
.usage-line { margin: 8px 0 4px; font-size: 13px; } .usage-line { margin: 8px 0 4px; font-size: 13px; }
.panel-inset { .panel-inset {