- 退避上限从 5 秒压到 1.5 秒(含抖动最长约 1.9 秒),连续失败放大倍数 8→4,抖动 50%→25%: 2026-10-03 实测库存只活了约 1.1 秒(PC2 四个实例 18:00:00.171~18:00:01.101 一直能锁到 同一个礼包),而 PC1 默认实例的 31 个号在 18:00:00.225 失败后一直睡到 18:01:09.379, 唯一有货的那段窗口被退避吃掉;超过 2 秒的等待都可能正好错过库存回流 - 首发请求不再随机等 150-700 毫秒,改为"第 0 个账号立即发出 + 其余按 8ms 步长错开", 整批铺开不超过 300 毫秒(账号再多也不会拖长)。定时器本来就提前约 100ms 触发以补偿 RTT, 再抖几百毫秒等于把这个提前量整个抵消,请求实际落到 t+150ms~t+650ms - 锁单成功到首次支付的等待从 2-4 秒压到 300-700 毫秒:这段纯属把关键路径上的时间白送出去, "订单操作进行中"现在按"已受理、结果未定"快重试,不需要靠等待规避 - 支付退避上限同步收紧:频控 3s→2s,其它 2s→1.2s(在途仍为 300ms→1s) - 新增 TestRetryDelayNeverBacksOffLong:遍历 75 秒 × 40 次连续失败,断言任何一次退避都 小于 2 秒,并覆盖"重试间隔策略被配到 5000ms"的极端配置 - 新增 TestFirstWaveStartsImmediately:单账号步长必须为 0、位次从 0 递增、 2/34/100/500/2000 个账号下步长 ≤8ms 且整批铺开 ≤300ms - 设置页文案同步:退避"任何一次等待都不超过约 2 秒"、首发错开说明
629 lines
16 KiB
Vue
629 lines
16 KiB
Vue
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { useSettings, type RetryStage } from '@/composables/useSettings'
|
||
|
||
const { settings, loadSettings, saveSettings } = useSettings()
|
||
|
||
const visible = ref(false)
|
||
const formData = ref({
|
||
timeApiUrl: '',
|
||
activity: '',
|
||
traceEnabled: false,
|
||
traceMaxBody: 0,
|
||
logLevel: 'info',
|
||
concurrency: 150,
|
||
attempts: 2,
|
||
retryStages: [] as RetryStage[],
|
||
seckillMode: 1,
|
||
xiaodianPageSize: 200
|
||
})
|
||
|
||
const open = async () => {
|
||
await loadSettings()
|
||
formData.value = {
|
||
...settings.value,
|
||
retryStages: settings.value.retryStages.map(s => ({ ...s }))
|
||
}
|
||
visible.value = true
|
||
}
|
||
|
||
const close = () => {
|
||
visible.value = false
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
const success = await saveSettings(formData.value)
|
||
if (success) {
|
||
close()
|
||
}
|
||
}
|
||
|
||
const addStage = () => {
|
||
const lastStage = formData.value.retryStages[formData.value.retryStages.length - 1]
|
||
const newMaxElapsed = lastStage ? lastStage.maxElapsed + 10 : 30
|
||
formData.value.retryStages.push({
|
||
maxElapsed: newMaxElapsed,
|
||
interval: 500
|
||
})
|
||
}
|
||
|
||
const removeStage = (index: number) => {
|
||
formData.value.retryStages.splice(index, 1)
|
||
}
|
||
|
||
// 暴露给父组件
|
||
defineExpose({ open, close })
|
||
</script>
|
||
|
||
<template>
|
||
<Teleport to="body">
|
||
<Transition name="modal">
|
||
<div v-if="visible" class="modal-overlay" @click.self="close">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h3>⚙ 设置</h3>
|
||
<button class="modal-close" @click="close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<!-- 时间 API -->
|
||
<div class="form-group">
|
||
<label class="form-label">时间 API 地址</label>
|
||
<input
|
||
v-model="formData.timeApiUrl"
|
||
class="form-input"
|
||
type="text"
|
||
placeholder="留空使用默认地址"
|
||
>
|
||
<div class="form-hint">用于获取斗鱼服务端时间,一般不需要修改</div>
|
||
</div>
|
||
|
||
<!-- 和平小店参数 -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">和平小店商品列表</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">每页商品数</label>
|
||
<input v-model.number="formData.xiaodianPageSize" class="form-input" type="number" min="1" max="200" step="1">
|
||
<div class="form-hint">角色信息从账号 Cookie 自动获取,接口上限 200</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 日志设置 -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">日志设置</div>
|
||
|
||
<div class="form-group">
|
||
<div class="toggle-row">
|
||
<div>
|
||
<span class="toggle-label">Trace 日志</span>
|
||
<div class="form-hint">开启后会在 logs/ 目录生成 douyu_trace_*.log</div>
|
||
</div>
|
||
<label class="toggle">
|
||
<input v-model="formData.traceEnabled" type="checkbox">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Trace 正文上限</label>
|
||
<div class="input-with-unit">
|
||
<input
|
||
v-model.number="formData.traceMaxBody"
|
||
class="form-input"
|
||
type="number"
|
||
min="0"
|
||
step="1024"
|
||
placeholder="0"
|
||
>
|
||
<span class="input-unit">字节</span>
|
||
</div>
|
||
<div class="form-hint">0 表示不限制</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">日志显示等级</label>
|
||
<select v-model="formData.logLevel" class="form-select">
|
||
<option value="debug">Debug</option>
|
||
<option value="info">Info</option>
|
||
<option value="warn">Warn</option>
|
||
<option value="error">Error</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 抢购设置 -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">抢购设置</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">并发数</label>
|
||
<input
|
||
v-model.number="formData.concurrency"
|
||
class="form-input"
|
||
type="number"
|
||
min="1"
|
||
max="500"
|
||
step="10"
|
||
>
|
||
<div class="form-hint">同时发起的请求数量,建议 50-200</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">小店抢购次数</label>
|
||
<input
|
||
v-model.number="formData.attempts"
|
||
class="form-input"
|
||
type="number"
|
||
min="1"
|
||
max="10"
|
||
step="1"
|
||
>
|
||
<div class="form-hint">
|
||
仅用于和平小店(作为次数缺省值)。精英/电竞手册按抢购窗口自动重试,不受此项影响
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 抢购模式 -->
|
||
<div class="form-group">
|
||
<label class="form-label">抢购模式</label>
|
||
<select v-model.number="formData.seckillMode" class="form-select">
|
||
<option :value="0">标准模式(电竞手册每次重新取鉴权)</option>
|
||
<option :value="1">激进模式(电竞手册复用鉴权)</option>
|
||
</select>
|
||
<div class="form-hint">
|
||
<template v-if="formData.seckillMode === 0">
|
||
精英手册固定为锁单后再支付,不查商品状态。电竞手册每次重新取鉴权后直接兑换。
|
||
</template>
|
||
<template v-else>
|
||
精英手册与标准模式相同。电竞手册复用已有鉴权直接兑换,失效后再取一次。
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 重试间隔配置 -->
|
||
<div class="form-group">
|
||
<label class="form-label">重试间隔策略</label>
|
||
<div class="retry-stages">
|
||
<div
|
||
v-for="(stage, index) in formData.retryStages"
|
||
:key="index"
|
||
class="retry-stage-row"
|
||
>
|
||
<span class="stage-label">阶段{{ index + 1 }}:</span>
|
||
<div class="stage-inputs">
|
||
<div class="stage-input-group">
|
||
<input
|
||
v-model.number="stage.maxElapsed"
|
||
class="form-input stage-input"
|
||
type="number"
|
||
min="1"
|
||
max="75"
|
||
step="1"
|
||
>
|
||
<span class="input-unit">秒内</span>
|
||
</div>
|
||
<span class="stage-separator">→</span>
|
||
<div class="stage-input-group">
|
||
<input
|
||
v-model.number="stage.interval"
|
||
class="form-input stage-input"
|
||
type="number"
|
||
min="50"
|
||
max="5000"
|
||
step="10"
|
||
>
|
||
<span class="input-unit">ms</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
class="btn-icon btn-remove"
|
||
title="删除阶段"
|
||
@click="removeStage(index)"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<button class="btn btn-ghost btn-sm btn-add-stage" @click="addStage">
|
||
+ 添加阶段
|
||
</button>
|
||
</div>
|
||
<div class="form-hint">
|
||
按经过秒数从小到大排列,控制精英/电竞手册的<strong>锁单</strong>重试节奏;
|
||
同一账号连续失败会在此基础上成倍放大,但<strong>任何一次等待都不超过约 2 秒</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-card">
|
||
<div class="info-title">💡 抢购策略说明</div>
|
||
<ul class="info-list">
|
||
<li>抢购窗口:<strong>75 秒</strong>;窗口内每个账号各自独立重试,互不阻塞</li>
|
||
<li>默认:阶段 2 为 200ms,阶段 3 为 500ms,其余阶段 1000ms</li>
|
||
<li>已锁单的订单即使窗口结束也会把<strong>支付</strong>重试完(最长 120 秒)</li>
|
||
<li>首发请求:第一个账号立即发出,其余按几毫秒步长错开,整批不超过 0.3 秒</li>
|
||
<li>"活动火爆/网络繁忙"是请求级限流(同一秒别的账号仍能锁单),会继续重试</li>
|
||
<li>"库存已耗完"也不提前停止:别人锁单未支付时库存会回流,会降频探测到窗口结束</li>
|
||
</ul>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" @click="close">取消</button>
|
||
<button class="btn btn-primary" @click="handleSave">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, .5);
|
||
z-index: 9000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
.modal {
|
||
background: var(--surface);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
width: 520px;
|
||
max-width: 92vw;
|
||
max-height: 85vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--line);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.modal-header h3 {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: var(--text-strong);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.modal-close {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 0;
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
font-size: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: .15s;
|
||
}
|
||
|
||
.modal-close:hover {
|
||
background: var(--surface-2);
|
||
color: var(--text-strong);
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
min-height: 0;
|
||
}
|
||
|
||
.form-section {
|
||
margin-top: 20px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
|
||
.form-section-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text-strong);
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.form-group:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
font-size: 12.5px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.form-hint {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
margin-top: 4px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.form-row {
|
||
display: flex;
|
||
gap: 16px;
|
||
}
|
||
|
||
.form-row .form-group {
|
||
flex: 1;
|
||
}
|
||
|
||
.form-input, .form-select {
|
||
width: 100%;
|
||
height: 36px;
|
||
padding: 0 12px;
|
||
background: var(--field);
|
||
color: var(--text-strong);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
font-size: 13px;
|
||
transition: .15s;
|
||
}
|
||
|
||
.form-input:focus, .form-select:focus {
|
||
outline: 0;
|
||
border-color: var(--accent);
|
||
box-shadow: var(--focus);
|
||
}
|
||
|
||
.input-with-unit {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.input-with-unit .form-input {
|
||
flex: 1;
|
||
}
|
||
|
||
.input-unit {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.toggle-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
}
|
||
|
||
.toggle-label {
|
||
font-size: 13px;
|
||
color: var(--text-strong);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.toggle {
|
||
position: relative;
|
||
width: 44px;
|
||
height: 24px;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toggle input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.toggle-slider {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: var(--line-strong);
|
||
border-radius: 999px;
|
||
transition: .2s;
|
||
}
|
||
|
||
.toggle-slider::before {
|
||
content: "";
|
||
position: absolute;
|
||
left: 2px;
|
||
top: 2px;
|
||
width: 20px;
|
||
height: 20px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
transition: .2s;
|
||
}
|
||
|
||
.toggle input:checked + .toggle-slider {
|
||
background: var(--accent);
|
||
}
|
||
|
||
.toggle input:checked + .toggle-slider::before {
|
||
transform: translateX(20px);
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 16px 20px;
|
||
border-top: 1px solid var(--line);
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
background: var(--surface);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.info-card {
|
||
margin-top: 16px;
|
||
padding: 14px;
|
||
background: var(--accent-soft);
|
||
border: 1px solid var(--accent-border);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.info-card-warn {
|
||
background: var(--warning-soft);
|
||
border-color: var(--warning);
|
||
}
|
||
|
||
.info-card-warn .info-title {
|
||
color: var(--warning);
|
||
}
|
||
|
||
.info-title {
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.info-list {
|
||
margin: 0;
|
||
padding-left: 18px;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.info-list li {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.info-list li:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.info-list strong {
|
||
color: var(--text-strong);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 重试阶段配置 */
|
||
.retry-stages {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.retry-stage-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 12px;
|
||
background: var(--field);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.stage-label {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
white-space: nowrap;
|
||
min-width: 42px;
|
||
}
|
||
|
||
.stage-inputs {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
}
|
||
|
||
.stage-input-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.stage-input {
|
||
width: 70px !important;
|
||
text-align: center;
|
||
}
|
||
|
||
.stage-separator {
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.btn-icon {
|
||
width: 28px;
|
||
height: 28px;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 18px;
|
||
transition: .15s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.btn-remove:hover {
|
||
background: var(--danger-soft, #fef2f2);
|
||
color: var(--danger, #ef4444);
|
||
}
|
||
|
||
.btn-add-stage {
|
||
align-self: flex-start;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.btn-sm {
|
||
height: 32px;
|
||
padding: 0 12px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 动画 */
|
||
.modal-enter-active {
|
||
transition: all .2s ease;
|
||
}
|
||
|
||
.modal-leave-active {
|
||
transition: all .2s ease;
|
||
}
|
||
|
||
.modal-enter-from {
|
||
opacity: 0;
|
||
}
|
||
|
||
.modal-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
.modal-enter-from .modal {
|
||
transform: scale(.95);
|
||
opacity: 0;
|
||
}
|
||
|
||
.modal-leave-to .modal {
|
||
transform: scale(.95);
|
||
opacity: 0;
|
||
}
|
||
</style>
|