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:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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]) + "..."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 是和平小店商品列表的角色与分页参数。
|
||||
|
||||
Reference in New Issue
Block a user