package api import ( "encoding/csv" "fmt" "io" "log" "net/http" "regexp" "strconv" "strings" "sync" "time" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/qingwuyingxing/douyin-live-fetcher/internal/broadcaster" "github.com/qingwuyingxing/douyin-live-fetcher/internal/db" "github.com/qingwuyingxing/douyin-live-fetcher/internal/fetcher" "github.com/qingwuyingxing/douyin-live-fetcher/internal/license" ) var ( fetchers = make(map[string]*fetcher.Fetcher) fetcherMu sync.RWMutex bc *broadcaster.Broadcaster startedAt = time.Now() ) func StartServer(b *broadcaster.Broadcaster, assets http.FileSystem, port int) error { bc = b e := echo.New() e.HideBanner = true e.Use(middleware.Recover()) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "OPTIONS"}, AllowHeaders: []string{"Content-Type"}, })) g := e.Group("/api") g.POST("/start", handleStart) g.POST("/stop", handleStop) g.POST("/del", handleDel) g.GET("/rooms", handleRooms) g.GET("/rooms_db", handleRoomsDB) g.POST("/rooms", handleAddRoom) g.GET("/messages", handleMessages) g.GET("/users", handleUsers) g.GET("/users_db", handleUsersDB) g.GET("/marks", handleMarksGet) g.POST("/mark", handleMarkSet) g.POST("/marks_clear", handleMarksClear) g.POST("/msg_seen", handleMsgSeen) g.POST("/msg_seen_bulk", handleMsgSeenBulk) g.POST("/msg_seen_clear", handleMsgSeenClear) g.POST("/lead", handleLead) g.GET("/leads", handleLeads) g.GET("/leads_export", handleLeadsExport) g.GET("/export", handleExport) g.GET("/keywords", handleKeywordsGet) g.POST("/keywords", handleKeywordsSet) g.GET("/resolve", handleResolve) g.GET("/status", handleStatus) g.GET("/stream", handleStream) g.GET("/errors", handleErrors) g.POST("/error", handleError) g.GET("/license", handleLicenseGet) g.POST("/license", handleLicensePost) g.POST("/license/logout", handleLicenseLogout) g.GET("/settings", handleSettingsGet) g.POST("/settings", handleSettingsPost) // 静态文件服务(直接读文件系统) e.GET("/", func(c echo.Context) error { return c.File("frontend/dist/index.html") }) e.Static("/assets", "frontend/dist/assets") go _autoWatch() go _licenseWatch() addr := fmt.Sprintf(":%d", port) log.Printf("[api] starting server on %s", addr) return e.Start(addr) } func serveFile(c echo.Context, fs http.FileSystem, name string) error { f, err := fs.Open(name) if err != nil { log.Printf("[serveFile] open %q failed: %v", name, err) return c.String(404, "not found") } defer f.Close() data, err := io.ReadAll(f) if err != nil { log.Printf("[serveFile] read %q failed: %v", name, err) return c.String(500, "read failed") } c.Response().Header().Set("Content-Type", http.DetectContentType(data)) c.Response().Header().Set("Cache-Control", "public, max-age=3600") c.Response().WriteHeader(200) _, err = c.Response().Write(data) if err != nil { log.Printf("[serveFile] write %q failed: %v", name, err) } return nil } // ─── 房间 ──────────────────────────────────────────────────── func handleStart(c echo.Context) error { if !license.LicenseAllowed(false) { return c.JSON(403, map[string]string{"error": "授权不可用"}) } var body struct { LiveID string `json:"live_id"` Keywords string `json:"keywords"` } if err := c.Bind(&body); err != nil { return c.JSON(400, map[string]string{"error": err.Error()}) } lid := resolveID(body.LiveID) if lid == "" { return c.JSON(400, map[string]string{"error": "无效直播间ID"}) } kws := body.Keywords if kws == "" { kws = db.GetSetting("kw_" + lid) } db.SaveRoom(lid, kws) startFetcher(lid, kws, "manual") return c.JSON(200, map[string]string{"status": "ok", "live_id": lid}) } func handleStop(c echo.Context) error { var body struct { LiveID string `json:"live_id"` } c.Bind(&body) lid := strings.TrimSpace(body.LiveID) fetcherMu.Lock() if lid == "all" { for id := range fetchers { stopFetcher(id) } fetcherMu.Unlock() return c.JSON(200, map[string]string{"status": "ok", "message": "Stopped all"}) } if lid != "" { stopFetcher(lid) } fetcherMu.Unlock() return c.JSON(200, map[string]string{"status": "ok"}) } func handleDel(c echo.Context) error { var body struct { LiveID string `json:"live_id"` } c.Bind(&body) lid := resolveID(body.LiveID) fetcherMu.Lock() stopFetcher(lid) fetcherMu.Unlock() db.DelRoom(lid) return c.JSON(200, map[string]string{"status": "ok"}) } func handleRooms(c echo.Context) error { fetcherMu.RLock() rooms := make([]map[string]interface{}, 0) for id, f := range fetchers { rooms = append(rooms, map[string]interface{}{ "live_id": id, "connected": f.Ok(), "silent": f.IsSilent(), "last_pkt_age": time.Since(time.Unix(int64(f.LastPkt()), 0)).Seconds(), "chatter_count": len(f.Users()), "keywords": f.Keywords(), }) } fetcherMu.RUnlock() return c.JSON(200, map[string]interface{}{"rooms": rooms}) } func handleRoomsDB(c echo.Context) error { return c.JSON(200, map[string]interface{}{"rooms": db.GetRooms()}) } func handleAddRoom(c echo.Context) error { var body struct { LiveID string `json:"live_id"` } c.Bind(&body) lid := resolveID(body.LiveID) if lid == "" { return c.JSON(400, map[string]string{"error": "无效的直播间ID"}) } db.SaveRoom(lid, "") if license.LicenseAllowed(false) { startFetcher(lid, db.GetSetting("kw_"+lid), "add-room") } return c.JSON(200, map[string]interface{}{"status": "ok", "live_id": lid}) } // ─── 消息 ──────────────────────────────────────────────────── func handleMessages(c echo.Context) error { lid := resolveID(c.QueryParam("live_id")) if lid == "" { return c.JSON(400, map[string]string{"error": "missing live_id"}) } after := parseInt64(c.QueryParam("after_id"), 0) limit := min(parseInt(c.QueryParam("limit"), 200), 500) msgs, last := db.GetMessages(lid, after, limit) return c.JSON(200, map[string]interface{}{"live_id": lid, "after_id": last, "messages": msgs}) } // ─── 用户 ──────────────────────────────────────────────────── func handleUsers(c echo.Context) error { lid := resolveID(c.QueryParam("live_id")) if lid == "" { return c.JSON(400, map[string]string{"error": "missing live_id"}) } fetcherMu.RLock() f := fetchers[lid] fetcherMu.RUnlock() if f != nil { return c.JSON(200, map[string]interface{}{"live_id": lid, "count": len(f.Users()), "users": f.Users()}) } users := db.GetUsers(lid) return c.JSON(200, map[string]interface{}{"live_id": lid, "count": len(users), "users": userMapList(users)}) } func handleUsersDB(c echo.Context) error { lid := resolveID(c.QueryParam("live_id")) if lid == "" { return c.JSON(400, map[string]string{"error": "missing live_id"}) } users := db.GetUsers(lid) return c.JSON(200, map[string]interface{}{"live_id": lid, "count": len(users), "users": userMapList(users)}) } // ─── 标记 / 已看 / 客户 ───────────────────────────────────── func handleMarksGet(c echo.Context) error { lid := resolveID(c.QueryParam("live_id")) if lid == "" { return c.JSON(400, map[string]string{"error": "missing live_id"}) } return c.JSON(200, map[string]interface{}{"live_id": lid, "marks": db.GetMarks(lid)}) } func handleMarkSet(c echo.Context) error { var body struct { LiveID string `json:"live_id"` UserID string `json:"user_id"` Mark string `json:"mark"` } c.Bind(&body) lid := resolveID(body.LiveID) if lid == "" || body.UserID == "" { return c.JSON(400, map[string]string{"error": "missing live_id/user_id"}) } valid := map[string]bool{"blue": true, "red": true, "yellow": true} mark := body.Mark if mark != "" && !valid[mark] { mark = "" } db.SetMark(lid, body.UserID, mark) fetcherMu.RLock() if f := fetchers[lid]; f != nil { f.SetMark(body.UserID, mark) } fetcherMu.RUnlock() return c.JSON(200, map[string]interface{}{"status": "ok", "user_id": body.UserID, "mark": mark}) } func handleMarksClear(c echo.Context) error { var body struct { LiveID string `json:"live_id"` } c.Bind(&body) lid := resolveID(body.LiveID) if lid == "" { return c.JSON(400, map[string]string{"error": "missing live_id"}) } db.ClearMarks(lid) fetcherMu.RLock() if f := fetchers[lid]; f != nil { f.ClearMarks() } fetcherMu.RUnlock() return c.JSON(200, map[string]interface{}{"status": "ok"}) } func handleMsgSeen(c echo.Context) error { var body struct { LiveID string `json:"live_id"` MsgID int64 `json:"msg_id"` Seen bool `json:"seen"` } c.Bind(&body) lid := resolveID(body.LiveID) if lid == "" || body.MsgID == 0 { return c.JSON(400, map[string]string{"error": "missing live_id/msg_id"}) } n := db.SetMsgSeen(lid, body.MsgID, body.Seen) return c.JSON(200, map[string]interface{}{"status": "ok", "updated": n}) } func handleMsgSeenBulk(c echo.Context) error { var body struct { LiveID string `json:"live_id"` IDs []int64 `json:"ids"` Seen bool `json:"seen"` } c.Bind(&body) lid := resolveID(body.LiveID) if lid == "" || len(body.IDs) == 0 { return c.JSON(400, map[string]string{"error": "missing live_id/ids"}) } n := db.SetMsgSeenMany(lid, body.IDs, body.Seen) return c.JSON(200, map[string]interface{}{"status": "ok", "updated": n}) } func handleMsgSeenClear(c echo.Context) error { var body struct { LiveID string `json:"live_id"` } c.Bind(&body) lid := resolveID(body.LiveID) if lid == "" { return c.JSON(400, map[string]string{"error": "missing live_id"}) } n := db.ClearMsgSeen(lid) return c.JSON(200, map[string]interface{}{"status": "ok", "cleared": n}) } func handleLead(c echo.Context) error { var body struct { LiveID string `json:"live_id"` UserID string `json:"user_id"` Lead bool `json:"lead"` } c.Bind(&body) lid := resolveID(body.LiveID) if lid == "" || body.UserID == "" { return c.JSON(400, map[string]string{"error": "missing live_id/user_id"}) } db.SetLead(lid, body.UserID, body.Lead) fetcherMu.RLock() if f := fetchers[lid]; f != nil { f.SetLead(body.UserID, body.Lead) } fetcherMu.RUnlock() return c.JSON(200, map[string]interface{}{"status": "ok", "lead": body.Lead}) } func handleLeads(c echo.Context) error { lid := resolveID(c.QueryParam("live_id")) leads := db.GetLeads(lid) return c.JSON(200, map[string]interface{}{"live_id": lid, "count": len(leads), "leads": userMapList(leads)}) } func handleLeadsExport(c echo.Context) error { lid := resolveID(c.QueryParam("live_id")) leads := db.GetLeads(lid) buf := new(strings.Builder) w := csv.NewWriter(buf) w.Write([]string{"live_id", "user_id", "nickname", "profile_url", "mark", "last_msg", "msg_count", "gift_count", "enter_count", "like_count", "follow_count", "lead_time", "last_seen"}) for _, u := range leads { profileURL := "" if u.SecUID != "" { profileURL = "https://www.douyin.com/user/" + u.SecUID } w.Write([]string{ u.LiveID, u.UserID, u.UserName, profileURL, u.Mark, u.LastMsg, strconv.Itoa(u.MsgCount), strconv.Itoa(u.GiftCount), strconv.Itoa(u.EnterCount), strconv.Itoa(u.LikeCount), strconv.Itoa(u.FollowCount), formatTS(u.LeadTs), formatTS(u.LastSeen), }) } w.Flush() resp := c.Response() resp.Header().Set("Content-Type", "text/csv; charset=utf-8") resp.Header().Set("Content-Disposition", "attachment; filename=leads.csv") resp.Write([]byte("\ufeff" + buf.String())) return nil } func handleExport(c echo.Context) error { lid := resolveID(c.QueryParam("live_id")) fmtType := c.QueryParam("fmt") if lid == "" { return c.JSON(400, map[string]string{"error": "missing live_id"}) } fetcherMu.RLock() var users []map[string]interface{} if f := fetchers[lid]; f != nil { users = f.Users() } fetcherMu.RUnlock() if len(users) == 0 { users = userMapList(db.GetUsers(lid)) } if fmtType == "json" { return c.JSON(200, users) } buf := new(strings.Builder) w := csv.NewWriter(buf) w.Write([]string{"user_id", "nickname", "msg_count", "gift_count", "enter_count", "like_count", "follow_count", "last_msg", "hit_keyword", "mark", "lead", "last_action", "sec_uid", "profile_url"}) for _, u := range users { suid := strVal(u, "sec_uid") profileURL := "" if suid != "" { profileURL = "https://www.douyin.com/user/" + suid } w.Write([]string{ strVal(u, "user_id"), strVal(u, "user_name"), strVal(u, "msg_count"), strVal(u, "gift_count"), strVal(u, "enter_count"), strVal(u, "like_count"), strVal(u, "follow_count"), strVal(u, "last_msg"), strVal(u, "hit_kw"), strVal(u, "mark"), func() string { if boolVal(u, "lead") { return "精准客户" }; return "" }(), strVal(u, "last_action"), suid, profileURL, }) } w.Flush() resp := c.Response() resp.Header().Set("Content-Type", "text/csv; charset=utf-8") resp.Header().Set("Content-Disposition", "attachment; filename=users_"+lid+".csv") resp.Write([]byte("\ufeff" + buf.String())) return nil } // ─── 关键词 / 解析 ─────────────────────────────────────────── func handleKeywordsGet(c echo.Context) error { lid := resolveID(c.QueryParam("live_id")) if lid == "" { return c.JSON(400, map[string]string{"error": "missing live_id"}) } return c.JSON(200, map[string]interface{}{"live_id": lid, "keywords": db.GetSetting("kw_" + lid)}) } func handleKeywordsSet(c echo.Context) error { var body struct { LiveID string `json:"live_id"` Keywords string `json:"keywords"` } c.Bind(&body) lid := resolveID(body.LiveID) if lid == "" { return c.JSON(400, map[string]string{"error": "missing live_id"}) } db.SetSetting("kw_"+lid, body.Keywords) fetcherMu.RLock() if f := fetchers[lid]; f != nil { f.SetKeywords(body.Keywords) } fetcherMu.RUnlock() return c.JSON(200, map[string]string{"status": "ok"}) } func handleResolve(c echo.Context) error { lid := resolveID(c.QueryParam("input")) if lid == "" { return c.JSON(400, map[string]string{"error": "无法识别为抖音直播间,请输入直播间ID或完整分享链接"}) } return c.JSON(200, map[string]string{"live_id": lid}) } // ─── 状态 / SSE ───────────────────────────────────────────── func handleStatus(c echo.Context) error { fetcherMu.RLock() rooms := make([]map[string]interface{}, 0) for id, f := range fetchers { rooms = append(rooms, map[string]interface{}{ "live_id": id, "connected": f.Ok(), "last_pkt_age": time.Since(time.Unix(int64(f.LastPkt()), 0)).Seconds(), "silent": f.IsSilent(), "chatter_count": len(f.Users()), "keywords": f.Keywords(), }) } fetcherMu.RUnlock() ts := license.AppLicenseState(false) note, _ := ts["note"].(string) if note == "" { note = "登录订阅账号以解锁全部功能" } return c.JSON(200, map[string]interface{}{ "mode": func() string { if db.GetSetting("mode") == "debug" { return "debug" }; return "release" }(), "version": "1.1.0", "uptime": time.Since(startedAt).Seconds(), "log_enabled": db.GetSetting("log_enabled") == "1", "rooms": rooms, "license": ts, "note": note, }) } func handleStream(c echo.Context) error { ch := bc.Register() c.Response().Header().Set("Content-Type", "text/event-stream") c.Response().Header().Set("Cache-Control", "no-cache") c.Response().Header().Set("Connection", "keep-alive") flusher, _ := c.Response().Writer.(http.Flusher) done := c.Request().Context().Done() for { select { case <-done: bc.Unregister(ch) return nil case data := <-ch: fmt.Fprintf(c.Response().Writer, "data: %s\n\n", string(data)) if flusher != nil { flusher.Flush() } } } } // ─── 错误日志 ──────────────────────────────────────────────── func handleErrors(c echo.Context) error { limit := parseInt(c.QueryParam("limit"), 100) return c.JSON(200, map[string]interface{}{"errors": db.GetErrors(limit)}) } func handleError(c echo.Context) error { var body struct { Message string `json:"message"` Stack string `json:"stack"` RoomID string `json:"room_id"` } c.Bind(&body) msg := body.Message if len(msg) > 2000 { msg = msg[:2000] } stack := body.Stack if len(stack) > 8000 { stack = stack[:8000] } db.LogError("frontend", body.RoomID, msg, stack) return c.JSON(200, map[string]string{"status": "ok"}) } // ─── 授权 ──────────────────────────────────────────────────── func handleLicenseGet(c echo.Context) error { return c.JSON(200, map[string]interface{}{ "license": license.AppLicenseState(true), "config": license.LicenseConfig(), }) } func handleLicensePost(c echo.Context) error { var body struct { ServerUrl string `json:"server_url"` CustomerCode string `json:"customer_code"` ProductCode string `json:"product_code"` AccountToken string `json:"account_token"` Login string `json:"login"` Password string `json:"password"` } c.Bind(&body) if body.Login != "" && body.Password != "" { data, err := license.LoginAccount(body.ServerUrl, body.Login, body.Password) if err != nil { return c.JSON(400, map[string]interface{}{"status": "error", "message": err.Error(), "license": license.AppLicenseState(false)}) } return c.JSON(200, map[string]interface{}{"status": "ok", "account": data, "license": license.CheckCloudLicense(true), "config": license.LicenseConfig()}) } license.SaveLicenseConfig(body.ServerUrl, body.CustomerCode, body.ProductCode, body.AccountToken) return c.JSON(200, map[string]interface{}{"status": "ok", "license": license.CheckCloudLicense(true), "config": license.LicenseConfig()}) } func handleLicenseLogout(c echo.Context) error { license.ClearLicenseLogin() return c.JSON(200, map[string]interface{}{"status": "ok", "license": license.AppLicenseState(true), "config": license.LicenseConfig()}) } // ─── 设置 ──────────────────────────────────────────────────── func handleSettingsGet(c echo.Context) error { return c.JSON(200, map[string]interface{}{ "log_enabled": db.GetSetting("log_enabled") == "1", "mode": db.GetSetting("mode"), }) } func handleSettingsPost(c echo.Context) error { var body struct { LogEnabled *bool `json:"log_enabled"` Mode string `json:"mode"` } c.Bind(&body) if body.LogEnabled != nil { db.SetSetting("log_enabled", boolStr(*body.LogEnabled)) } if body.Mode == "debug" || body.Mode == "release" { db.SetSetting("mode", body.Mode) } return c.JSON(200, map[string]string{"status": "ok"}) } // ─── 内部 helper ───────────────────────────────────────────── func startFetcher(lid, keywords, reason string) { fetcherMu.Lock() defer fetcherMu.Unlock() if f, ok := fetchers[lid]; ok { if f.ThreadAlive() { log.Printf("[api] room %s already running", lid) return } f.Stop() delete(fetchers, lid) } if keywords == "" { keywords = db.GetSetting("kw_" + lid) } log.Printf("[api] start room=%s kw=[%s] reason=%s", lid, keywords, reason) bc.Broadcast(map[string]interface{}{"type": "status", "room_id": lid, "content": "connecting", "timestamp": float64(time.Now().Unix())}) f := fetcher.NewFetcher(lid, bc, keywords) fetchers[lid] = f go f.Start() } func stopFetcher(lid string) { fetcherMu.Lock() f := fetchers[lid] delete(fetchers, lid) fetcherMu.Unlock() if f != nil { f.Stop() } } func _autoWatch() { for { time.Sleep(15 * time.Second) if !license.LicenseAllowed(false) { continue } rooms := db.GetRooms() fetcherMu.Lock() for _, r := range rooms { lid := r.LiveID f := fetchers[lid] if f == nil { startFetcher(lid, db.GetSetting("kw_"+lid), "auto-start") continue } alive := f.ThreadAlive() ok := f.Ok() age := time.Since(time.Unix(int64(f.LastPkt()), 0)).Seconds() silent := f.IsSilent() if alive && ok && !silent && age <= 90 { continue } log.Printf("[auto-watch] room %s reconnect: age=%.1fs", lid, age) stopFetcher(lid) startFetcher(lid, db.GetSetting("kw_"+lid), "auto-reconnect") } for lid := range fetchers { found := false for _, r := range rooms { if r.LiveID == lid { found = true break } } if !found { stopFetcher(lid) } } fetcherMu.Unlock() } } func _licenseWatch() { for { time.Sleep(30 * time.Second) if !license.LicenseAllowed(false) && len(fetchers) > 0 { fetcherMu.Lock() for id := range fetchers { stopFetcher(id) } fetcherMu.Unlock() log.Printf("[license] 授权不可用,全部直播间已停止") } } } func resolveID(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if allDigits(raw) { return raw } re1 := regexp.MustCompile(`live\.douyin\.com/(\d+)`) if m := re1.FindStringSubmatch(raw); len(m) > 1 { return m[1] } re2 := regexp.MustCompile(`webcast\.amemv\.com/\w+/\w+/reflow/(\d+)`) if m := re2.FindStringSubmatch(raw); len(m) > 1 { return m[1] } re3 := regexp.MustCompile(`https?://\S+`) for _, u := range re3.FindAllString(raw, -1) { if m := re1.FindStringSubmatch(u); len(m) > 1 { return m[1] } if m := re2.FindStringSubmatch(u); len(m) > 1 { return m[1] } } if allDigits(raw) { return raw } return "" } func allDigits(s string) bool { if s == "" { return false } for _, c := range s { if c < '0' || c > '9' { return false } } return true } func userMapList(users []db.User) []map[string]interface{} { out := make([]map[string]interface{}, len(users)) for i, u := range users { out[i] = map[string]interface{}{ "id": u.ID, "live_id": u.LiveID, "user_id": u.UserID, "user_name": u.UserName, "first_seen": u.FirstSeen, "last_seen": u.LastSeen, "msg_count": u.MsgCount, "gift_count": u.GiftCount, "enter_count": u.EnterCount, "like_count": u.LikeCount, "follow_count": u.FollowCount, "last_msg": u.LastMsg, "last_action": u.LastAction, "hit_kw": u.HitKw, "sec_uid": u.SecUID, "avatar": u.Avatar, "mark": u.Mark, "lead": u.Lead, "lead_ts": u.LeadTs, } } return out } func strVal(m map[string]interface{}, k string) string { v, ok := m[k] if !ok { return "" } s, _ := v.(string) return s } func boolVal(m map[string]interface{}, k string) bool { v, ok := m[k] if !ok { return false } b, _ := v.(bool) return b } func parseInt(s string, def int) int { n, err := strconv.Atoi(s) if err != nil { return def } return n } func parseInt64(s string, def int64) int64 { n, err := strconv.ParseInt(s, 10, 64) if err != nil { return def } return n } func boolStr(b bool) string { if b { return "1" } return "0" } func formatTS(ts float64) string { if ts == 0 { return "" } return time.Unix(int64(ts), 0).Format("2006-01-02 15:04") }