// main.go package main import ( "context" "encoding/json" "html/template" "io" "log" "fmt" "math/rand" "net/http" "os" "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"` History []HistoryItem `json:"history"` } 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"` } 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 ) 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/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() defer mu.RUnlock() list := make([]*Task, 0, len(tasks)) for _, t := range tasks { list = append(list, t) } 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.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 } mu.RUnlock() } mu.Lock() tasks[task.ID] = &task 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 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() notifyEnabled := config.NotifyEnabled configMu.RUnlock() if notifyEnabled && inStock && !wasInStock && !wasNotified { notify(task.Name + " 有货了!", task.URL) 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 updateClient() { configMu.RLock() timeout := config.Timeout configMu.RUnlock() client = &http.Client{Timeout: time.Duration(timeout) * time.Second} } func saveJSON(filename string, v interface{}) { data, _ := json.MarshalIndent(v, "", " ") os.WriteFile(filename, data, 0644) } func loadTasks() { data, err := os.ReadFile("tasks.json") if err != nil { return } var list []*Task json.Unmarshal(data, &list) mu.Lock() for _, t := range list { 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("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) }