diff --git a/main.go b/main.go
index bf4e8f7..e201aa8 100644
--- a/main.go
+++ b/main.go
@@ -1,573 +1,600 @@
-// 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"`
-}
-
-type Config struct {
- GotifyURL string `json:"gotify_url"`
- GotifyToken string `json:"gotify_token"`
- Interval int `json:"interval"`
- Timeout int `json:"timeout"`
-}
-
-var (
- tasks = make(map[string]*Task)
- config = &Config{Interval: 60, Timeout: 20}
- 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))
-
- 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)
- if task.ID == "" {
- task.ID = time.Now().Format("20060102150405")
- }
-
- 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) {
- task.Status = "checking"
-
- 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)
- }
-
- mu.Lock()
- wasInStock := task.InStock
- task.Status = status
- task.InStock = inStock
- task.LastCheck = now
- mu.Unlock()
-
- if inStock && !wasInStock {
- notify(task.Name + " 有货了!", task.URL)
- }
-}
-
-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() {
- payload := fmt.Sprintf(`{"title":"%s","message":"%s","priority":10}`, title, msg)
- http.Post(url+"/message?token="+token, "application/json", strings.NewReader(payload))
- }()
-}
-
-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)
-}
+// 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"`
+}
+
+type Config struct {
+ GotifyURL string `json:"gotify_url"`
+ GotifyToken string `json:"gotify_token"`
+ Interval int `json:"interval"`
+ Timeout int `json:"timeout"`
+}
+
+var (
+ tasks = make(map[string]*Task)
+ config = &Config{Interval: 60, Timeout: 20}
+ 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)
+ if task.ID == "" {
+ task.ID = time.Now().Format("20060102150405")
+ }
+
+ 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) {
+ task.Status = "checking"
+
+ 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)
+ }
+
+ mu.Lock()
+ wasInStock := task.InStock
+ task.Status = status
+ task.InStock = inStock
+ task.LastCheck = now
+ mu.Unlock()
+
+ if inStock && !wasInStock {
+ notify(task.Name + " 有货了!", task.URL)
+ }
+}
+
+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) {
+ notify("测试通知", "Gotify 配置正常")
+ 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)
+}