927 lines
32 KiB
Go
927 lines
32 KiB
Go
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)
|
||
}
|