后端: - SQLite 数据层 (rooms/users/chat_logs/settings/error_logs) - 抖音 WebSocket 抓取器 (proto 解析, 心跳, watchdog) - __ac_signature 算法 (纯 Go 移植) - a_bogus/sign.js 签名 (goja JS 引擎) - Echo HTTP API 服务器 (端口 5000) - SSE 事件广播 - 自动保活 + 授权守护 - PAYS 云授权对接 前端: - 顶栏: 品牌 + 日志/调试/客户名单/授权/文档/房间控制/主题切换 - 添加直播间 (链接解析 + 关键词) - Dashboard (卡片/列表视图, 公开/匿名房筛选, 分页) - 详情页 (多 tab 消息流, 右侧用户面板, 颜色标记, 精准客户, 已看标记) - 精准客户名单 (跨房间汇总) - 订阅授权页面 (登录/注册/购买) - 使用帮助 + 系统调试 - 白昼/黑夜主题切换 (CSS 变量 + iconfont) - 底部版权栏
804 lines
23 KiB
Go
804 lines
23 KiB
Go
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")
|
||
}
|