feat: 抖音直播间弹幕监控 - Go+Wails 实现

后端:
- 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)
- 底部版权栏
This commit is contained in:
2026-09-18 04:25:57 +08:00
commit e266d3098a
56 changed files with 29905 additions and 0 deletions

803
internal/api/server.go Normal file
View File

@@ -0,0 +1,803 @@
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")
}