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

581
internal/db/db.go Normal file
View File

@@ -0,0 +1,581 @@
package db
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
var (
dbPath string
mu sync.Mutex
sqlDB *sql.DB
)
type Room struct {
LiveID string `json:"live_id"`
Keywords string `json:"keywords"`
CreatedAt float64 `json:"created_at"`
}
type User struct {
ID int64 `json:"id"`
LiveID string `json:"live_id"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
FirstSeen float64 `json:"first_seen"`
LastSeen float64 `json:"last_seen"`
MsgCount int `json:"msg_count"`
GiftCount int `json:"gift_count"`
EnterCount int `json:"enter_count"`
LikeCount int `json:"like_count"`
FollowCount int `json:"follow_count"`
LastMsg string `json:"last_msg"`
LastAction string `json:"last_action"`
HitKw string `json:"hit_kw"`
SecUID string `json:"sec_uid"`
Avatar string `json:"avatar"`
Mark string `json:"mark"`
Lead int `json:"lead"`
LeadTs float64 `json:"lead_ts"`
}
type ChatLog struct {
ID int64 `json:"id"`
LiveID string `json:"live_id"`
Msg string `json:"msg"`
Seen int `json:"seen"`
}
type ErrorLog struct {
ID int64 `json:"id"`
TS float64 `json:"ts"`
Source string `json:"source"`
RoomID string `json:"room_id"`
Message string `json:"message"`
Stack string `json:"stack"`
}
func init() {
if exePath, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exePath), "data.db")
if dir := filepath.Dir(candidate); writable(dir) {
dbPath = candidate
return
}
}
appData := os.Getenv("APPDATA")
if appData == "" {
appData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Roaming")
}
dbPath = filepath.Join(appData, "青梧映星", "douyinlive", "data.db")
}
func writable(d string) bool {
if err := os.MkdirAll(d, 0755); err != nil {
return false
}
f, err := os.CreateTemp(d, ".wtest")
if err != nil {
return false
}
f.Close()
os.Remove(f.Name())
return true
}
func conn() (*sql.DB, error) {
if sqlDB != nil {
return sqlDB, nil
}
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
var err error
sqlDB, err = sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(4)
sqlDB.SetMaxIdleConns(2)
return sqlDB, nil
}
func InitDB() error {
mu.Lock()
defer mu.Unlock()
c, err := conn()
if err != nil {
return err
}
return ensureMigrated(c)
}
func ensureMigrated(c *sql.DB) error {
_, err := c.Exec(`
CREATE TABLE IF NOT EXISTS rooms(live_id TEXT PRIMARY KEY, keywords TEXT DEFAULT NULL, created_at REAL);
CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT, live_id TEXT, user_id TEXT, user_name TEXT, first_seen REAL, last_seen REAL, msg_count INTEGER DEFAULT 0, gift_count INTEGER DEFAULT 0, enter_count INTEGER DEFAULT 0, like_count INTEGER DEFAULT 0, follow_count INTEGER DEFAULT 0, last_msg TEXT, last_action TEXT, hit_kw TEXT, sec_uid TEXT DEFAULT '', avatar TEXT DEFAULT '', mark TEXT DEFAULT '', lead INTEGER DEFAULT 0, lead_ts REAL DEFAULT 0, UNIQUE(live_id, user_id));
CREATE TABLE IF NOT EXISTS error_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL, source TEXT, room_id TEXT, message TEXT, stack TEXT);
CREATE INDEX IF NOT EXISTS idx_el ON error_logs(ts);
CREATE TABLE IF NOT EXISTS settings(k TEXT PRIMARY KEY, v TEXT);
CREATE TABLE IF NOT EXISTS chat_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, live_id TEXT, ts REAL, msg TEXT, seen INTEGER DEFAULT 0);
CREATE INDEX IF NOT EXISTS idx_cl ON chat_logs(live_id, id);
CREATE INDEX IF NOT EXISTS idx_ul ON users(live_id);
CREATE INDEX IF NOT EXISTS idx_un ON users(user_name);
`)
if err != nil {
return err
}
return migrateUsers(c)
}
func migrateUsers(c *sql.DB) error {
var cnt int
c.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'").Scan(&cnt)
if cnt == 0 {
return nil
}
for col, ddl := range map[string]string{
"sec_uid": "TEXT DEFAULT ''",
"avatar": "TEXT DEFAULT ''",
"mark": "TEXT DEFAULT ''",
"lead": "INTEGER DEFAULT 0",
"lead_ts": "REAL DEFAULT 0",
} {
var n int
c.QueryRow("SELECT COUNT(*) FROM pragma_table_info('users') WHERE name=?", col).Scan(&n)
if n == 0 {
c.Exec(fmt.Sprintf("ALTER TABLE users ADD COLUMN %s %s", col, ddl))
}
}
var n int
c.QueryRow("SELECT COUNT(*) FROM pragma_table_info('chat_logs') WHERE name='seen'").Scan(&n)
if n == 0 {
c.Exec("ALTER TABLE chat_logs ADD COLUMN seen INTEGER DEFAULT 0")
}
return nil
}
// SaveRoom records a room (idempotent on live_id).
func SaveRoom(liveID, keywords string) {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
c.Exec("INSERT OR IGNORE INTO rooms(live_id, keywords, created_at) VALUES(?,?,?)",
liveID, keywords, float64(time.Now().Unix()))
}
// GetRooms returns all rooms from DB.
func GetRooms() []Room {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
rows, err := c.Query("SELECT live_id, keywords, created_at FROM rooms ORDER BY created_at ASC")
if err != nil {
return nil
}
defer rows.Close()
var out []Room
for rows.Next() {
var r Room
rows.Scan(&r.LiveID, &r.Keywords, &r.CreatedAt)
out = append(out, r)
}
return out
}
// DelRoom removes a room and its associated users/chat_logs.
func DelRoom(liveID string) {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
c.Exec("DELETE FROM users WHERE live_id=?", liveID)
c.Exec("DELETE FROM rooms WHERE live_id=?", liveID)
c.Exec("DELETE FROM chat_logs WHERE live_id=?", liveID)
}
// GetUsers returns users for a room.
func GetUsers(liveID string) []User {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
rows, err := c.Query(
`SELECT id,live_id,user_id,user_name,first_seen,last_seen,
msg_count,gift_count,enter_count,like_count,follow_count,
last_msg,last_action,hit_kw,sec_uid,avatar,mark,lead,lead_ts
FROM users WHERE live_id=? ORDER BY (msg_count*10+gift_count*20+enter_count+like_count+follow_count) DESC`,
liveID)
if err != nil {
return nil
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
rows.Scan(&u.ID, &u.LiveID, &u.UserID, &u.UserName, &u.FirstSeen, &u.LastSeen,
&u.MsgCount, &u.GiftCount, &u.EnterCount, &u.LikeCount, &u.FollowCount,
&u.LastMsg, &u.LastAction, &u.HitKw, &u.SecUID, &u.Avatar, &u.Mark, &u.Lead, &u.LeadTs)
out = append(out, u)
}
return out
}
// UpsertUser creates or updates a user row.
func UpsertUser(liveID, uid, uname, action string, extra map[string]string) {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
now := float64(time.Now().Unix())
var id int64
var mc, gc, ec, lc, fc int
err := c.QueryRow("SELECT id,msg_count,gift_count,enter_count,like_count,follow_count FROM users WHERE live_id=? AND user_id=?", liveID, uid).Scan(&id, &mc, &gc, &ec, &lc, &fc)
if err == nil {
switch action {
case "chat":
mc++
case "gift":
gc++
case "enter":
ec++
case "like":
lc++
case "follow":
fc++
}
q := "'`"
sets := []string{fmt.Sprintf("last_seen=%g", now), fmt.Sprintf("last_action=%s%s%s", q, action, q)}
if action == "chat" {
cm := escapeSQL(extra["content"])
kw := escapeSQL(extra["keyword"])
sets = append(sets, fmt.Sprintf("msg_count=%d", mc))
sets = append(sets, fmt.Sprintf("last_msg=%s%s%s", q, cm, q))
sets = append(sets, fmt.Sprintf("hit_kw=%s%s%s", q, kw, q))
} else if action == "gift" {
sets = append(sets, fmt.Sprintf("gift_count=%d", gc))
} else if action == "enter" {
sets = append(sets, fmt.Sprintf("enter_count=%d", ec))
} else if action == "like" {
sets = append(sets, fmt.Sprintf("like_count=%d", lc))
} else if action == "follow" {
sets = append(sets, fmt.Sprintf("follow_count=%d", fc))
}
for _, col := range []string{"sec_uid", "avatar"} {
if v := extra[col]; v != "" {
sets = append(sets, fmt.Sprintf("%s=%s%s%s", col, q, escapeSQL(v), q))
}
}
c.Exec(fmt.Sprintf("UPDATE users SET %s WHERE id=%d", joinStrings(sets, ","), id))
} else {
cols := []string{"live_id", "user_id", "user_name", "first_seen", "last_seen", "last_action"}
vs := []interface{}{liveID, uid, uname, now, now, action}
for _, col := range []string{"sec_uid", "avatar"} {
if v := extra[col]; v != "" {
cols = append(cols, col)
vs = append(vs, v)
}
}
ps := make([]string, len(cols))
for i := range ps {
ps[i] = "?"
}
c.Exec(fmt.Sprintf("INSERT INTO users(%s) VALUES(%s)", joinStrings(cols, ","), joinStrings(ps, ",")), vs...)
}
}
func escapeSQL(s string) string {
return `''` + s + `''`
}
func joinStrings(ss []string, sep string) string {
if len(ss) == 0 {
return ""
}
out := ss[0]
for i := 1; i < len(ss); i++ {
out += sep + ss[i]
}
return out
}
// SetMark assigns a color/mark to a user.
func SetMark(liveID, userID, mark string) {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
c.Exec("UPDATE users SET mark=? WHERE live_id=? AND user_id=?", mark, liveID, userID)
if mark != "" {
var id int64
c.QueryRow("SELECT id FROM users WHERE live_id=? AND user_id=?", liveID, userID).Scan(&id)
if id == 0 {
now := float64(time.Now().Unix())
c.Exec("INSERT OR IGNORE INTO users(live_id,user_id,user_name,first_seen,last_seen,mark) VALUES(?,?,?,?,?,?)",
liveID, userID, "", now, now, mark)
}
}
}
// ClearMarks resets all marks for a room.
func ClearMarks(liveID string) {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
c.Exec("UPDATE users SET mark='' WHERE live_id=? AND mark!=''", liveID)
}
// GetMarks returns {user_id: mark} for a room.
func GetMarks(liveID string) map[string]string {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
rows, err := c.Query("SELECT user_id, mark FROM users WHERE live_id=? AND mark IS NOT NULL AND mark!=''", liveID)
if err != nil {
return nil
}
defer rows.Close()
out := make(map[string]string)
for rows.Next() {
var uid, m string
rows.Scan(&uid, &m)
out[uid] = m
}
return out
}
// SetLead marks/unmarks a lead customer.
func SetLead(liveID, userID string, lead bool) {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
now := float64(time.Now().Unix())
var ll int
var lts float64
if lead {
ll = 1
lts = now
}
c.Exec("UPDATE users SET lead=?, lead_ts=? WHERE live_id=? AND user_id=?", ll, lts, liveID, userID)
if lead {
var id int64
c.QueryRow("SELECT id FROM users WHERE live_id=? AND user_id=?", liveID, userID).Scan(&id)
if id == 0 {
c.Exec("INSERT OR IGNORE INTO users(live_id,user_id,user_name,first_seen,last_seen,lead,lead_ts) VALUES(?,?,?,?,?,?,?)",
liveID, userID, "", now, now, 1, now)
}
}
}
// GetLeads returns lead customers, optionally scoped to one room.
func GetLeads(liveID string) []User {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
var rows *sql.Rows
var err error
if liveID != "" {
rows, err = c.Query("SELECT id,live_id,user_id,user_name,first_seen,last_seen,msg_count,gift_count,enter_count,like_count,follow_count,last_msg,last_action,hit_kw,sec_uid,avatar,mark,lead,lead_ts FROM users WHERE live_id=? AND lead=1 ORDER BY lead_ts DESC", liveID)
} else {
rows, err = c.Query("SELECT id,live_id,user_id,user_name,first_seen,last_seen,msg_count,gift_count,enter_count,like_count,follow_count,last_msg,last_action,hit_kw,sec_uid,avatar,mark,lead,lead_ts FROM users WHERE lead=1 ORDER BY lead_ts DESC")
}
if err != nil {
return nil
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
rows.Scan(&u.ID, &u.LiveID, &u.UserID, &u.UserName, &u.FirstSeen, &u.LastSeen,
&u.MsgCount, &u.GiftCount, &u.EnterCount, &u.LikeCount, &u.FollowCount,
&u.LastMsg, &u.LastAction, &u.HitKw, &u.SecUID, &u.Avatar, &u.Mark, &u.Lead, &u.LeadTs)
out = append(out, u)
}
return out
}
var storeTypes = map[string]bool{
"chat": true, "gift": true, "enter": true, "like": true,
"follow": true, "fansclub": true, "emoji_chat": true,
}
// StoreMsg persists a user-interaction message.
func StoreMsg(liveID, msgType string, data map[string]interface{}) {
if !storeTypes[msgType] {
return
}
mu.Lock()
defer mu.Unlock()
c, _ := conn()
ts := 0.0
if v, ok := data["timestamp"]; ok {
switch x := v.(type) {
case float64:
ts = x
case int:
ts = float64(x)
case int64:
ts = float64(x)
default:
ts = float64(time.Now().Unix())
}
}
if ts == 0 {
ts = float64(time.Now().Unix())
}
payload := map[string]interface{}{"type": msgType, "room_id": liveID, "timestamp": ts}
for k, v := range data {
if k != "type" && k != "room_id" && k != "timestamp" {
payload[k] = v
}
}
b, _ := json.Marshal(payload)
c.Exec("INSERT INTO chat_logs(live_id, ts, msg) VALUES(?,?,?)", liveID, ts, string(b))
}
// GetMessages returns messages for a room with pagination.
func GetMessages(liveID string, afterID int64, limit int) ([]ChatLog, int64) {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
rows, err := c.Query("SELECT id, msg, seen FROM chat_logs WHERE live_id=? AND id>? ORDER BY id ASC LIMIT ?", liveID, afterID, limit)
if err != nil {
return nil, afterID
}
defer rows.Close()
var out []ChatLog
var lastID = afterID
for rows.Next() {
var cl ChatLog
rows.Scan(&cl.ID, &cl.Msg, &cl.Seen)
out = append(out, cl)
lastID = cl.ID
}
return out, lastID
}
// SetMsgSeen marks a single message as seen.
func SetMsgSeen(liveID string, msgID int64, seen bool) int {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
v := 0
if seen {
v = 1
}
res, err := c.Exec("UPDATE chat_logs SET seen=? WHERE id=? AND live_id=?", v, msgID, liveID)
if err != nil {
return 0
}
n, _ := res.RowsAffected()
return int(n)
}
// SetMsgSeenMany bulk marks messages as seen.
func SetMsgSeenMany(liveID string, ids []int64, seen bool) int {
if len(ids) == 0 {
return 0
}
mu.Lock()
defer mu.Unlock()
c, _ := conn()
if len(ids) > 2000 {
ids = ids[:2000]
}
ph := make([]string, len(ids))
vs := make([]interface{}, len(ids)+2)
vs[0] = btoi(seen)
vs[1] = liveID
for i, id := range ids {
ph[i] = "?"
vs[i+2] = id
}
res, err := c.Exec(fmt.Sprintf("UPDATE chat_logs SET seen=? WHERE live_id=? AND id IN (%s)", joinStrings(ph, ",")), vs...)
if err != nil {
return 0
}
n, _ := res.RowsAffected()
return int(n)
}
// ClearMsgSeen resets all seen flags for a room.
func ClearMsgSeen(liveID string) int {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
res, err := c.Exec("UPDATE chat_logs SET seen=0 WHERE live_id=? AND seen=1", liveID)
if err != nil {
return 0
}
n, _ := res.RowsAffected()
return int(n)
}
// GetSetting reads a setting value.
func GetSetting(key string) string {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
var v string
c.QueryRow("SELECT v FROM settings WHERE k=?", key).Scan(&v)
return v
}
// SetSetting writes a setting value.
func SetSetting(key, val string) {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
c.Exec("INSERT OR REPLACE INTO settings(k,v) VALUES(?,?)", key, val)
}
// LogError stores an error log entry.
func LogError(source, roomID, message, stack string) {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
c.Exec("INSERT INTO error_logs(ts, source, room_id, message, stack) VALUES(?,?,?,?,?)",
float64(time.Now().Unix()),
truncStr(source, 20),
truncStr(roomID, 40),
truncStr(message, 2000),
truncStr(stack, 8000),
)
}
// GetErrors returns recent error logs.
func GetErrors(limit int) []ErrorLog {
mu.Lock()
defer mu.Unlock()
c, _ := conn()
rows, err := c.Query("SELECT id, ts, source, room_id, message, stack FROM error_logs ORDER BY id DESC LIMIT ?", limit)
if err != nil {
return nil
}
defer rows.Close()
var out []ErrorLog
for rows.Next() {
var el ErrorLog
rows.Scan(&el.ID, &el.TS, &el.Source, &el.RoomID, &el.Message, &el.Stack)
out = append(out, el)
}
return out
}
func truncStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func btoi(b bool) int {
if b {
return 1
}
return 0
}