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 := `
库存监控
`
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)
}