Files
dygou_go/frontend/src/components/modal/SettingsModal.vue
T
yml2213 43270e04a6 收紧重试退避并把首发请求改成立即发出,发布0.3.1
- 退避上限从 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 秒"、首发错开说明
2026-10-03 19:21:26 +08:00

629 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>