Files
stock-checker/main.go
2025-12-12 04:21:50 +08:00

927 lines
32 KiB
Go
Raw Permalink 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.
package main
import (
"context"
"encoding/json"
"html/template"
"io"
"log"
"fmt"
"math/rand"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
)
type Task struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
PageLoaded string `json:"page_loaded"`
OutOfStock string `json:"out_of_stock"`
InStock bool `json:"in_stock"`
LastCheck time.Time `json:"last_check"`
Status string `json:"status"`
Notified bool `json:"notified"`
NotifyEnabled bool `json:"notify_enabled"`
Order int `json:"order"`
History []HistoryItem `json:"history"`
// 新增:自动下单相关
AutoOrder bool `json:"auto_order"` // 是否启用自动下单
OrderConfigPath string `json:"order_config"` // 下单配置路径
Ordering bool `json:"ordering"` // 是否正在下单中
LastOrderTime time.Time `json:"last_order_time"` // 最后下单时间
OrderSuccess bool `json:"order_success"` // 是否下单成功过
}
type Config struct {
GotifyURL string `json:"gotify_url"`
GotifyToken string `json:"gotify_token"`
Interval int `json:"interval"`
Timeout int `json:"timeout"`
NotifyEnabled bool `json:"notify_enabled"`
}
type HistoryItem struct {
State string `json:"state"`
Time time.Time `json:"time"`
}
// OrderResult 结构体
type OrderResult struct {
Success bool `json:"success"`
Message string `json:"message"`
OrderID string `json:"order_id"`
Price string `json:"price"`
Location string `json:"location"`
Screenshot string `json:"screenshot"`
}
var (
tasks = make(map[string]*Task)
config = &Config{Interval: 60, Timeout: 20, NotifyEnabled: true}
mu sync.RWMutex
configMu sync.RWMutex
authToken = os.Getenv("AUTH_TOKEN")
client *http.Client
orderLocks = make(map[string]*sync.Mutex)
orderLocksMu sync.Mutex
)
func main() {
loadEnv()
loadTasks()
loadConfig()
updateClient()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go checker(ctx)
http.HandleFunc("/", handleIndex)
http.HandleFunc("/api/tasks", auth(handleTasks))
http.HandleFunc("/api/task", auth(handleTask))
http.HandleFunc("/api/task/toggle-notify", auth(handleToggleNotify))
http.HandleFunc("/api/config", auth(handleConfig))
http.HandleFunc("/api/test-notification", auth(handleTestNotification))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Println("启动服务:", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
func loadEnv() {
data, err := os.ReadFile(".env")
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
os.Setenv(parts[0], parts[1])
}
}
}
func auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if authToken != "" && r.Header.Get("Authorization") != "Bearer "+authToken {
http.Error(w, "Unauthorized", 401)
return
}
next(w, r)
}
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
tmpl := `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>库存监控</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#f5f5f5;--card:#fff;--text:#333;--border:#ddd;--th-bg:#f8f9fa;
--green:#4CAF50;--red:#f44336;--orange:#ff9800;--blue:#2196F3;--gray:#999
}
@media(prefers-color-scheme:dark){
:root{
--bg:#1a1a1a;--card:#2d2d2d;--text:#e0e0e0;--border:#404040;--th-bg:#383838
}
}
body{font:14px sans-serif;padding:20px;background:var(--bg);color:var(--text);transition:all .3s}
.container{max-width:1400px;margin:0 auto}
h1{margin-bottom:20px;text-align:center}
.header{display:flex;justify-content:center;gap:10px;margin-bottom:20px}
.add-btn,.config-btn{background:var(--green);color:#fff;border:none;padding:10px 20px;cursor:pointer;border-radius:4px}
.config-btn{background:var(--blue)}
table{width:100%;background:var(--card);border-collapse:collapse;box-shadow:0 2px 8px rgba(0,0,0,.15);border-radius:8px;overflow:hidden}
th,td{padding:12px;text-align:left;border-bottom:1px solid var(--border)}
th{background:var(--th-bg);text-align:center;font-weight:600}
tbody tr:hover{background:rgba(33,150,243,.05)}
.status-cell{text-align:center}
.status{display:inline-block;width:10px;height:10px;border-radius:50%}
.status.ok{background:var(--green)}
.status.error{background:var(--red)}
.status.checking{background:var(--orange)}
.stock-yes{color:var(--green);font-weight:600}
.stock-no{color:var(--gray)}
.btn{padding:5px 10px;margin:0 2px;cursor:pointer;border:none;border-radius:3px;background:var(--blue);color:#fff;font-size:12px}
.btn-del{background:var(--red)}
.btn-icon{background:transparent;color:var(--text);font-size:18px;padding:5px;margin:0 2px;cursor:pointer;border:none}
.btn-icon.active{color:var(--green)}
.btn-icon.edit{color:var(--blue)}
.btn-icon.delete{color:var(--red)}
.uptime-bar{display:flex;gap:2px;height:30px}
.uptime-item{flex:1;border-radius:2px;cursor:pointer;position:relative;transition:transform .2s}
.uptime-item:hover{transform:scaleY(1.2)}
.uptime-item.stock{background:var(--green)}
.uptime-item.no-stock{background:#555}
.uptime-item.error{background:var(--orange)}
.uptime-item.unknown{background:#333}
.uptime-tooltip{position:absolute;bottom:35px;left:50%;transform:translateX(-50%);background:#000;color:#fff;padding:4px 8px;border-radius:4px;font-size:11px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .2s;z-index:10}
.uptime-item:hover .uptime-tooltip{opacity:1}
.modal{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);align-items:center;justify-content:center;z-index:1000}
.modal.show{display:flex}
.modal-content{background:var(--card);padding:24px;border-radius:8px;width:90%;max-width:500px;box-shadow:0 4px 20px rgba(0,0,0,.3)}
.form-group{margin-bottom:15px}
.form-group.checkbox-group{display:flex;align-items:center;justify-content:flex-end;gap:10px}
.form-group.checkbox-group label{margin:0;font-weight:600}
.form-group.checkbox-group input[type="checkbox"]{width:auto;margin:0}
label{display:block;margin-bottom:5px;font-weight:600}
input{width:100%;padding:10px;border:1px solid var(--border);border-radius:4px;background:var(--card);color:var(--text)}
.form-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
a{text-decoration:none;color:var(--blue)}
@media(max-width:768px){
.container{padding:10px}
table{font-size:12px}
th,td{padding:8px}
}
</style>
</head>
<body>
<div class="container">
<h1>库存监控</h1>
<div class="header">
<button class="config-btn" onclick="showConfigModal()">⚙ 系统设置</button>
<button class="add-btn" onclick="showModal()">+ 添加监控</button>
</div>
<table id="taskTable">
<thead>
<tr>
<th width="60">状态</th>
<th width="150">名称</th>
<th>监控历史</th>
<th width="80">库存</th>
<th width="150">最后检测</th>
<th width="180">操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="modal" id="modal">
<div class="modal-content">
<h2 id="modalTitle">添加监控</h2>
<form id="taskForm">
<input type="hidden" id="taskId">
<div class="form-group">
<label>名称</label>
<input id="name" required>
</div>
<div class="form-group">
<label>URL</label>
<input id="url" required>
</div>
<div class="form-group">
<label>页面加载标识</label>
<input id="pageLoaded" placeholder="如: froots/js-js" required>
</div>
<div class="form-group">
<label>缺货标识</label>
<input id="outOfStock" placeholder="如: Out of Stock" required>
</div>
<div class="form-group">
<label>排序</label>
<input id="order" type="number" value="0">
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="hideModal()">取消</button>
<button type="submit" class="btn">保存</button>
</div>
</form>
</div>
</div>
<div class="modal" id="configModal">
<div class="modal-content">
<h2>系统设置</h2>
<form id="configForm">
<div class="form-group checkbox-group">
<label>启用通知推送</label>
<input type="checkbox" id="notifyEnabled">
</div>
<div class="form-group">
<label>Gotify URL</label>
<input id="gotifyUrl" placeholder="https://gotify.example.com">
</div>
<div class="form-group">
<label>Gotify Token</label>
<input id="gotifyToken" placeholder="通知令牌">
</div>
<div class="form-group">
<label>检测间隔(秒)</label>
<input id="interval" type="number" min="10" required>
</div>
<div class="form-group">
<label>超时时间(秒)</label>
<input id="timeout" type="number" min="5" required>
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="testNotification()">测试通知</button>
<button type="button" class="btn" onclick="hideConfigModal()">取消</button>
<button type="submit" class="btn">保存</button>
</div>
</form>
</div>
</div>
<script>
const token = localStorage.getItem('token') || prompt('请输入访问令牌:');
if(token) localStorage.setItem('token', token);
const headers = {'Authorization': 'Bearer ' + token};
function loadTasks() {
fetch('/api/tasks', {headers})
.then(r => {
if (!r.ok) {
localStorage.removeItem('token');
alert('认证失败,请刷新页面重新输入');
throw new Error('Unauthorized');
}
return r.json();
})
.then(tasks => {
const tbody = document.querySelector('#taskTable tbody');
tbody.innerHTML = tasks.map(t => {
const statusClass = t.status || 'checking';
const stockText = t.in_stock ? '<span class="stock-yes">有货</span>' : '<span class="stock-no">无货</span>';
const lastCheck = t.last_check ? new Date(t.last_check).toLocaleString('zh-CN') : '-';
const notifyIcon = t.notify_enabled ? '🔔' : '🔕';
const notifyClass = t.notify_enabled ? 'active' : '';
const history = t.history || [];
const uptimeBar = history.map(h => {
const time = new Date(h.time).toLocaleString('zh-CN');
const label = h.state === 'stock' ? '有货' : h.state === 'no-stock' ? '无货' : h.state === 'error' ? '错误' : '未知';
return '<div class="uptime-item ' + h.state + '"><span class="uptime-tooltip">' + time + '<br>' + label + '</span></div>';
}).join('');
return '<tr>' +
'<td class="status-cell"><span class="status ' + statusClass + '"></span></td>' +
'<td>' + t.name + '</td>' +
'<td><div class="uptime-bar">' + uptimeBar + '</div></td>' +
'<td>' + stockText + '</td>' +
'<td>' + lastCheck + '</td>' +
'<td>' +
'<a href="' + t.url + '" target="_blank" class="btn btn-icon" title="访问">🔗</a>' +
'<button class="btn btn-icon ' + notifyClass + '" onclick="toggleNotify(\'' + t.id + '\')" title="通知开关">' + notifyIcon + '</button>' +
'<button class="btn-icon edit" onclick="editTask(\'' + t.id + '\')" title="编辑">✏️</button>' +
'<button class="btn-icon delete" onclick="delTask(\'' + t.id + '\')" title="删除">🗑️</button>' +
'</td></tr>';
}).join('');
});
}
function toggleNotify(id) {
const btn = event.target;
const wasActive = btn.classList.contains('active');
btn.classList.toggle('active');
btn.textContent = wasActive ? '🔕' : '🔔';
fetch('/api/task/toggle-notify?id=' + id, {method: 'POST', headers});
}
function showModal(task) {
document.getElementById('modalTitle').textContent = task ? '编辑监控' : '添加监控';
document.getElementById('taskId').value = task?.id || '';
document.getElementById('name').value = task?.name || '';
document.getElementById('url').value = task?.url || '';
document.getElementById('pageLoaded').value = task?.page_loaded || '';
document.getElementById('outOfStock').value = task?.out_of_stock || '';
document.getElementById('order').value = task?.order || 0;
document.getElementById('modal').classList.add('show');
}
function hideModal() {
document.getElementById('modal').classList.remove('show');
}
function showConfigModal() {
fetch('/api/config', {headers})
.then(r => r.json())
.then(cfg => {
document.getElementById('gotifyUrl').value = cfg.gotify_url || '';
document.getElementById('gotifyToken').value = cfg.gotify_token || '';
document.getElementById('interval').value = cfg.interval || 60;
document.getElementById('timeout').value = cfg.timeout || 20;
document.getElementById('notifyEnabled').checked = cfg.notify_enabled !== false;
document.getElementById('configModal').classList.add('show');
});
}
function hideConfigModal() {
document.getElementById('configModal').classList.remove('show');
}
function testNotification() {
const testConfig = {
gotify_url: document.getElementById('gotifyUrl').value,
gotify_token: document.getElementById('gotifyToken').value
};
fetch('/api/test-notification', {
method: 'POST',
headers: {...headers, 'Content-Type': 'application/json'},
body: JSON.stringify(testConfig)
}).then(r => r.ok ? alert('测试通知已发送,请检查 Gotify') : alert('发送失败,查看后台日志'));
}
function editTask(id) {
fetch('/api/tasks', {headers})
.then(r => r.json())
.then(tasks => {
const task = tasks.find(t => t.id === id);
if(task) showModal(task);
});
}
function delTask(id) {
if(!confirm('确认删除?')) return;
fetch('/api/task?id=' + id, {method: 'DELETE', headers})
.then(() => loadTasks());
}
document.getElementById('taskForm').onsubmit = e => {
e.preventDefault();
const data = {
id: document.getElementById('taskId').value,
name: document.getElementById('name').value,
url: document.getElementById('url').value,
page_loaded: document.getElementById('pageLoaded').value,
out_of_stock: document.getElementById('outOfStock').value,
order: parseInt(document.getElementById('order').value) || 0
};
fetch('/api/task', {
method: 'POST',
headers: {...headers, 'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(() => {
hideModal();
loadTasks();
});
};
document.getElementById('configForm').onsubmit = e => {
e.preventDefault();
const data = {
gotify_url: document.getElementById('gotifyUrl').value,
gotify_token: document.getElementById('gotifyToken').value,
interval: parseInt(document.getElementById('interval').value),
timeout: parseInt(document.getElementById('timeout').value),
notify_enabled: document.getElementById('notifyEnabled').checked
};
fetch('/api/config', {
method: 'POST',
headers: {...headers, 'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(() => {
hideConfigModal();
alert('设置已保存并生效');
});
};
loadTasks();
setInterval(loadTasks, 30000);
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html")
template.Must(template.New("").Parse(tmpl)).Execute(w, nil)
}
func handleTasks(w http.ResponseWriter, r *http.Request) {
mu.RLock()
list := make([]*Task, 0, len(tasks))
for _, t := range tasks {
list = append(list, t)
}
mu.RUnlock()
sort.Slice(list, func(i, j int) bool {
return list[i].Order < list[j].Order
})
json.NewEncoder(w).Encode(list)
}
func handleTask(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
id := r.URL.Query().Get("id")
mu.Lock()
delete(tasks, id)
mu.Unlock()
saveTasks()
return
}
var task Task
json.NewDecoder(r.Body).Decode(&task)
isNew := task.ID == ""
if isNew {
task.ID = time.Now().Format("20060102150405")
task.NotifyEnabled = true
task.History = make([]HistoryItem, 90)
now := time.Now()
for i := 0; i < 90; i++ {
task.History[i] = HistoryItem{
State: "unknown",
Time: now.Add(time.Duration(i-90) * time.Minute),
}
}
} else {
mu.RLock()
if existing, ok := tasks[task.ID]; ok {
task.History = existing.History
task.InStock = existing.InStock
task.LastCheck = existing.LastCheck
task.Status = existing.Status
task.Notified = existing.Notified
task.NotifyEnabled = existing.NotifyEnabled
}
mu.RUnlock()
}
mu.Lock()
tasks[task.ID] = &task
mu.Unlock()
saveTasks()
}
func handleToggleNotify(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
mu.Lock()
if task, ok := tasks[id]; ok {
task.NotifyEnabled = !task.NotifyEnabled
}
mu.Unlock()
saveTasks()
}
func handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
var cfg Config
json.NewDecoder(r.Body).Decode(&cfg)
configMu.Lock()
config = &cfg
configMu.Unlock()
updateClient()
saveConfig()
return
}
configMu.RLock()
defer configMu.RUnlock()
json.NewEncoder(w).Encode(config)
}
func checker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
configMu.RLock()
interval := config.Interval
configMu.RUnlock()
mu.RLock()
list := make([]*Task, 0, len(tasks))
for _, t := range tasks {
list = append(list, t)
}
mu.RUnlock()
for _, task := range list {
go checkTask(task)
time.Sleep(time.Duration(2+rand.Intn(5)) * time.Second)
}
jitter := rand.Intn(20) - 10
time.Sleep(time.Duration(interval+jitter) * time.Second)
}
}
func checkTask(task *Task) {
body, err := fetch(task.URL)
now := time.Now()
status := "ok"
inStock := false
if err != nil || !strings.Contains(body, task.PageLoaded) {
status = "error"
} else {
inStock = !strings.Contains(body, task.OutOfStock)
}
state := "unknown"
if status == "ok" {
state = "no-stock"
if inStock {
state = "stock"
}
} else if status == "error" {
state = "error"
}
mu.Lock()
wasInStock := task.InStock
wasNotified := task.Notified
taskNotifyEnabled := task.NotifyEnabled
taskAutoOrder := task.AutoOrder
orderConfigPath := task.OrderConfigPath
task.Status = status
task.InStock = inStock
task.LastCheck = now
if task.History == nil {
task.History = []HistoryItem{}
}
task.History = append(task.History, HistoryItem{State: state, Time: now})
if len(task.History) > 90 {
task.History = task.History[len(task.History)-90:]
}
if !inStock {
task.Notified = false
}
mu.Unlock()
saveTasks()
configMu.RLock()
globalNotifyEnabled := config.NotifyEnabled
configMu.RUnlock()
// 有货且之前无货且未通知过
if globalNotifyEnabled && taskNotifyEnabled && inStock && !wasInStock && !wasNotified {
notify(task.Name + " 有货了!", task.URL)
// 自动下单(异步执行,不阻塞监控)
if taskAutoOrder && orderConfigPath != "" {
go executeAutoOrder(task)
}
mu.Lock()
task.Notified = true
mu.Unlock()
saveTasks()
}
}
func fetch(url string) (string, error) {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body), nil
}
func notify(title, msg string) {
configMu.RLock()
url, token := config.GotifyURL, config.GotifyToken
configMu.RUnlock()
if url == "" || token == "" {
return
}
go func() {
endpoint := strings.TrimRight(url, "/") + "/message"
payload := fmt.Sprintf(`{"title":"%s","message":"%s","priority":10}`, title, msg)
req, _ := http.NewRequest("POST", endpoint, strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Gotify-Key", token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("发送通知失败: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
log.Printf("通知返回错误 %d: %s", resp.StatusCode, body)
}
}()
}
func handleTestNotification(w http.ResponseWriter, r *http.Request) {
var testConfig struct {
GotifyURL string `json:"gotify_url"`
GotifyToken string `json:"gotify_token"`
}
json.NewDecoder(r.Body).Decode(&testConfig)
if testConfig.GotifyURL == "" || testConfig.GotifyToken == "" {
http.Error(w, "缺少配置", 400)
return
}
endpoint := strings.TrimRight(testConfig.GotifyURL, "/") + "/message"
payload := `{"title":"测试通知","message":"Gotify 配置正常","priority":10}`
req, _ := http.NewRequest("POST", endpoint, strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Gotify-Key", testConfig.GotifyToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
http.Error(w, string(body), resp.StatusCode)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// 获取任务专属的锁
func getOrderLock(taskID string) *sync.Mutex {
orderLocksMu.Lock()
defer orderLocksMu.Unlock()
if _, exists := orderLocks[taskID]; !exists {
orderLocks[taskID] = &sync.Mutex{}
}
return orderLocks[taskID]
}
func executeAutoOrder(task *Task) {
// 获取该任务的专属锁
taskLock := getOrderLock(task.ID)
// 尝试获取锁,如果获取失败说明已经有下单任务在执行
if !taskLock.TryLock() {
log.Printf("[%s] 已有下单任务正在执行,跳过", task.Name)
return
}
defer taskLock.Unlock()
// 检查是否已经下单成功过
mu.RLock()
if task.OrderSuccess {
mu.RUnlock()
log.Printf("[%s] 该任务已成功下单,跳过", task.Name)
return
}
// 检查最近是否下过单(防止频繁重试)
if !task.LastOrderTime.IsZero() && time.Since(task.LastOrderTime) < 5*time.Minute {
mu.RUnlock()
log.Printf("[%s] 距离上次下单不足5分钟跳过", task.Name)
return
}
mu.RUnlock()
// 标记为下单中
mu.Lock()
task.Ordering = true
task.LastOrderTime = time.Now()
mu.Unlock()
saveTasks()
log.Printf("[%s] 开始自动下单...", task.Name)
// 重试逻辑
maxRetries := 3
var lastResult *OrderResult
for attempt := 1; attempt <= maxRetries; attempt++ {
log.Printf("[%s] 第 %d/%d 次尝试下单", task.Name, attempt, maxRetries)
result := runOrder(task)
lastResult = result
if result.Success {
// 下单成功
mu.Lock()
task.Ordering = false
task.OrderSuccess = true
mu.Unlock()
saveTasks()
msg := fmt.Sprintf("✅ 自动下单成功!\n商品: %s\n订单号: %s\n价格: %s\n机房: %s\n截图: %s\n尝试次数: %d",
task.Name, result.OrderID, result.Price, result.Location, result.Screenshot, attempt)
notify("🎉 "+task.Name+" 下单成功", msg)
log.Printf("[%s] %s", task.Name, msg)
return
}
// 下单失败,判断是否需要重试
log.Printf("[%s] 第 %d 次尝试失败: %s", task.Name, attempt, result.Message)
// 判断失败原因,某些情况不需要重试
if shouldSkipRetry(result.Message) {
log.Printf("[%s] 失败原因不适合重试,停止", task.Name)
break
}
// 如果不是最后一次,等待后重试
if attempt < maxRetries {
waitTime := time.Duration(attempt*5) * time.Second // 递增等待5s, 10s, 15s
log.Printf("[%s] 等待 %v 后重试...", task.Name, waitTime)
time.Sleep(waitTime)
}
}
// 所有重试都失败
mu.Lock()
task.Ordering = false
mu.Unlock()
saveTasks()
msg := fmt.Sprintf("❌ 自动下单失败(已重试 %d 次)\n商品: %s\n最后错误: %s\n截图: %s",
maxRetries, task.Name, lastResult.Message, lastResult.Screenshot)
notify("⚠️ "+task.Name+" 下单失败", msg)
log.Printf("[%s] %s", task.Name, msg)
}
// 执行单次下单
func runOrder(task *Task) *OrderResult {
result := &OrderResult{Success: false}
// 检查配置文件是否存在
if task.OrderConfigPath == "" {
result.Message = "未配置下单配置文件"
return result
}
if _, err := os.Stat(task.OrderConfigPath); os.IsNotExist(err) {
result.Message = fmt.Sprintf("配置文件不存在: %s", task.OrderConfigPath)
return result
}
// 执行下单脚本
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "./auto_order", task.OrderConfigPath)
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
result.Message = "下单超时120秒"
return result
}
if err != nil {
result.Message = fmt.Sprintf("执行失败: %v, 输出: %s", err, string(output))
log.Printf("[%s] 命令执行错误: %v", task.Name, err)
log.Printf("[%s] 命令输出: %s", task.Name, string(output))
return result
}
// 解析结果
if err := json.Unmarshal(output, result); err != nil {
result.Message = fmt.Sprintf("解析结果失败: %v, 原始输出: %s", err, string(output))
log.Printf("[%s] JSON 解析错误: %v", task.Name, err)
log.Printf("[%s] 原始输出: %s", task.Name, string(output))
return result
}
return result
}
// 判断是否应该跳过重试
func shouldSkipRetry(message string) bool {
// 这些情况不需要重试
skipKeywords := []string{
"价格超出预算",
"机房不匹配",
"未配置",
"配置文件不存在",
"Cookie",
"认证失败",
}
messageLower := strings.ToLower(message)
for _, keyword := range skipKeywords {
if strings.Contains(messageLower, strings.ToLower(keyword)) {
return true
}
}
return false
}
func updateClient() {
configMu.RLock()
timeout := config.Timeout
configMu.RUnlock()
client = &http.Client{Timeout: time.Duration(timeout) * time.Second}
}
func saveJSON(filename string, v interface{}) {
os.MkdirAll("data", 0755)
data, _ := json.MarshalIndent(v, "", " ")
os.WriteFile("data/"+filename, data, 0644)
}
func loadTasks() {
data, err := os.ReadFile("data/tasks.json")
if err != nil {
return
}
var list []*Task
json.Unmarshal(data, &list)
mu.Lock()
for _, t := range list {
if t.NotifyEnabled == false && t.ID != "" {
t.NotifyEnabled = true
}
tasks[t.ID] = t
}
mu.Unlock()
}
func saveTasks() {
mu.RLock()
list := make([]*Task, 0, len(tasks))
for _, t := range tasks {
list = append(list, t)
}
mu.RUnlock()
saveJSON("tasks.json", list)
}
func loadConfig() {
data, err := os.ReadFile("data/config.json")
if err != nil {
return
}
configMu.Lock()
json.Unmarshal(data, config)
configMu.Unlock()
}
func saveConfig() {
configMu.RLock()
defer configMu.RUnlock()
saveJSON("config.json", config)
}