feat(proxy): 支持多实例独立代理与短效API动态提取

- 实例配置独立:支持为每个实例单独配置 http/https/socks5 代理,避免单机多开挤兑触发斗鱼出口 IP 限流
- 动态 API 提取:原生支持星空代理等平台的 API 提取(兼容 JSON/文本格式),提供前端一键提取填入与连通性/延迟测速
- 定时前自动拉取:针对 3 分钟短效代理,支持在开抢前 15 秒自动提取并绑定最新 IP,并在预热前完成热连接与时间复校
- 修复复校竞态:将 resyncBeforeTrigger 从 3s 提升至 8s,确保三轮采样在开抢前顺利落盘,消除时钟漂移
- 验证方式:通过 go test ./... 全量测试与前端 vue-tsc/vite 编译,实测通过星空代理 API 解析测试用例
This commit is contained in:
yml2213
2026-10-24 19:26:16 +08:00
parent 288109de2f
commit bc366fd6b9
12 changed files with 889 additions and 33 deletions
+244 -1
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useSettings, type RetryStage } from '@/composables/useSettings'
import { useToast } from '@/composables/useToast'
import { TestProxy, FetchProxy } from '@wails/go/app/App'
const { settings, loadSettings, saveSettings } = useSettings()
const { showToast } = useToast()
const visible = ref(false)
const formData = ref({
@@ -15,15 +18,81 @@ const formData = ref({
attempts: 2,
retryStages: [] as RetryStage[],
seckillMode: 1,
xiaodianPageSize: 200
xiaodianPageSize: 200,
proxyEnabled: false,
proxyUrl: '',
proxyApi: '',
autoFetchProxyOnSchedule: false
})
const testingProxy = ref(false)
const testProxyResult = ref<{ success?: boolean; text?: string; error?: string } | null>(null)
const fetchingProxy = ref(false)
const handleTestProxy = async () => {
const target = formData.value.proxyUrl.trim()
if (!target) {
testProxyResult.value = { success: false, error: '请先输入代理地址' }
return
}
testingProxy.value = true
testProxyResult.value = null
try {
const res = await TestProxy(target)
if (res.success) {
testProxyResult.value = {
success: true,
text: `连通正常 | 出口 IP: ${res.ip}${res.region ? ' (' + res.region + ')' : ''} | 往返延迟: ${res.latency}ms`
}
} else {
testProxyResult.value = {
success: false,
error: res.error || '测试连接失败'
}
}
} catch (e: any) {
testProxyResult.value = {
success: false,
error: e.message || String(e)
}
} finally {
testingProxy.value = false
}
}
const handleFetchProxy = async () => {
const api = formData.value.proxyApi.trim()
if (!api) {
showToast('提取失败', '请先填写代理提取 API 链接', 'error')
return
}
fetchingProxy.value = true
testProxyResult.value = null
try {
const res = await FetchProxy(api)
if (res.success && res.proxyUrl) {
formData.value.proxyUrl = res.proxyUrl
formData.value.proxyEnabled = true
showToast('提取成功', `已获取代理: ${res.proxyUrl} (${res.region || ''})`, 'success')
// 自动触发一次连通性测速
handleTestProxy()
} else {
showToast('提取失败', res.error || '未能提取到有效代理', 'error')
}
} catch (e: any) {
showToast('提取失败', e.message || String(e), 'error')
} finally {
fetchingProxy.value = false
}
}
const open = async () => {
await loadSettings()
formData.value = {
...settings.value,
retryStages: settings.value.retryStages.map(s => ({ ...s }))
}
testProxyResult.value = null
visible.value = true
}
@@ -77,6 +146,86 @@ defineExpose({ open, close })
<div class="form-hint">用于获取斗鱼服务端时间,一般不需要修改</div>
</div>
<!-- 实例独立代理设置 (防多开 IP 限流/风控) -->
<div class="form-section">
<div class="form-section-title">🌐 实例独立代理设置 (防多开 IP 限流)</div>
<div class="form-group">
<div class="toggle-row">
<div>
<span class="toggle-label">启用代理</span>
<div class="form-hint">开启后本实例所有请求(校准、预热、锁单、支付)均走该代理出口</div>
</div>
<label class="toggle">
<input v-model="formData.proxyEnabled" type="checkbox">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div v-if="formData.proxyEnabled" class="proxy-config-area">
<!-- 静态/当前代理 -->
<div class="form-group">
<label class="form-label">当前代理地址 (IP:端口 或 隧道)</label>
<div class="input-with-button">
<input
v-model="formData.proxyUrl"
class="form-input"
type="text"
placeholder="例: http://115.230.134.174:40014 或 socks5://127.0.0.1:1080"
>
<button
type="button"
class="btn-action"
:disabled="testingProxy || !formData.proxyUrl"
@click="handleTestProxy"
>
<span v-if="testingProxy">测试中...</span>
<span v-else>⚡ 测试代理</span>
</button>
</div>
<div class="form-hint">支持 http、https、socks5(可带账号密码,格式: http://user:pass@ip:port)</div>
<!-- 测试结果展示 -->
<div v-if="testProxyResult" class="test-result-box" :class="testProxyResult.success ? 'success' : 'error'">
<span v-if="testProxyResult.success">✅ {{ testProxyResult.text }}</span>
<span v-else>❌ {{ testProxyResult.error }}</span>
</div>
</div>
<!-- 代理提取 API -->
<div class="form-group">
<label class="form-label">代理 API 提取链接 (星空/快代理/品赞等动态 IP)</label>
<div class="input-with-button">
<input
v-model="formData.proxyApi"
class="form-input"
type="text"
placeholder="粘贴代理平台的提取 URL,例如 http://api2.xkdaili.com/tools/XApi.ashx?..."
>
<button
type="button"
class="btn-action primary"
:disabled="fetchingProxy || !formData.proxyApi"
@click="handleFetchProxy"
>
<span v-if="fetchingProxy">提取中...</span>
<span v-else>🔄 提取并填入</span>
</button>
</div>
<div class="form-hint">支持返回 JSON 或纯文本格式的代理 API,点击后自动拉取并填入上方地址</div>
</div>
<!-- 定时抢购前自动拉取最新代理 -->
<div class="form-group">
<label class="checkbox-row">
<input v-model="formData.autoFetchProxyOnSchedule" type="checkbox">
<span class="checkbox-text">定时任务触发前 15 秒自动提取最新 IP</span>
</label>
<div class="form-hint">适用于 3~5 分钟短效代理:定时抢购触发前自动刷新 IP,确保开抢时代理不过期且连接最新</div>
</div>
</div>
</div>
<!-- 和平小店参数 -->
<div class="form-section">
<div class="form-section-title">和平小店商品列表</div>
@@ -605,6 +754,100 @@ defineExpose({ open, close })
font-size: 12px;
}
.input-with-button {
display: flex;
gap: 8px;
align-items: center;
}
.input-with-button .form-input {
flex: 1;
}
.btn-action {
height: 36px;
padding: 0 14px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--text-strong);
font-size: 12.5px;
cursor: pointer;
white-space: nowrap;
transition: .15s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-action:hover:not(:disabled) {
background: var(--surface-3, rgba(255, 255, 255, 0.08));
border-color: var(--accent);
}
.btn-action.primary {
background: var(--accent);
color: #fff;
border-color: transparent;
}
.btn-action.primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.test-result-box {
margin-top: 8px;
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 12px;
line-height: 1.4;
}
.test-result-box.success {
background: rgba(34, 197, 94, 0.12);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.test-result-box.error {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.checkbox-text {
font-size: 13px;
color: var(--text-strong);
font-weight: 500;
}
.proxy-config-area {
margin-top: 14px;
padding: 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
/* 动画 */
.modal-enter-active {
transition: all .2s ease;
+14 -2
View File
@@ -19,6 +19,10 @@ export interface Settings {
retryStages: RetryStage[]
seckillMode: number // 0=标准, 1=激进
xiaodianPageSize: number
proxyEnabled: boolean
proxyUrl: string
proxyApi: string
autoFetchProxyOnSchedule: boolean
}
const defaultRetryStages: RetryStage[] = [
@@ -38,7 +42,11 @@ const settings = ref<Settings>({
attempts: 2,
retryStages: [...defaultRetryStages],
seckillMode: 1,
xiaodianPageSize: 200
xiaodianPageSize: 200,
proxyEnabled: false,
proxyUrl: '',
proxyApi: '',
autoFetchProxyOnSchedule: false
})
export function useSettings() {
@@ -59,7 +67,11 @@ export function useSettings() {
? cfg.retryStages
: [...defaultRetryStages],
seckillMode: cfg.seckillMode ?? 1,
xiaodianPageSize: cfg.xiaodianPageSize || 200
xiaodianPageSize: cfg.xiaodianPageSize || 200,
proxyEnabled: !!cfg.proxyEnabled,
proxyUrl: cfg.proxyUrl || '',
proxyApi: cfg.proxyApi || '',
autoFetchProxyOnSchedule: !!cfg.autoFetchProxyOnSchedule
}
} catch (e: any) {
showToast('读取设置失败', e.message, 'error')
+14 -1
View File
@@ -3,6 +3,7 @@ package app
import (
"context"
"os"
"strings"
"sync"
"time"
@@ -125,6 +126,13 @@ func New(lease *config.InstanceLease, instanceErr error) *App {
return &App{instanceLease: lease, instanceErr: instanceErr}
}
func (a *App) effectiveProxyURL(cfg types.Config) string {
if cfg.ProxyEnabled {
return strings.TrimSpace(cfg.ProxyURL)
}
return ""
}
// Startup Wails 应用启动时调用。
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
@@ -171,9 +179,14 @@ func (a *App) Startup(ctx context.Context) {
act = activity.Default()
logger.LogEvent("活动", "warn", "配置中的活动 %q 未识别,已回退到默认活动 %s", a.cfg.Activity, act.DisplayName)
}
a.client = client.New(act, a.cfg.TimeAPIURL, a.getTrace())
proxyURL := a.effectiveProxyURL(a.cfg)
a.client = client.New(act, a.cfg.TimeAPIURL, a.getTrace(), proxyURL)
if proxyURL != "" {
logger.LogEvent("代理", "info", "已启用代理: %s", proxyURL)
}
a.font = imgpkg.LoadFont(a.paths.FontFiles)
a.scheduler = seckill.New(a.client, a.paths, a.font, a.cfg.Concurrency, a.cfg.Attempts, a.cfg.RetryStages, a.cfg.SeckillMode)
a.scheduler.UpdateProxySettings(a.cfg.ProxyEnabled, a.cfg.ProxyURL, a.cfg.ProxyAPI, a.cfg.AutoFetchProxyOnSchedule)
a.scheduler.SetStateChangeCallback(func(state string) {
runtime.EventsEmit(a.ctx, seckillStateEvent, state)
})
+15 -5
View File
@@ -43,7 +43,7 @@ func (a *App) SwitchActivity(id string) types.SwitchActivityResult {
return types.SwitchActivityResult{Error: "配置保存失败: " + err.Error()}
}
a.setCfg(cfg)
newClient := client.New(act, cfg.TimeAPIURL, a.getTrace())
newClient := client.New(act, cfg.TimeAPIURL, a.getTrace(), a.effectiveProxyURL(cfg))
a.setClient(newClient)
if !a.scheduler.UpdateClient(newClient) {
logger.LogEvent("活动", "warn", "抢购任务正在运行,调度器客户端将在下次抢购时生效")
@@ -101,7 +101,8 @@ func (a *App) SaveSettings(cfg types.Config) types.SimpleResult {
}
traceChanged := cfg.TraceEnabled != oldCfg.TraceEnabled || cfg.TraceMaxBody != oldCfg.TraceMaxBody
clientChanged := traceChanged || cfg.TimeAPIURL != oldCfg.TimeAPIURL || cfg.Activity != oldCfg.Activity
proxyChanged := cfg.ProxyEnabled != oldCfg.ProxyEnabled || cfg.ProxyURL != oldCfg.ProxyURL
clientChanged := traceChanged || cfg.TimeAPIURL != oldCfg.TimeAPIURL || cfg.Activity != oldCfg.Activity || proxyChanged
if traceChanged {
a.replaceTrace(cfg)
}
@@ -110,12 +111,21 @@ func (a *App) SaveSettings(cfg types.Config) types.SimpleResult {
a.setCfg(cfg)
logger.SetMinLevel(cfg.LogLevel)
a.scheduler.UpdateSettings(cfg.Concurrency, cfg.Attempts, cfg.RetryStages, cfg.SeckillMode)
a.scheduler.UpdateProxySettings(cfg.ProxyEnabled, cfg.ProxyURL, cfg.ProxyAPI, cfg.AutoFetchProxyOnSchedule)
if clientChanged {
newClient := client.New(act, cfg.TimeAPIURL, a.getTrace())
proxyURL := a.effectiveProxyURL(cfg)
newClient := client.New(act, cfg.TimeAPIURL, a.getTrace(), proxyURL)
a.setClient(newClient)
if !a.scheduler.UpdateClient(newClient) {
logger.LogEvent("配置", "warn", "抢购任务正在运行,客户端配置将在下次抢购时完整生效")
}
if proxyChanged {
if proxyURL != "" {
logger.LogEvent("代理", "success", "代理已生效: %s", proxyURL)
} else {
logger.LogEvent("代理", "info", "代理已停用,恢复直连")
}
}
if sample, err := newClient.ServerTimeSample(); err == nil {
logger.SetDouyuOffset(sample.Offset)
} else {
@@ -123,7 +133,7 @@ func (a *App) SaveSettings(cfg types.Config) types.SimpleResult {
}
}
logger.LogEvent("配置", "success", "配置已保存并生效: 日志等级=%s 并发=%d 重试=%d 重试阶段=%d 模式=%d trace=%v",
cfg.LogLevel, cfg.Concurrency, cfg.Attempts, len(cfg.RetryStages), cfg.SeckillMode, cfg.TraceEnabled)
logger.LogEvent("配置", "success", "配置已保存并生效: 日志等级=%s 并发=%d 重试=%d 重试阶段=%d 模式=%d trace=%v 代理=%v",
cfg.LogLevel, cfg.Concurrency, cfg.Attempts, len(cfg.RetryStages), cfg.SeckillMode, cfg.TraceEnabled, cfg.ProxyEnabled)
return types.SimpleResult{Success: true}
}
+32
View File
@@ -0,0 +1,32 @@
package app
import (
"douyu_go/internal/client"
"douyu_go/internal/logger"
"douyu_go/internal/types"
)
// TestProxy 测试指定代理的连通性、出口公网 IP 与往返延迟。
func (a *App) TestProxy(proxyURL string) types.ProxyTestResult {
res := client.TestProxyConnection(proxyURL)
if res.Success {
logger.LogEvent("代理", "success", "代理测试成功: IP=%s 归属=%s 延迟=%dms", res.IP, res.Region, res.Latency)
} else {
logger.LogEvent("代理", "warn", "代理测试失败: %s", res.Error)
}
return res
}
// FetchProxy 从指定 API 提取一个代理 IP。
func (a *App) FetchProxy(apiURL string) types.ProxyFetchResult {
res, err := client.FetchProxyFromAPI(apiURL)
if err != nil {
logger.LogEvent("代理", "warn", "代理提取失败: %v", err)
return types.ProxyFetchResult{
Success: false,
Error: err.Error(),
}
}
logger.LogEvent("代理", "success", "代理提取成功: %s (IP=%s 端口=%s 地区=%s)", res.ProxyURL, res.IP, res.Port, res.Region)
return *res
}
+44 -9
View File
@@ -29,6 +29,7 @@ type DouyuClient struct {
Activity *activity.Activity
TimeAPIURL string
Trace *trace.Recorder
ProxyURL string
xiaodianEmbedMu sync.Mutex
xiaodianEmbedCache map[string]xiaodianEmbedCacheItem
@@ -56,8 +57,9 @@ type xiaodianEmbedCacheItem struct {
}
// New 创建斗鱼客户端。act 决定使用哪个活动的接口参数;
// timeAPIURL 为空时回退到 constants.DefaultTimeAPI(全局共享的服务端时间接口)。
func New(act *activity.Activity, timeAPIURL string, tr *trace.Recorder) *DouyuClient {
// timeAPIURL 为空时回退到 constants.DefaultTimeAPI(全局共享的服务端时间接口);
// proxyURL 为可选参数,为空时直连,非空时配置 http/https/socks5 代理。
func New(act *activity.Activity, timeAPIURL string, tr *trace.Recorder, proxyURL ...string) *DouyuClient {
if act == nil {
act = activity.Default()
}
@@ -67,23 +69,56 @@ func New(act *activity.Activity, timeAPIURL string, tr *trace.Recorder) *DouyuCl
if tr == nil {
tr = &trace.Recorder{}
}
var effectiveProxy string
if len(proxyURL) > 0 {
effectiveProxy = proxyURL[0]
}
trsp := &http.Transport{
MaxConnsPerHost: 120,
MaxIdleConns: 120,
MaxIdleConnsPerHost: 80,
IdleConnTimeout: 30 * time.Second,
}
if effectiveProxy != "" {
if u, err := ParseProxyURL(effectiveProxy); err == nil && u != nil {
trsp.Proxy = http.ProxyURL(u)
}
}
return &DouyuClient{
Activity: act,
TimeAPIURL: timeAPIURL,
Trace: tr,
ProxyURL: effectiveProxy,
xiaodianEmbedCache: make(map[string]xiaodianEmbedCacheItem),
HTTPClient: &http.Client{
Timeout: 8 * time.Second,
Transport: &http.Transport{
MaxConnsPerHost: 120,
MaxIdleConns: 120,
MaxIdleConnsPerHost: 80,
IdleConnTimeout: 30 * time.Second,
},
Timeout: 8 * time.Second,
Transport: trsp,
},
}
}
// SetProxy 运行时更新客户端使用的代理。
func (c *DouyuClient) SetProxy(proxyURL string) error {
var proxyFn func(*http.Request) (*url.URL, error)
if strings.TrimSpace(proxyURL) != "" {
u, err := ParseProxyURL(proxyURL)
if err != nil {
return err
}
proxyFn = http.ProxyURL(u)
}
c.ProxyURL = proxyURL
if c.HTTPClient != nil {
c.HTTPClient.CloseIdleConnections()
if trsp, ok := c.HTTPClient.Transport.(*http.Transport); ok {
trsp.Proxy = proxyFn
trsp.CloseIdleConnections()
}
}
return nil
}
// ServerTime 从商品详情接口读取 data.preExchange.currentTime。
func (c *DouyuClient) ServerTime() (time.Time, error) {
sample, err := c.ServerTimeSample()
+331
View File
@@ -0,0 +1,331 @@
package client
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"douyu_go/internal/types"
)
var ipPortRegex = regexp.MustCompile(`(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{2,5})`)
// ParseProxyURL 解析并规范化代理字符串为 *url.URL。
// 支持 http://, https://, socks5://;若未带协议头,默认补齐 http://。
func ParseProxyURL(proxyStr string) (*url.URL, error) {
s := strings.TrimSpace(proxyStr)
if s == "" {
return nil, nil
}
if !strings.Contains(s, "://") {
s = "http://" + s
}
u, err := url.Parse(s)
if err != nil {
return nil, fmt.Errorf("代理地址格式无效: %w", err)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" && scheme != "socks5" {
return nil, fmt.Errorf("不支持的代理协议 %q,仅支持 http、https、socks5", u.Scheme)
}
if u.Host == "" {
return nil, errors.New("代理地址缺少主机或端口")
}
return u, nil
}
// FetchProxyFromAPI 从指定的代理提供商 API 拉取一个代理 IP。
// 兼容星空代理等常见 JSON 格式,以及纯文本 IP:Port 格式。
func FetchProxyFromAPI(apiURL string) (*types.ProxyFetchResult, error) {
trimmed := strings.TrimSpace(apiURL)
if trimmed == "" {
return nil, errors.New("代理提取 API 地址为空")
}
if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
return nil, errors.New("代理提取 API 地址必须以 http:// 或 https:// 开头")
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, trimmed, nil)
if err != nil {
return nil, fmt.Errorf("创建提取请求失败: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
client := &http.Client{
Timeout: 8 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求代理 API 失败: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
if err != nil {
return nil, fmt.Errorf("读取代理 API 响应失败: %w", err)
}
bodyStr := strings.TrimSpace(string(bodyBytes))
if bodyStr == "" {
return nil, errors.New("代理 API 返回内容为空")
}
// 尝试优先按照 JSON 结构体解析
var jsonObj struct {
Status int `json:"status"`
Code *int `json:"code"`
Info string `json:"info"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
if jsonErr := json.Unmarshal([]byte(bodyStr), &jsonObj); jsonErr == nil {
// 判断是否有错误提示
errInfo := jsonObj.Info
if errInfo == "" {
errInfo = jsonObj.Msg
}
// 星空代理成功状态为 100,其它平台通用成功状态为 0
hasSuccessStatus := jsonObj.Status == 100 || (jsonObj.Code != nil && *jsonObj.Code == 0)
hasErrorStatus := (jsonObj.Status != 0 && jsonObj.Status != 100) || (jsonObj.Code != nil && *jsonObj.Code != 0)
if hasErrorStatus && errInfo != "" {
return nil, fmt.Errorf("代理平台返回: %s (状态码=%d)", errInfo, jsonObj.Status)
}
if len(jsonObj.Data) > 0 {
// 1. 尝试解析为对象数组: [{"ip":"...", "port":"...", "prov":"...", "city":"..."}]
type nodeItem struct {
IP string `json:"ip"`
Port any `json:"port"`
Prov string `json:"prov"`
City string `json:"city"`
}
var items []nodeItem
if err := json.Unmarshal(jsonObj.Data, &items); err == nil && len(items) > 0 {
ip := strings.TrimSpace(items[0].IP)
portStr := fmt.Sprintf("%v", items[0].Port)
region := strings.TrimSpace(items[0].Prov + " " + items[0].City)
if ip != "" && portStr != "" {
return &types.ProxyFetchResult{
Success: true,
ProxyURL: fmt.Sprintf("http://%s:%s", ip, portStr),
IP: ip,
Port: portStr,
Region: region,
}, nil
}
}
// 2. 尝试解析为字符串数组: ["ip:port"]
var strArr []string
if err := json.Unmarshal(jsonObj.Data, &strArr); err == nil && len(strArr) > 0 {
if m := ipPortRegex.FindStringSubmatch(strArr[0]); len(m) == 3 {
return &types.ProxyFetchResult{
Success: true,
ProxyURL: fmt.Sprintf("http://%s:%s", m[1], m[2]),
IP: m[1],
Port: m[2],
}, nil
}
}
// 3. 尝试解析为单字符串
var singleStr string
if err := json.Unmarshal(jsonObj.Data, &singleStr); err == nil {
if m := ipPortRegex.FindStringSubmatch(singleStr); len(m) == 3 {
return &types.ProxyFetchResult{
Success: true,
ProxyURL: fmt.Sprintf("http://%s:%s", m[1], m[2]),
IP: m[1],
Port: m[2],
}, nil
}
}
}
if !hasSuccessStatus && errInfo != "" {
return nil, fmt.Errorf("代理平台提示: %s", errInfo)
}
}
// 兜底正则提取 IP:Port 文本格式
matches := ipPortRegex.FindStringSubmatch(bodyStr)
if len(matches) == 3 {
return &types.ProxyFetchResult{
Success: true,
ProxyURL: fmt.Sprintf("http://%s:%s", matches[1], matches[2]),
IP: matches[1],
Port: matches[2],
}, nil
}
return nil, fmt.Errorf("未能从 API 返回中识别出有效 IP:端口,原始返回: %s", truncateString(bodyStr, 120))
}
// TestProxyConnection 测试指定代理的连通性、出口公网 IP 与往返延迟。
func TestProxyConnection(proxyStr string) types.ProxyTestResult {
start := time.Now()
trsp := &http.Transport{
MaxConnsPerHost: 10,
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 10 * time.Second,
}
trimmed := strings.TrimSpace(proxyStr)
if trimmed != "" {
u, err := ParseProxyURL(trimmed)
if err != nil {
return types.ProxyTestResult{
Success: false,
Error: err.Error(),
}
}
trsp.Proxy = http.ProxyURL(u)
}
client := &http.Client{
Transport: trsp,
Timeout: 6 * time.Second,
}
// 优先选用 myip.ipip.net
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://myip.ipip.net", nil)
if err != nil {
return types.ProxyTestResult{
Success: false,
Error: fmt.Sprintf("创建请求失败: %v", err),
}
}
req.Header.Set("User-Agent", "curl/8.0")
resp, err := client.Do(req)
latency := time.Since(start).Milliseconds()
if err != nil {
// 备用请求 cip.cc
backupStart := time.Now()
backupReq, bErr := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://cip.cc", nil)
if bErr == nil {
backupReq.Header.Set("User-Agent", "curl/8.0")
if bResp, bDoErr := client.Do(backupReq); bDoErr == nil {
defer bResp.Body.Close()
latency = time.Since(backupStart).Milliseconds()
bBytes, _ := io.ReadAll(io.LimitReader(bResp.Body, 8192))
bText := string(bBytes)
ip, region := parseCipResponse(bText)
if ip != "" {
return types.ProxyTestResult{
Success: true,
IP: ip,
Region: region,
Latency: latency,
}
}
}
}
return types.ProxyTestResult{
Success: false,
Latency: latency,
Error: formatProxyError(err),
}
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 8192))
if err != nil {
return types.ProxyTestResult{
Success: false,
Latency: latency,
Error: fmt.Sprintf("读取响应失败: %v", err),
}
}
text := string(bodyBytes)
ip, region := parseIpipResponse(text)
if ip == "" {
// 尝试正则抽取 IP
m := regexp.MustCompile(`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`).FindString(text)
ip = m
}
if ip == "" {
return types.ProxyTestResult{
Success: false,
Latency: latency,
Error: fmt.Sprintf("无法识别出口 IP,返回内容: %s", truncateString(text, 80)),
}
}
return types.ProxyTestResult{
Success: true,
IP: ip,
Region: region,
Latency: latency,
}
}
func parseIpipResponse(text string) (string, string) {
// 示例: 当前 IP:115.230.134.174 来自于:中国 浙江 嘉兴 电信
text = strings.TrimSpace(text)
ip := ""
region := ""
if idx := strings.Index(text, "当前 IP:"); idx != -1 {
rest := text[idx+len("当前 IP:"):]
parts := strings.SplitN(rest, "来自于:", 2)
ip = strings.TrimSpace(parts[0])
if len(parts) > 1 {
region = strings.TrimSpace(parts[1])
}
}
return ip, region
}
func parseCipResponse(text string) (string, string) {
// IP : 115.230.134.174
// 地址 : 中国 浙江 嘉兴
lines := strings.Split(text, "\n")
ip := ""
region := ""
for _, l := range lines {
l = strings.TrimSpace(l)
if strings.HasPrefix(l, "IP") && strings.Contains(l, ":") {
parts := strings.SplitN(l, ":", 2)
ip = strings.TrimSpace(parts[1])
}
if strings.HasPrefix(l, "地址") && strings.Contains(l, ":") {
parts := strings.SplitN(l, ":", 2)
region = strings.TrimSpace(parts[1])
}
}
return ip, region
}
func formatProxyError(err error) string {
msg := err.Error()
if strings.Contains(msg, "407") {
return "代理服务器要求身份认证(407):请检查是否已在代理后台将本机公网IP添加进白名单,或代理账密是否有误"
}
if strings.Contains(msg, "context deadline exceeded") || strings.Contains(msg, "timeout") {
return "连接代理超时(timeout):代理节点可能已失效或网络不通"
}
if strings.Contains(msg, "connection refused") {
return "代理连接被拒绝(connection refused):请核对代理地址与端口"
}
return msg
}
func truncateString(s string, maxLen int) string {
r := []rune(s)
if len(r) <= maxLen {
return s
}
return string(r[:maxLen]) + "..."
}
+101
View File
@@ -0,0 +1,101 @@
package client
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestParseProxyURL(t *testing.T) {
tests := []struct {
input string
expectURL string
expectError bool
}{
{"", "", false},
{" ", "", false},
{"127.0.0.1:8888", "http://127.0.0.1:8888", false},
{"http://127.0.0.1:8888", "http://127.0.0.1:8888", false},
{"https://127.0.0.1:8888", "https://127.0.0.1:8888", false},
{"socks5://127.0.0.1:1080", "socks5://127.0.0.1:1080", false},
{"http://user:pass@127.0.0.1:8888", "http://user:pass@127.0.0.1:8888", false},
{"ftp://127.0.0.1:21", "", true},
}
for _, tc := range tests {
u, err := ParseProxyURL(tc.input)
if tc.expectError {
if err == nil {
t.Fatalf("ParseProxyURL(%q) expected error, got nil", tc.input)
}
} else {
if err != nil {
t.Fatalf("ParseProxyURL(%q) unexpected error: %v", tc.input, err)
}
if tc.expectURL == "" {
if u != nil {
t.Fatalf("ParseProxyURL(%q) expected nil, got %v", tc.input, u)
}
} else if u.String() != tc.expectURL {
t.Fatalf("ParseProxyURL(%q) = %q, want %q", tc.input, u.String(), tc.expectURL)
}
}
}
}
func TestFetchProxyFromAPI_XKDaili(t *testing.T) {
// 模拟星空代理正常返回
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
fmt.Fprint(w, `{ "status":100 ,"info":"成功" , "data": [{"ip": "115.230.134.174", "port": "40014", "prov": "浙江", "city": "嘉兴"}]}`)
}))
defer ts.Close()
res, err := FetchProxyFromAPI(ts.URL)
if err != nil {
t.Fatalf("FetchProxyFromAPI failed: %v", err)
}
if res.IP != "115.230.134.174" || res.Port != "40014" {
t.Fatalf("unexpected res: %+v", res)
}
if res.ProxyURL != "http://115.230.134.174:40014" {
t.Fatalf("unexpected proxy url: %s", res.ProxyURL)
}
if res.Region != "浙江 嘉兴" {
t.Fatalf("unexpected region: %s", res.Region)
}
}
func TestFetchProxyFromAPI_Error(t *testing.T) {
// 模拟星空代理未加白名单报错
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
fmt.Fprint(w, `{ "status":211 , "info":"当前IP不在白名单内" }`)
}))
defer ts.Close()
_, err := FetchProxyFromAPI(ts.URL)
if err == nil {
t.Fatal("expected error, got nil")
}
if err.Error() != "代理平台返回: 当前IP不在白名单内 (状态码=211)" {
t.Fatalf("unexpected error string: %v", err)
}
}
func TestFetchProxyFromAPI_Text(t *testing.T) {
// 模拟纯文本返回
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "221.122.91.45:3128\r\n")
}))
defer ts.Close()
res, err := FetchProxyFromAPI(ts.URL)
if err != nil {
t.Fatalf("FetchProxyFromAPI failed: %v", err)
}
if res.ProxyURL != "http://221.122.91.45:3128" {
t.Fatalf("unexpected proxy url: %s", res.ProxyURL)
}
}
+38
View File
@@ -7,6 +7,7 @@ import (
"sync/atomic"
"time"
"douyu_go/internal/client"
"douyu_go/internal/constants"
"douyu_go/internal/logger"
"douyu_go/internal/types"
@@ -46,6 +47,39 @@ func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (types.ScheduleResult,
s.scheduleTimers(ap, targetTime, triggerTime, calibration, req.Attempts, req.DelayMs, false)
s.proxyMu.RLock()
autoFetch := s.autoFetchProxyOnSchedule
apiURL := s.proxyAPI
s.proxyMu.RUnlock()
if autoFetch && apiURL != "" {
proxyFetchRemain := triggerTime.Sub(serverTime) - proxyFetchBeforeTrigger
if proxyFetchRemain < 0 {
proxyFetchRemain = 0
}
s.scheduleMu.Lock()
s.replaceTimerLocked(&s.proxyFetchSchedule, proxyFetchRemain, func() {
if s.IsRunning() {
return
}
logger.LogEvent("代理", "info", "定时任务触发前,自动从 API 提取最新独立代理...")
res, err := client.FetchProxyFromAPI(apiURL)
if err != nil {
logger.LogEvent("代理", "warn", "定时自动提取代理失败: %v,继续使用现有网络通道", err)
return
}
cli := s.clientSnapshot()
if cli != nil {
if err := cli.SetProxy(res.ProxyURL); err != nil {
logger.LogEvent("代理", "warn", "应用提取代理失败: %v", err)
} else {
logger.LogEvent("代理", "success", "定时自动应用最新代理: %s (IP=%s 归属=%s)", res.ProxyURL, res.IP, res.Region)
}
}
})
s.scheduleMu.Unlock()
}
resyncRemain := triggerTime.Sub(serverTime) - resyncBeforeTrigger
if resyncRemain < 0 {
resyncRemain = 0
@@ -213,4 +247,8 @@ func (s *Scheduler) stopSchedulesLocked() {
s.resyncSchedule.Stop()
s.resyncSchedule = nil
}
if s.proxyFetchSchedule != nil {
s.proxyFetchSchedule.Stop()
s.proxyFetchSchedule = nil
}
}
+32 -14
View File
@@ -21,15 +21,16 @@ import (
)
const (
warmupBeforeTrigger = 2 * time.Second
warmupMaxAge = 30 * time.Second
qualifiedMaxAge = 30 * time.Second
timeCalibrateRounds = 3
timeCalibrateWait = 1200 * time.Millisecond
resyncBeforeTrigger = 3 * time.Second
calibrationHistorySize = 3
maxCooldownCount = 5
xiaodianDefaultDelay = 500 * time.Millisecond
warmupBeforeTrigger = 2 * time.Second
warmupMaxAge = 30 * time.Second
qualifiedMaxAge = 30 * time.Second
timeCalibrateRounds = 3
timeCalibrateWait = 1200 * time.Millisecond
resyncBeforeTrigger = 8 * time.Second
proxyFetchBeforeTrigger = 15 * time.Second
calibrationHistorySize = 3
maxCooldownCount = 5
xiaodianDefaultDelay = 500 * time.Millisecond
)
// seckillMaxDuration 是手册类活动的抢购窗口:超过后不再发起新的锁单。
@@ -89,11 +90,18 @@ type Scheduler struct {
// pendingClient 保存运行中收到的客户端更新,当前任务结束后自动应用。
pendingClient *client.DouyuClient
scheduleMu sync.Mutex
schedule *time.Timer
warmupSchedule *time.Timer
resyncSchedule *time.Timer
warmupGen int64
scheduleMu sync.Mutex
schedule *time.Timer
warmupSchedule *time.Timer
resyncSchedule *time.Timer
proxyFetchSchedule *time.Timer
warmupGen int64
proxyMu sync.RWMutex
proxyEnabled bool
proxyURL string
proxyAPI string
autoFetchProxyOnSchedule bool
warmupsMu sync.RWMutex
warmups map[string]types.WarmupData
@@ -238,6 +246,16 @@ func (s *Scheduler) UpdateSettings(concurrency, attempts int, retryStages []type
s.seckillMode = normalizeSeckillMode(seckillMode)
}
// UpdateProxySettings 运行时更新代理配置。
func (s *Scheduler) UpdateProxySettings(enabled bool, proxyURL, proxyAPI string, autoFetch bool) {
s.proxyMu.Lock()
defer s.proxyMu.Unlock()
s.proxyEnabled = enabled
s.proxyURL = proxyURL
s.proxyAPI = proxyAPI
s.autoFetchProxyOnSchedule = autoFetch
}
func (s *Scheduler) settingsSnapshot() runSettings {
s.settingsMu.RLock()
settings := runSettings{
+1 -1
View File
@@ -72,7 +72,7 @@ func newOrderTestScheduler(t *testing.T, handler http.HandlerFunc) *Scheduler {
act.CSRFURL = server.URL + "/csrf"
act.ScoreURL = server.URL + "/score"
act.ScoreMethod = "POST"
cli := client.New(&act, "", &trace.Recorder{})
cli := client.New(&act, "", &trace.Recorder{}, "")
cli.HTTPClient = server.Client()
return New(cli, types.AppPaths{}, nil, 8, 1, nil, types.SeckillModeAggressive)
}
+23
View File
@@ -17,6 +17,29 @@ type Config struct {
RetryStages []RetryStage `json:"retryStages"` // 精英/电竞手册的锁单重试节奏(按经过秒数分档)
SeckillMode SeckillMode `json:"seckillMode"` // 抢购模式:0=标准, 1=激进
XiaodianPageSize int `json:"xiaodianPageSize"`
ProxyEnabled bool `json:"proxyEnabled"` // 是否启用代理
ProxyURL string `json:"proxyUrl"` // 代理地址,支持 http://, https://, socks5://
ProxyAPI string `json:"proxyApi"` // 代理提取 API 地址(如星空代理)
AutoFetchProxyOnSchedule bool `json:"autoFetchProxyOnSchedule"` // 定时抢购触发前自动从 API 提取最新代理
}
// ProxyTestResult 代理测试结果。
type ProxyTestResult struct {
Success bool `json:"success"`
IP string `json:"ip,omitempty"`
Region string `json:"region,omitempty"`
Latency int64 `json:"latency"` // 往返耗时(毫秒)
Error string `json:"error,omitempty"`
}
// ProxyFetchResult 代理 API 提取结果。
type ProxyFetchResult struct {
Success bool `json:"success"`
ProxyURL string `json:"proxyUrl,omitempty"`
IP string `json:"ip,omitempty"`
Port string `json:"port,omitempty"`
Region string `json:"region,omitempty"`
Error string `json:"error,omitempty"`
}
// XiaodianProductListOptions 是和平小店商品列表的角色与分页参数。