From 78c6336c932757971bc6edecdce0f9117f9790df Mon Sep 17 00:00:00 2001 From: Joftal <42807405+Joftal@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:45:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(live/douyu,servers,configs,webapp):=20?= =?UTF-8?q?=E6=96=97=E9=B1=BC=20cookie=20=E5=90=8E=E5=8F=B0=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=BB=AD=E6=9C=9F=EF=BC=88LTP0=20+=20safeAuth=20?= =?UTF-8?q?=E6=8D=A2=E7=A5=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主站 acf_* 登录 cookie 实测仅约 6 天有效(Set-Cookie Max-Age=529200s),过期后取流降级、 弹幕登录失败且只能重新扫码。本功能用 passport 域的长期票据 LTP0 走 safeAuth 换票链自动续命。 - configs: 新增 douyu_auth(LTP0/DyDid/续期日程);Marshal 改 temp+fsync+rename 原子写并 Chmod 0600, rename 或建临时文件失败才回退原地写(Docker 单文件挂载场景);cookie 域名别名(douyu.com/m.douyu.com) 入库统一归一到 www.douyu.com;写盘前从 cookie 串剥离 LTP0;凭证三处出口全部掩码 (/api/config 走 json:"-"、raw-config 走 __BILILIVE_DOUYU_SECRET_HIDDEN__、debug 转储走 CloneForLog) - live/douyu/refresh.go: 手动逐跳跟随 302 累积 Set-Cookie(8 跳上限、跳过空值), 只放行 https 且 *.douyu.com 的跳点(douyu.com.evil.com、裸 http、片段藏域名一律拒绝), 终点必须 200 且 follow/top3 探针返回 error=0 才算成功;非 200 归为临时错误、缺必需字段才判失效; 字段级合并 + 设备号策略(dy_did/acf_did/acf_devid 复用旧值),合并结果按字段名有序写回避免无意义配置 diff - 阈值调度:3d 到点续期、临时失败 6h 退避、判失效 24h、连败 6d 升档;cookie 确凿过期前不发取流请求 - servers/douyu_keeper.go: 30s 首检 + 每小时廉价检查,仅在存有 LTP0 时真正换票; 续期结果只在 CAS 闭包内合并并校验 LTP0 归属(旧票据的迟到结果直接作废); 失效只置 need_rescan 不清 LTP0,告警按 LTP0 去重且推送走独立 goroutine 不阻塞续期循环 - servers/handler.go: raw-config 区分"该节未提交"与"显式清空"、按 acf_uid 判换号、带锁提交并支持版本冲突重试; putLiveHostCookie 不误清长期票据;新增 GET /api/douyu/auth/status 供前端显示登录态与下次续期时间; B 站 pollBilibiliQRCode 保留上游原有字段(不再因结构体重序列化丢 refresh_token)并把 Set-Cookie 登录字段拼进 data.cookies - notify: 新增 SendSystemAlert,斗鱼需重新扫码时按已配渠道推送 - cmd/bililive: 启动 keeper 且不依赖 Web RPC 是否启用,保证无界面后台长期保活 - webapp: 斗鱼面板显示登录态/续期日程并做轮询会话隔离,房间列表标记登录态; B 站扫码成功后回填 cookie 并提示未落盘的后果 - 测试:新增 6 个文件覆盖换票链分类、失效与退避升档、掩码出口、原子写与回退、别名归一、设备号复用 (全部 httptest/合成值,不触网、不含真实凭证) - go.mod: google/uuid 由 indirect 归位为 direct(handler 直接引用) 实测记录:真机换票链有效态 302→200 JSONP+17 个 Set-Cookie、垃圾票据态 200 JSONP+零 cookie; 临时实例 30s 首检续期成功并热应用后,斗鱼原画以 60fps 连续录制跨过 5 分钟分段; 垃圾 LTP0 走 need_rescan + 5.99h 退避,原 cookie 与票据均未被破坏。 --- go.mod | 2 +- src/cmd/bililive/bililive.go | 6 +- src/configs/config.go | 304 +++++++++- src/configs/config_cookie_write_test.go | 226 +++++++ src/configs/config_secret_exposure_test.go | 39 ++ src/live/douyu/auth.go | 73 ++- src/live/douyu/douyu.go | 98 ++- src/live/douyu/douyu_device_id_test.go | 57 ++ src/live/douyu/douyu_login_state_test.go | 201 +++++++ src/live/douyu/refresh.go | 299 ++++++++++ src/live/douyu/refresh_test.go | 373 ++++++++++++ src/notify/send.go | 11 + src/servers/bilibili_login_test.go | 72 +++ src/servers/douyu_cookie_api_test.go | 463 ++++++++++++++ src/servers/douyu_keeper.go | 319 ++++++++++ src/servers/douyu_keeper_backoff_test.go | 115 ++++ src/servers/douyu_keeper_test.go | 564 ++++++++++++++++++ src/servers/handler.go | 355 +++++++++-- src/servers/server.go | 1 + .../edit-cookie/bili-login-panel.tsx | 42 +- .../edit-cookie/douyu-login-panel.tsx | 219 +++++-- .../src/component/edit-cookie/index.tsx | 6 +- src/webapp/src/component/live-list/index.tsx | 36 +- src/webapp/src/utils/api.ts | 7 + 24 files changed, 3728 insertions(+), 160 deletions(-) create mode 100644 src/configs/config_cookie_write_test.go create mode 100644 src/configs/config_secret_exposure_test.go create mode 100644 src/live/douyu/douyu_device_id_test.go create mode 100644 src/live/douyu/douyu_login_state_test.go create mode 100644 src/live/douyu/refresh.go create mode 100644 src/live/douyu/refresh_test.go create mode 100644 src/servers/bilibili_login_test.go create mode 100644 src/servers/douyu_cookie_api_test.go create mode 100644 src/servers/douyu_keeper.go create mode 100644 src/servers/douyu_keeper_backoff_test.go create mode 100644 src/servers/douyu_keeper_test.go diff --git a/go.mod b/go.mod index 5675268..9e7b353 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/getsentry/sentry-go v0.31.1 github.com/go-delve/delve v1.26.0 github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.7.4 github.com/gorilla/websocket v1.5.3 github.com/hr3lxphr6j/requests v0.0.1 @@ -67,7 +68,6 @@ require ( github.com/google/go-dap v0.12.0 // indirect github.com/google/jsonschema-go v0.3.0 // indirect github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect - github.com/google/uuid v1.6.0 // indirect github.com/huandu/xstrings v1.3.2 // indirect github.com/imdario/mergo v0.3.11 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/src/cmd/bililive/bililive.go b/src/cmd/bililive/bililive.go index e511e57..960247d 100644 --- a/src/cmd/bililive/bililive.go +++ b/src/cmd/bililive/bililive.go @@ -276,7 +276,7 @@ func main() { logger.Debugf("flag: %s used.", os.Args) } logger.Debugf("%+v", consts.GetAppInfo()) - logger.Debugf("%+v", configs.GetCurrentConfig()) + logger.Debugf("%+v", configs.CloneForLog(configs.GetCurrentConfig())) // 提前声明信号通道,以便后续 OnShutdownRequest 闭包可以引用它 c := make(chan os.Signal, 1) @@ -559,6 +559,10 @@ func main() { // 异步检测并(按需)下载 FFmpeg,不阻塞启动流程 tools.FFmpegAsyncInit(ctx) + // 启动斗鱼 cookie 后台自动续期(仅在存有 LTP0 长期凭证时实际发起 safeAuth 换票, + // 与 Web RPC 是否启用无关,保证无界面后台录制也能长期保持登录态) + servers.StartDouyuCookieKeeper(ctx) + // 工具链是异步初始化的,WebUI 会先于工具就绪启动。 // 注册就绪检查,让依赖外部工具的平台(抖音依赖 bililive-tools)在工具可用之前 // 不去请求直播间信息,从而也不会启动录制任务。 diff --git a/src/configs/config.go b/src/configs/config.go index df5d098..c1328e6 100644 --- a/src/configs/config.go +++ b/src/configs/config.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strings" "sync" "sync/atomic" @@ -547,6 +548,26 @@ type SoopLiveAuth struct { Password string `yaml:"password,omitempty" json:"password,omitempty"` } +// DouyuAuth 保存斗鱼 passport 域的长期凭证,用于通过 safeAuth 自动续期主站登录 cookie。 +// LTP0 是数月有效的长期票据,主站 acf_* 登录 cookie 仅约 6 天有效,需定期用 LTP0 换新的。 +// LTP0/DyDid 标记 json:"-":它们只用于 YAML 落盘,绝不通过 /api/config 等 JSON 接口下发给浏览器 +// (GET /api/config 直接序列化整个 Config,若保留 json tag 会把数月有效的长期凭证暴露给前端)。 +type DouyuAuth struct { + LTP0 string `yaml:"ltp0,omitempty" json:"-"` + DyDid string `yaml:"dy_did,omitempty" json:"-"` // 可选,实测缺失也能发权 + // NextRefreshAt 是下次应续期的 Unix 秒时间戳(0 表示未知/立即续期)。 + // 后台 keeper 平时只做廉价时间比较,到点才真正发起 safeAuth 换票,避免无谓轮换。 + NextRefreshAt int64 `yaml:"next_refresh_at,omitempty" json:"next_refresh_at,omitempty"` + // NeedRescan 标记"自动续期已因凭证失效而失败、已通知用户重新扫码",用于去重: + // 失效时只推送一次通知,续期成功或重新扫码后清除。 + NeedRescan bool `yaml:"need_rescan,omitempty" json:"need_rescan,omitempty"` + // LastRenewSuccessAt 是上一次"拿到新鲜登录态"(自动续期成功或扫码登录成功)的 Unix 秒时间戳, + // 0 表示该字段引入前从未成功过(此时不据其升级提醒)。 + // 用途:网络类失败默认只静默退避,但若距上次成功已超过主站 cookie 的整个有效期, + // 说明"临时错误"实际已让登录态彻底过期,必须升级为失效提醒,否则会长期匿名录制却毫无提示。 + LastRenewSuccessAt int64 `yaml:"last_renew_success_at,omitempty" json:"last_renew_success_at,omitempty"` +} + // Config content all config info. type Config struct { // 核心配置 @@ -580,6 +601,9 @@ type Config struct { // SoopLive 账号配置 SoopLiveAuth SoopLiveAuth `yaml:"sooplive_auth,omitempty" json:"sooplive_auth,omitempty"` + // 斗鱼长期凭证配置(用于主站 cookie 自动续期) + DouyuAuth DouyuAuth `yaml:"douyu_auth,omitempty" json:"douyu_auth,omitempty"` + // 通知服务配置 Notify Notify `yaml:"notify" json:"notify"` @@ -840,8 +864,9 @@ func SetDebug(v bool) (*Config, error) { return UpdateWithRetry(func(c *Config) error { c.Debug = v; return nil }, 3, 10*time.Millisecond) } -// SetCookie 设置某个 host 的 Cookie。 +// SetCookie 设置某个 host 的 Cookie。host 会先归一为标准域名,避免别名写入后房间取不到。 func SetCookie(host, cookie string) (*Config, error) { + host = NormalizeCookieHost(host) return UpdateWithRetry(func(c *Config) error { if c.Cookies == nil { c.Cookies = make(map[string]string) @@ -856,11 +881,22 @@ func SetCookie(host, cookie string) (*Config, error) { } func SetCookies(hostCookies map[string]string) (*Config, error) { + normalized := make(map[string]string, len(hostCookies)) + for host, cookie := range hostCookies { + std := NormalizeCookieHost(host) + // 同一批里别名与标准键并存时以标准键为准,不能由 map 遍历顺序决定谁生效 + if std != host { + if _, ok := hostCookies[std]; ok { + continue + } + } + normalized[std] = cookie + } return UpdateWithRetry(func(c *Config) error { if c.Cookies == nil { c.Cookies = make(map[string]string) } - for host, cookie := range hostCookies { + for host, cookie := range normalized { if strings.TrimSpace(cookie) == "" { delete(c.Cookies, host) continue @@ -1115,6 +1151,14 @@ func newConfigPostProcess(c *Config) { } c.LiveRooms = deduped } + // cookies 的 host key 同样归一:房间 URL 归一后按标准 host 查 cookie, + // 手工编辑时写的 douyu.com / m.douyu.com 必须能对上,否则登录态静默失效。 + c.Cookies = normalizeCookieHostKeys(c.Cookies) + // LTP0 只该存在 DouyuAuth.LTP0 一处:它是扫码那一刻由 passport 域下发的数月有效期长期凭证, + // 混进 Cookies 就会跟着 cookie 串一起从 /api/config 的 JSON 和"设置明文"的 YAML 两个出口漏出去。 + // 这里是加载侧的总闸(NewConfig / NewConfigWithBytes 都经过它,"设置明文"保存也先经后者解析), + // 但 Marshal 并不调用本函数,运行期往 Cookies 里写值的入口仍须各自剔除,见 DropCookieFields 调用处。 + stripCookieFields(c.Cookies, "LTP0") } // configMinimal 是配置文件的最小子集,仅包含 launcher 决策所需的字段。 @@ -1233,6 +1277,10 @@ func (c Config) getLiveRoomByUrlImpl(url string) (*LiveRoom, error) { func NewConfigWithBytes(b []byte) (*Config, error) { config := defaultConfig + // defaultConfig 是包级变量,按值拷贝后 liveRoomIndexCache 仍指向同一张 map: + // 不换新 map 的话,下面 RefreshLiveRoomIndexCache 会直接改写全局默认配置的缓存, + // 让所有配置实例共享一张被并发读写的 map。 + config.liveRoomIndexCache = map[string]int{} if err := yaml.Unmarshal(b, &config); err != nil { return nil, err } @@ -1301,7 +1349,69 @@ func (c *Config) Marshal() error { return err } - return os.WriteFile(c.File, buf.Bytes(), 0644) + // 配置文件里必然存着各平台登录 cookie 与斗鱼长期票据:先写同目录临时文件再 rename 替换。 + // 直接 os.WriteFile 覆盖时,进程中途被杀或磁盘写满会在 config.yml 上留下半截 YAML, + // 下次启动连房间和登录态都读不出来;0600 也只有在新建时生效,故显式 Chmod。 + return c.writeFileAtomically(buf.Bytes()) +} + +// writeFileAtomically 把 data 以仅属主可读写的方式写入 c.File:优先原子替换, +// 只有在"原子替换这个动作本身不可用"时才回退为原地写。 +// +// 必须有回退:Docker 用 -v ./config.yml:/app/config.yml 单文件挂载(K8s ConfigMap 的 +// subPath 同理)时,挂载点是跨设备的固定 inode,temp+rename 会 EXDEV/EBUSY 失败; +// 而 Marshal 在启动路径上(NewConfigWithFile 会补写缺失字段),失败即 os.Exit(1), +// 等于让原本能正常跑的部署方式直接起不来。原地写就是本功能之前的行为,不会更差。 +// +// 但只有 rename/建临时文件失败才允许回退:若数据写到一半就失败(典型是磁盘满), +// 原地写会先把 config.yml 截断成 0 字节再失败,留下一份读不出来的配置。 +func (c *Config) writeFileAtomically(data []byte) error { + dir := filepath.Dir(c.File) + tmp, err := os.CreateTemp(dir, ".bililive-config-*.tmp") + if err != nil { + return c.writeInPlace(data) + } + tmpName := tmp.Name() + if err := writeAndSync(tmp, data); err != nil { + os.Remove(tmpName) + return err + } + // 权限要在改名前设:新文件默认带 0600,但 umask/已存在的临时文件都可能让它更宽 + if err := os.Chmod(tmpName, 0600); err != nil { + os.Remove(tmpName) + return err + } + if err := os.Rename(tmpName, c.File); err != nil { + os.Remove(tmpName) + return c.writeInPlace(data) + } + return nil +} + +// writeAndSync 写入并落盘后关闭(rename 只保证目录项原子替换,数据本身仍需 sync) +func writeAndSync(f *os.File, data []byte) error { + if _, err := f.Write(data); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + return f.Close() +} + +// writeInPlace 原地覆盖写入 c.File,同样保证 0600 与落盘 +func (c *Config) writeInPlace(data []byte) error { + f, err := os.OpenFile(c.File, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + if err := writeAndSync(f, data); err != nil { + return err + } + // 已存在的文件不会被 OpenFile 的 perm 改动权限,需显式收紧 + return os.Chmod(c.File, 0600) } func (c Config) GetFilePath() (string, error) { @@ -1351,6 +1461,85 @@ func CloneConfigShallow(src *Config) *Config { return &cp } +// LogSecretMask 是调试日志中代替凭证原文的占位符 +const LogSecretMask = "__BILILIVE_SECRET_HIDDEN__" + +// CloneForLog 返回 cfg 的副本,其中所有凭证类字段被替换为占位符,仅用于日志输出。 +// 启动时的 debug 配置转储会把整个 Config 以 %+v 写入日志文件,而日志经常随问题反馈一起上传, +// 因此站点的 cookie(含斗鱼 acf_auth)、LTP0 长期票据、通知服务的 token/密码都不能原样落盘。 +// 只替换"值",键与是否配置仍然可见,排障时仍能判断某项有没有配。 +func CloneForLog(cfg *Config) *Config { + if cfg == nil { + return nil + } + cp := CloneConfigShallow(cfg) + for host := range cp.Cookies { + cp.Cookies[host] = LogSecretMask + } + if cp.DouyuAuth.LTP0 != "" { + cp.DouyuAuth.LTP0 = LogSecretMask + } + if cp.DouyuAuth.DyDid != "" { + cp.DouyuAuth.DyDid = LogSecretMask + } + if cp.SoopLiveAuth.Password != "" { + cp.SoopLiveAuth.Password = LogSecretMask + } + if cp.Notify.Telegram.BotToken != "" { + cp.Notify.Telegram.BotToken = LogSecretMask + } + if cp.Notify.Email.SenderPassword != "" { + cp.Notify.Email.SenderPassword = LogSecretMask + } + if cp.Notify.Ntfy.Token != "" { + cp.Notify.Ntfy.Token = LogSecretMask + } + if cp.Notify.Bark.DeviceKey != "" { + cp.Notify.Bark.DeviceKey = LogSecretMask + } + if cp.Notify.WxPusher.AppToken != "" { + cp.Notify.WxPusher.AppToken = LogSecretMask + } + if cp.OpenList.Password != "" { + cp.OpenList.Password = LogSecretMask + } + if cp.OpenList.Token != "" { + cp.OpenList.Token = LogSecretMask + } + // 代理 URL 常内嵌账号密码(http://user:pass@host),同样按凭证处理 + cp.Proxy.URL = maskProxyURL(cp.Proxy.URL) + cp.Proxy.InfoProxy = maskProxyEntryForLog(cp.Proxy.InfoProxy) + cp.Proxy.DownloadProxy = maskProxyEntryForLog(cp.Proxy.DownloadProxy) + return cp +} + +func maskProxyEntryForLog(entry *ProxyEntry) *ProxyEntry { + if entry == nil || entry.URL == "" { + return entry + } + // 必须换新对象:CloneConfigShallow 是浅拷贝,改写指针会污染当前配置 + cp := *entry + cp.URL = maskProxyURL(entry.URL) + return &cp +} + +// maskProxyURL 隐藏代理 URL 中的用户名密码,保留 scheme 与 host 以便确认代理是否配置正确 +func maskProxyURL(rawURL string) string { + if rawURL == "" { + return rawURL + } + u, err := url.Parse(rawURL) + if err != nil || u.User == nil { + return rawURL + } + if _, hasPwd := u.User.Password(); hasPwd { + u.User = url.UserPassword(u.User.Username(), LogSecretMask) + } else { + u.User = url.User(u.User.Username()) + } + return u.String() +} + // ResolveConfigForRoom 为指定房间解析最终的配置值 // 通过合并 全局 -> 平台 -> 房间 级别的配置 func (c *Config) ResolveConfigForRoom(room *LiveRoom, platformName string) ResolvedConfig { @@ -1485,6 +1674,115 @@ func (r *ResolvedConfig) applyOverrides(override *OverridableConfig) { // 使 cookies、平台层级配置、房间查找等所有按 host 索引的链路只需认一个 key。 var roomUrlHostAliases = map[string]string{ "m.douyu.com": "www.douyu.com", + // 斗鱼只注册了 www 域名的 builder,裸域房间必须在入库前归一,否则 "not support this url"; + // 归一后 cookie host key 也与斗鱼登录/续期使用的 www.douyu.com 对齐。 + "douyu.com": "www.douyu.com", +} + +// NormalizeCookieHost 把 Cookies 的 host key 归一为平台标准域名(douyu.com / m.douyu.com → www.douyu.com), +// 与房间 URL 共用同一张别名表,使"按 host 取 cookie"的链路只认一个 key。 +func NormalizeCookieHost(host string) string { + if std, ok := roomUrlHostAliases[host]; ok { + return std + } + return host +} + +// normalizeCookieHostKeys 归一并合并 Cookies 的 host key。 +// 手工编辑配置时常写裸域或移动端域名,键不归一则房间按标准 host 查不到登录 cookie—— +// 既不报错也不显示在面板上,只是静默退回匿名录制(斗鱼约 5 分钟被切断一次)。 +// 先按 host 排序再写入:标准键的值优先于别名键(不被别名反向覆盖),且结果与 map 遍历顺序无关, +// 否则每次加载都会把 config.yml 里的 cookie 条目重排一遍,产生无意义的配置 diff。 +func normalizeCookieHostKeys(cookies map[string]string) map[string]string { + if len(cookies) == 0 { + return cookies + } + hosts := make([]string, 0, len(cookies)) + for host := range cookies { + hosts = append(hosts, host) + } + sort.Strings(hosts) + out := make(map[string]string, len(cookies)) + // 第一遍:非别名键原样保留。标准键是权威来源,不能被指向它的别名覆盖。 + for _, host := range hosts { + if _, isAlias := roomUrlHostAliases[host]; isAlias { + continue + } + out[host] = cookies[host] + } + // 第二遍:别名键只在标准键缺失或为空时补位。 + // 若按字典序单趟写入,"douyu.com" 排在 "www.douyu.com" 之前, + // 一条空值的裸域条目会先占位、再把标准键的有效 cookie 挤掉,登录态反而被归一动作弄丢。 + for _, host := range hosts { + std, isAlias := roomUrlHostAliases[host] + if !isAlias || strings.TrimSpace(out[std]) != "" { + continue + } + out[std] = cookies[host] + } + return out +} + +// ParseCookieFields 把 "k1=v1; k2=v2" 形式的 cookie 串解析为字段 map。 +func ParseCookieFields(s string) map[string]string { + m := make(map[string]string) + for _, item := range strings.Split(s, ";") { + item = strings.TrimSpace(item) + if k, v, ok := strings.Cut(item, "="); ok && k != "" { + m[k] = v + } + } + return m +} + +// MergeCookieFields 用新字段覆盖旧 cookie 串中的同名字段,保留其余字段。 +// 输出按字段名排序:map 遍历顺序随机,不排序会让每次续期都把 config.yml 里的 cookie 整行重排。 +func MergeCookieFields(old string, newFields map[string]string) string { + merged := ParseCookieFields(old) + for k, v := range newFields { + merged[k] = v + } + keys := make([]string, 0, len(merged)) + for k := range merged { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+merged[k]) + } + return strings.Join(parts, "; ") +} + +// DropCookieFields 从 cookie 串中剔除指定字段,其余字段保持原样输出(按名排序,避免配置 diff 抖动)。 +func DropCookieFields(cookie string, names ...string) string { + fields := ParseCookieFields(cookie) + for _, n := range names { + delete(fields, n) + } + if len(fields) == 0 { + return "" + } + return MergeCookieFields("", fields) +} + +// stripCookieFields 从 Cookies 每一项的值中剔除指定字段(如数月有效期的长期票据 LTP0)。 +// 只重写真正命中该字段的条目:其余 cookie 串保持用户原样,避免被重排后产生无意义的配置 diff。 +func stripCookieFields(cookies map[string]string, names ...string) { + for host, value := range cookies { + fields := ParseCookieFields(value) + hit := false + for _, n := range names { + if _, ok := fields[n]; ok { + hit = true + break + } + } + if !hit { + continue + } + cookies[host] = DropCookieFields(value, names...) + } } // NormalizeLiveRoomUrl 将房间 URL 中命中的别名 host 替换为标准 host(如 m.douyu.com → www.douyu.com)。 diff --git a/src/configs/config_cookie_write_test.go b/src/configs/config_cookie_write_test.go new file mode 100644 index 0000000..47f368f --- /dev/null +++ b/src/configs/config_cookie_write_test.go @@ -0,0 +1,226 @@ +package configs + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +// 手工编辑配置时常把斗鱼写成裸域或移动端域名,键不归一的话房间按标准 host 查不到 cookie, +// 既不报错也不显示在面板上,只是静默退回匿名录制(约 5 分钟被切断一次)。 +func TestLoadNormalizesCookieHostAliases(t *testing.T) { + cases := []struct { + name string + yaml string + wantN int + want string + }{ + {"裸域补位", "cookies:\n douyu.com: \"acf_uid=1\"\n", 1, "acf_uid=1"}, + {"移动端域名补位", "cookies:\n m.douyu.com: \"acf_uid=2\"\n", 1, "acf_uid=2"}, + {"标准键优先于别名", "cookies:\n douyu.com: \"stale\"\n www.douyu.com: \"authoritative\"\n", 1, "authoritative"}, + {"空值别名不得挤掉标准键有效值", "cookies:\n douyu.com: \"\"\n www.douyu.com: \"keep\"\n", 1, "keep"}, + {"他平台键原样保留", "cookies:\n www.huya.com: \"huya\"\n live.bilibili.com: \"bili\"\n", 2, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg, err := NewConfigWithBytes([]byte(c.yaml)) + assert.NoError(t, err) + if len(cfg.Cookies) != c.wantN { + t.Fatalf("归一后键数=%d 期望=%d, got=%v", len(cfg.Cookies), c.wantN, cfg.Cookies) + } + if c.want != "" && cfg.Cookies["www.douyu.com"] != c.want { + t.Errorf("www.douyu.com=%q 期望 %q", cfg.Cookies["www.douyu.com"], c.want) + } + // 别名键不得残留:否则续期只更新标准键,别名那份会永远陈旧 + if _, ok := cfg.Cookies["douyu.com"]; ok { + t.Errorf("别名键 douyu.com 未被合并: %v", cfg.Cookies) + } + if _, ok := cfg.Cookies["m.douyu.com"]; ok { + t.Errorf("别名键 m.douyu.com 未被合并: %v", cfg.Cookies) + } + }) + } +} + +// LTP0 是 passport 域的数月票据,只应存在 DouyuAuth.LTP0 一处。 +// 混进 Cookies 就会从 /api/config 的 JSON 与 /api/raw-config 的 YAML 两个出口漏给浏览器。 +func TestLoadStripsLongTermTicketFromCookieValues(t *testing.T) { + cfg, err := NewConfigWithBytes([]byte("cookies:\n www.douyu.com: \"acf_uid=1; LTP0=abc.def; acf_auth=x\"\n live.bilibili.com: \"SESSDATA=keepme\"\n")) + assert.NoError(t, err) + got := cfg.Cookies["www.douyu.com"] + if strings.Contains(got, "LTP0") { + t.Errorf("LTP0 未从 cookie 串剔除: %q", got) + } + if !strings.Contains(got, "acf_uid=1") || !strings.Contains(got, "acf_auth=x") { + t.Errorf("剔除时误伤了登录字段: %q", got) + } + // 未命中该字段的条目必须保持用户原样,避免产生无意义的配置 diff + if cfg.Cookies["live.bilibili.com"] != "SESSDATA=keepme" { + t.Errorf("他平台 cookie 被重排: %q", cfg.Cookies["live.bilibili.com"]) + } +} + +// SetCookies 一批里同时提交别名与标准键时,必须以标准键为准(不能由 map 遍历顺序决定), +// 否则浏览器批量同步会随机覆盖掉刚写好的那份。 +func TestSetCookiesAliasBatchIsDeterministic(t *testing.T) { + orig := GetCurrentConfig() + t.Cleanup(func() { SetCurrentConfig(orig) }) + base := NewConfig() + SetCurrentConfig(base) + + for i := 0; i < 20; i++ { + _, err := SetCookies(map[string]string{"douyu.com": "from-alias", "www.douyu.com": "from-standard"}) + assert.NoError(t, err) + if got := GetCurrentConfig().Cookies["www.douyu.com"]; got != "from-standard" { + t.Fatalf("第 %d 次:标准键被别名覆盖, got=%q", i, got) + } + if _, ok := GetCurrentConfig().Cookies["douyu.com"]; ok { + t.Fatalf("别名键残留") + } + SetCurrentConfig(base) + } +} + +// Marshal 必须可回读、权限收紧到 0600,且不在目录里留下临时文件。 +func TestMarshalWritesReadableConfigWithTightMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + c := NewConfig() + c.File = path + c.Cookies = map[string]string{"www.douyu.com": "acf_uid=1; acf_auth=x", "www.huya.com": "huya=1"} + c.DouyuAuth = DouyuAuth{LTP0: "ticket.value", NextRefreshAt: 1893456000} + assert.NoError(t, c.Marshal()) + + b, err := os.ReadFile(path) + assert.NoError(t, err) + if !strings.Contains(string(b), "acf_uid=1") || !strings.Contains(string(b), "ticket.value") { + t.Fatalf("内容未完整写入: %s", truncateForTest(string(b))) + } + // 原子写:同目录不得残留 .bililive-config-*.tmp + entries, err := os.ReadDir(dir) + assert.NoError(t, err) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".bililive-config-") { + t.Errorf("临时文件残留: %s", e.Name()) + } + } + // Windows 下 Go 只能表达"只读/可写",权限位恒为 0666,故仅在 POSIX 下断言收紧 + if runtime.GOOS != "windows" { + if info, err := os.Stat(path); err == nil { + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("权限未收紧: %v", perm) + } + } + } + // 回读必须等价(房间/cookie/票据都还在) + reloaded, err := NewConfigWithFile(path) + assert.NoError(t, err) + if reloaded.Cookies["www.huya.com"] != "huya=1" { + t.Errorf("回读后他平台 cookie 丢失: %v", reloaded.Cookies) + } + if reloaded.DouyuAuth.LTP0 != "ticket.value" { + t.Errorf("回读后票据丢失: %q", reloaded.DouyuAuth.LTP0) + } +} + +// 已存在的宽权限配置文件在写回后必须被收紧(POSIX);原地写分支同样要覆盖。 +func TestWriteInPlaceFallback(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + assert.NoError(t, os.WriteFile(path, []byte("interval: 20\n"), 0666)) + + // 原地写分支(Docker 单文件挂载时 rename 会 EXDEV/EBUSY) + c2 := NewConfig() + c2.File = path + assert.NoError(t, c2.writeInPlace([]byte("cookies:\n www.douyu.com: \"in-place\"\n"))) + reloaded, err := NewConfigWithFile(path) + assert.NoError(t, err) + if reloaded.Cookies["www.douyu.com"] != "in-place" { + t.Errorf("原地写内容未生效: %v", reloaded.Cookies) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + assert.NoError(t, err) + if info.Mode().Perm() != 0600 { + t.Errorf("已存在文件未收紧权限: %v", info.Mode().Perm()) + } + } +} + +// 写不进磁盘时不得把已有配置截断成半截 YAML(磁盘满对录制器是常规风险) +func TestMarshalFailureKeepsExistingConfigIntact(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + assert.NoError(t, os.WriteFile(path, []byte("interval: 20\nout_put_path: ./\n"), 0600)) + + c := NewConfig() + c.File = filepath.Join(dir, "no-such-dir", "config.yml") // CreateTemp 与原地写都会失败 + assert.Error(t, c.Marshal()) + // 原文件必须仍然是完整可读的 YAML,且内容未被本次覆盖 + orig, err := os.ReadFile(path) + assert.NoError(t, err) + if !strings.Contains(string(orig), "interval: 20") { + t.Errorf("原配置被破坏: %s", truncateForTest(string(orig))) + } +} + +func TestCloneForLogMasksSecretsWithoutMutatingOriginal(t *testing.T) { + orig := NewConfig() + orig.Cookies = map[string]string{"www.douyu.com": "acf_auth=secret-auth"} + orig.DouyuAuth = DouyuAuth{LTP0: "secret-ticket", DyDid: "secret-did"} + orig.SoopLiveAuth = SoopLiveAuth{Username: "u", Password: "secret-pw"} + orig.Notify.Telegram.BotToken = "secret-bot" + orig.Notify.Email.SenderPassword = "secret-smtp" + orig.Notify.Ntfy.Token = "secret-ntfy" + orig.Notify.Bark.DeviceKey = "secret-bark" + orig.Notify.WxPusher.AppToken = "secret-wx" + orig.OpenList.Password = "secret-webdav" + orig.OpenList.Token = "secret-oplist-token" + orig.Proxy.URL = "http://puser:ppass@proxy.example:3128" + orig.Proxy.DownloadProxy = &ProxyEntry{URL: "http://duser:dpass@dl.example:3128"} + + cp := CloneForLog(orig) + text := dumpForTest(cp) + for _, s := range []string{"secret-auth", "secret-ticket", "secret-did", "secret-pw", "secret-bot", "secret-smtp", "secret-ntfy", "secret-bark", "secret-wx", "secret-webdav", "secret-oplist-token", "ppass", "dpass"} { + if strings.Contains(text, s) { + t.Errorf("日志副本泄露了 %q", s) + } + } + // 浅拷贝陷阱:Proxy.DownloadProxy 是指针,改写它会污染正被业务使用的当前配置 + if orig.Proxy.DownloadProxy.URL != "http://duser:dpass@dl.example:3128" { + t.Errorf("掩码污染了原配置: %q", orig.Proxy.DownloadProxy.URL) + } + if orig.Cookies["www.douyu.com"] != "acf_auth=secret-auth" { + t.Errorf("掩码污染了原 cookie: %q", orig.Cookies["www.douyu.com"]) + } + if orig.DouyuAuth.LTP0 != "secret-ticket" { + t.Errorf("掩码污染了原票据: %q", orig.DouyuAuth.LTP0) + } + // 排障线索要留下:键与"配了没配"仍可见 + if cp.Cookies["www.douyu.com"] != LogSecretMask { + t.Errorf("cookie 应替换为占位符, got=%q", cp.Cookies["www.douyu.com"]) + } + if !strings.Contains(cp.Proxy.URL, "proxy.example:3128") { + t.Errorf("代理 host 应保留以便确认配置: %q", cp.Proxy.URL) + } +} + +func truncateForTest(s string) string { + if len(s) > 200 { + return s[:200] + } + return s +} + +func dumpForTest(c *Config) string { + b, err := yaml.Marshal(c) + if err != nil { + return "" + } + return string(b) +} diff --git a/src/configs/config_secret_exposure_test.go b/src/configs/config_secret_exposure_test.go new file mode 100644 index 0000000..241d064 --- /dev/null +++ b/src/configs/config_secret_exposure_test.go @@ -0,0 +1,39 @@ +package configs + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +// 斗鱼长期凭证只能落盘,不能进 JSON:GET /api/config 直接 writeJSON(整个 Config), +// 一旦 LTP0/DyDid 带 json tag,数月有效的 passport 域凭证就会被下发到浏览器。 +// 同时 YAML 侧必须保留,否则重启/续期后拿不到 LTP0,自动续期整体失效。 +// 注意:GET /api/raw-config(设置页的 YAML 明文)与本用例无关,它仍会返回配置文件原文, +// 与 cookies / sooplive_auth 的既有暴露面相同。 +func TestDouyuAuthSecretsStayOutOfJSON(t *testing.T) { + c := &Config{ + DouyuAuth: DouyuAuth{ + LTP0: "long-term-passport-ticket", + DyDid: "device-id-value", + NextRefreshAt: 1893456000, + NeedRescan: true, + }, + } + + b, err := json.Marshal(c) + assert.NoError(t, err) + assert.NotContains(t, string(b), "long-term-passport-ticket") + assert.NotContains(t, string(b), "device-id-value") + assert.NotContains(t, string(b), `"ltp0"`) + // 非敏感状态字段仍要可见,前端续期健康度提示依赖它们 + assert.Contains(t, string(b), `"need_rescan":true`) + assert.Contains(t, string(b), `"next_refresh_at":1893456000`) + + y, err := yaml.Marshal(c) + assert.NoError(t, err) + assert.Contains(t, string(y), "long-term-passport-ticket") + assert.Contains(t, string(y), "device-id-value") +} diff --git a/src/live/douyu/auth.go b/src/live/douyu/auth.go index d0ac6b7..12be50a 100644 --- a/src/live/douyu/auth.go +++ b/src/live/douyu/auth.go @@ -48,9 +48,12 @@ type QRLoginState string const ( QRStateWaiting QRLoginState = "waiting" // 客户端还未扫码 QRStateScanned QRLoginState = "scanned" // 已扫码,等待手机端确认 - QRStateSuccess QRLoginState = "success" // 确认成功,已换取登录 cookie + QRStateSuccess QRLoginState = "success" // 确认成功,已换取并保存登录 cookie QRStateExpired QRLoginState = "expired" // 二维码失效/被取消 QRStateRejected QRLoginState = "rejected" // 手机端拒绝授权 + // QRStateSaveFailed 表示"手机端已确认登录,但后端保存配置失败"(磁盘满/配置只读等)。 + // 必须与"轮询本身失败"区分:前者反复重扫也没用,要先解决配置可写。 + QRStateSaveFailed QRLoginState = "save_failed" ) // QRLoginSession 一次扫码登录会话 @@ -60,12 +63,16 @@ type QRLoginSession struct { Expire int `json:"expire"` // 有效期(秒) } -// QRPollResult 轮询结果;成功时附带登录 cookie 与用户信息 +// QRPollResult 轮询结果;成功时附带登录 cookie 与用户信息。 +// Cookie/LTP0/DyDid 一律 json:"-":它们只用于后端内部传递(写配置、续期), +// 绝不能出现在任何 JSON 响应里——pollDouyuQRCode 在 writeJSON 前会显式置空作为第二道保险。 type QRPollResult struct { State QRLoginState `json:"state"` Nickname string `json:"nickname,omitempty"` UserID string `json:"uid,omitempty"` - Cookie string `json:"cookie,omitempty"` // 仅成功时有值 + LTP0 string `json:"-"` // passport 域长期凭证,用于后续 safeAuth 自动续期 + DyDid string `json:"-"` // 可选设备 id,随 LTP0 一起持久化 + Cookie string `json:"-"` // 仅成功时有值,由后端直接落盘 Msg string `json:"msg,omitempty"` } @@ -113,7 +120,7 @@ func PollLoginQRCode(ctx context.Context, code string) (*QRPollResult, error) { req.Header.Set("User-Agent", douyuWebUAString) req.Header.Set("Referer", qrLoginReferer) req.Header.Set("X-Requested-With", "XMLHttpRequest") - body, err := doDouyuRequest(req) + body, pollCookies, err := doDouyuRequestWithCookies(req) if err != nil { return nil, err } @@ -145,11 +152,22 @@ func PollLoginQRCode(ctx context.Context, code string) (*QRPollResult, error) { } } } + // LTP0 由轮询 error=0 响应的 Set-Cookie 下发(passport 域),是后续续期的关键长期凭证 + ltp0 := cookieFromList(pollCookies, "LTP0") + if ltp0 == "" { + ltp0 = cookieValue(cookie, "LTP0") + } + dyDid := cookieFromList(pollCookies, "dy_did") + if dyDid == "" { + dyDid = cookieValue(cookie, "dy_did") + } return &QRPollResult{ State: QRStateSuccess, Nickname: nickname, UserID: userID, Cookie: cookie, + LTP0: ltp0, + DyDid: dyDid, }, nil case 1: return &QRPollResult{State: QRStateScanned, Msg: msg}, nil @@ -164,12 +182,33 @@ func PollLoginQRCode(ctx context.Context, code string) (*QRPollResult, error) { // doDouyuRequest 执行一次斗鱼上游请求并返回响应体(限制 1MB) func doDouyuRequest(req *http.Request) ([]byte, error) { + body, _, err := doDouyuRequestWithCookies(req) + return body, err +} + +// doDouyuRequestWithCookies 与 doDouyuRequest 相同,但额外返回响应的 Set-Cookie, +// 供扫码成功时提取 passport 域 LTP0 等长期凭证。 +func doDouyuRequestWithCookies(req *http.Request) ([]byte, []*http.Cookie, error) { resp, err := douyuHTTPClient.Do(req) if err != nil { - return nil, err + return nil, nil, err } defer resp.Body.Close() - return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, nil, err + } + return body, resp.Cookies(), nil +} + +// cookieFromList 从 http.Cookie 列表中取指定字段的值 +func cookieFromList(cookies []*http.Cookie, name string) string { + for _, c := range cookies { + if c.Name == name { + return c.Value + } + } + return "" } // exchangeLoginCookie 请求登录成功回调 URL(JSONP),从 Set-Cookie 收集登录态。 @@ -180,8 +219,8 @@ func exchangeLoginCookie(ctx context.Context, rawUrl string) (cookie string, jso if err = checkDouyuURL(rawUrl); err != nil { return "", "", "", err } - seen := make(map[string]bool) // 已收集的 k=v,兼做去重 - var hops []string // 跳转链诊断信息(仅记录 cookie 名,不记录值) + seen := make(map[string]string) // 字段名 -> 值,后一跳覆盖前一跳(终点才是权威登录态) + var hops []string // 跳转链诊断信息(仅记录 cookie 名,不记录值) cur := rawUrl var finalBody string for hop := 0; hop < 8; hop++ { @@ -197,10 +236,19 @@ func exchangeLoginCookie(ctx context.Context, rawUrl string) (cookie string, jso } names := make([]string, 0, len(resp.Cookies())) for _, c := range resp.Cookies() { - seen[c.Name+"="+c.Value] = true names = append(names, c.Name) + // 空值 Set-Cookie 是"清除该 cookie"的写法,不值得写入登录态;缺失会让下游字段保持更早的有效值 + if c.Value == "" { + continue + } + seen[c.Name] = c.Value } - hops = append(hops, fmt.Sprintf("%s -> %d (Set-Cookie: %v)", cur, resp.StatusCode, names)) + // 一次性登录 token 就在 query 里,这条诊断串会随错误冒泡到前端与日志,只保留 scheme://host/path + hopURL := cur + if u, e := url.Parse(cur); e == nil { + hopURL = u.Scheme + "://" + u.Host + u.Path + } + hops = append(hops, fmt.Sprintf("%s -> %d (Set-Cookie: %v)", hopURL, resp.StatusCode, names)) if resp.StatusCode >= 300 && resp.StatusCode < 400 { loc, _ := resp.Location() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) @@ -223,10 +271,7 @@ func exchangeLoginCookie(ctx context.Context, rawUrl string) (cookie string, jso finalBody = string(body) break } - parts := make([]string, 0, len(seen)) - for kv := range seen { - parts = append(parts, kv) - } + parts := sortedCookiePairs(seen) trace = strings.Join(hops, "; ") + "; 终点响应体: " + truncateForLog([]byte(finalBody)) return strings.Join(parts, "; "), stripJSONP(finalBody), trace, nil } diff --git a/src/live/douyu/douyu.go b/src/live/douyu/douyu.go index b840206..94b6984 100644 --- a/src/live/douyu/douyu.go +++ b/src/live/douyu/douyu.go @@ -2,17 +2,20 @@ package douyu import ( "bytes" + "context" "errors" "fmt" "net/http" "net/url" "strconv" "strings" + "sync" "text/template" "time" "github.com/hr3lxphr6j/requests" + "github.com/bililive-go/bililive-go/src/configs" "github.com/bililive-go/bililive-go/src/live" "github.com/bililive-go/bililive-go/src/live/internal" "github.com/bililive-go/bililive-go/src/pkg/utils" @@ -147,8 +150,10 @@ func (l *Live) getEngineWithCryptoJS() (*otto.Otto, error) { type Live struct { internal.BaseLive roomID string - // did 在 Live 生命周期内固定,避免每次签名都换新设备号触发风控 - did string + // did 在 Live 生命周期内固定,避免每次签名都换新设备号触发风控; + // 读取自签名路径、重置自配置热更新,故需加锁。 + didMu sync.Mutex + did string } // GetRoomID 返回已解析的数字房间号(由 fetchRoomID 解析)。 @@ -157,10 +162,21 @@ func (l *Live) GetRoomID() string { return l.roomID } +// loginCookieUsable 报告当前这份斗鱼登录 cookie 是否还值得作为"登录态"发出去。 +// 自动续期一旦已不可能把这份 cookie 救回来(见 LoginCookieKnownExpired),就主动停用, +// 让取流请求回到"从未登录过"那条行为已验证的路径上,而不是继续赌服务端会忽略非法登录字段。 +func loginCookieUsable() bool { + cfg := configs.GetCurrentConfig() + if cfg == nil { + return true + } + return !LoginCookieKnownExpired(cfg.DouyuAuth.LTP0, cfg.DouyuAuth.LastRenewSuccessAt, time.Now()) +} + // getCookieString 从 Options cookie jar 中读取斗鱼域名的 cookie 字符串("k1=v1; k2=v2")。 -// 未配置 cookie 时返回空串,调用方据此决定是否附加 Cookie 头。 +// 未配置 cookie、或这份登录态已确凿失效时返回空串,调用方据此决定是否附加 Cookie 头。 func (l *Live) getCookieString() string { - if l.Options == nil || l.Options.Cookies == nil { + if l.Options == nil || l.Options.Cookies == nil || !loginCookieUsable() { return "" } cookies := l.Options.Cookies.Cookies(l.Url) @@ -171,17 +187,72 @@ func (l *Live) getCookieString() string { return strings.Join(parts, "; ") } +// deviceIDCookieKeys 是斗鱼侧设备标识字段,按优先级排列(cookiejar 返回顺序不稳定,不能"取先命中的")。 +// 这张表同时供续期写回侧判定设备号该丢还是该补种(见 ApplyDeviceIDPolicy): +// "取来签名"与"续期时取舍"必须共用同一处定义,否则加一个字段就要漏改一处。 +var deviceIDCookieKeys = []string{"dy_did", "acf_did", "acf_devid"} + +// isDeviceIDCookieKey 报告字段名是否为设备标识字段。 +func isDeviceIDCookieKey(name string) bool { + for _, k := range deviceIDCookieKeys { + if k == name { + return true + } + } + return false +} + +// HasStoredDeviceID 报告这份 cookie 串里是否已有非空的设备标识字段。 +func HasStoredDeviceID(cookie string) bool { + fields := configs.ParseCookieFields(cookie) + for _, k := range deviceIDCookieKeys { + if fields[k] != "" { + return true + } + } + return false +} + +// ApplyDeviceIDPolicy 决定一次续期换到的字段里,设备号该丢掉还是该落盘(唯一决策点,返回副本)。 +// - 现有登录 cookie 里已经有设备号:挡掉换票链这次签发的新值。设备号在扫码那次就定下并长期沿用, +// 跟着续期轮换等于每 3 天换一台"新设备",是签名侧最要避免的风控诱因。 +// - 现有 cookie 里一个设备号都没有(手改配置删掉了整行、或那次扫码未下发):把新值补种进去。 +// 否则签名侧只能自行生成随机 did,而续期热应用会作废该缓存,实测表现为每次续期换一个设备号—— +// 带着登录 cookie 却每次换设备,比"设备号来自续期"更可疑。补种一次之后即落入上一条分支,自动稳定。 +func ApplyDeviceIDPolicy(storedCookie string, fields map[string]string) map[string]string { + hasStored := HasStoredDeviceID(storedCookie) + out := make(map[string]string, len(fields)) + for k, v := range fields { + if hasStored && isDeviceIDCookieKey(k) { + continue + } + out[k] = v + } + return out +} + // getDID 返回固定的设备号:优先复用用户 cookie 中的斗鱼设备号 -// (浏览器实际使用 dy_did,登录态下 acf_did 与其一致),使签名 did +// (扫码链是纯 HTTP、不执行页面 JS,拿不到浏览器用的 dy_did,实际下发的设备号是 acf_devid; +// 浏览器自己登录时则通常有 dy_did),使签名 did // 与登录 cookie 保持一致、更接近浏览器行为;否则生成一次并在该 Live 生命周期内复用。 +// 注意:设备号与"登录态是否还可用"无关(斗鱼侧设备号本身不过期),所以即使登录 cookie 已被 +// 停用(getCookieString 返回空),这里仍延用它——换成一个随机 did 反而更像一台新设备。 func (l *Live) getDID() string { + l.didMu.Lock() + defer l.didMu.Unlock() if l.did != "" { return l.did } if l.Options != nil && l.Options.Cookies != nil { + found := make(map[string]string, len(deviceIDCookieKeys)) for _, c := range l.Options.Cookies.Cookies(l.Url) { - if (c.Name == "dy_did" || c.Name == "acf_did") && c.Value != "" { - l.did = c.Value + if c.Value != "" { + found[c.Name] = c.Value + } + } + for _, name := range deviceIDCookieKeys { + if v := found[name]; v != "" { + l.did = v return l.did } } @@ -190,6 +261,19 @@ func (l *Live) getDID() string { return l.did } +// UpdateLiveOptionsbyConfig 热更新房间选项(重新扫码、自动续期换了 cookie)后作废已缓存的设备号。 +// did 优先复用登录 cookie 里的斗鱼设备号,若一直沿用首次的值,换号之后签名里的 did +// 仍是上一份登录态的设备号,与新 cookie 不匹配(表现为偶发签名被拒、退回匿名录制)。 +func (l *Live) UpdateLiveOptionsbyConfig(ctx context.Context, room *configs.LiveRoom) error { + if err := l.BaseLive.UpdateLiveOptionsbyConfig(ctx, room); err != nil { + return err + } + l.didMu.Lock() + defer l.didMu.Unlock() + l.did = "" + return nil +} + // cookieRequestOption 返回带 Cookie 头的请求选项;无 cookie 时返回 nil,调用方需过滤。 func (l *Live) cookieRequestOption() requests.RequestOption { if ck := l.getCookieString(); ck != "" { diff --git a/src/live/douyu/douyu_device_id_test.go b/src/live/douyu/douyu_device_id_test.go new file mode 100644 index 0000000..9c04e74 --- /dev/null +++ b/src/live/douyu/douyu_device_id_test.go @@ -0,0 +1,57 @@ +package douyu + +import ( + "net/url" + "regexp" + "testing" + + "github.com/bililive-go/bililive-go/src/live" +) + +// 签名里的 did 必须与登录 cookie 里的设备号一致,否则等于用"已登录的 cookie + 陌生设备号"取流。 +// 而扫码链是纯 HTTP、不执行页面 JS,斗鱼实际下发的设备号字段是 acf_devid(浏览器里才是 dy_did), +// 只认 dy_did/acf_did 会让这段复用逻辑永远落空、退化成每次重启换一个随机设备号。 +func TestGetDIDReusesDeviceIDFromCookie(t *testing.T) { + u, err := url.Parse("https://www.douyu.com/123") + if err != nil { + t.Fatal(err) + } + cases := []struct { + name string + cookie string + want string + }{ + {"acf_devid(扫码链实际形态)", "acf_devid=devid-value; acf_uid=42", "devid-value"}, + {"dy_did(浏览器登录形态)", "dy_did=dydid-value; acf_uid=42", "dydid-value"}, + {"acf_did", "acf_did=acfdid-value", "acfdid-value"}, + {"多种并存时按 dy_did 优先", "acf_devid=devid-value; dy_did=dydid-value", "dydid-value"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + l := &Live{} + l.Url = u + l.Options = live.MustNewOptions(live.WithKVStringCookies(u, c.cookie)) + if got := l.getDID(); got != c.want { + t.Fatalf("签名 did=%q 期望=%q(cookie=%s)", got, c.want, c.cookie) + } + }) + } +} + +// 完全没有设备号字段时才自行生成,且必须在 Live 生命周期内固定,避免每次签名都换新设备号 +func TestGetDIDFallsBackToStableGeneratedID(t *testing.T) { + u, err := url.Parse("https://www.douyu.com/123") + if err != nil { + t.Fatal(err) + } + l := &Live{} + l.Url = u + l.Options = live.MustNewOptions(live.WithKVStringCookies(u, "acf_uid=42; acf_auth=x")) + first := l.getDID() + if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(first) { + t.Fatalf("生成的 did 形态异常: %q", first) + } + if second := l.getDID(); second != first { + t.Fatalf("did 未固定: %q != %q", second, first) + } +} diff --git a/src/live/douyu/douyu_login_state_test.go b/src/live/douyu/douyu_login_state_test.go new file mode 100644 index 0000000..1d5f359 --- /dev/null +++ b/src/live/douyu/douyu_login_state_test.go @@ -0,0 +1,201 @@ +package douyu + +import ( + "context" + "net/url" + "strings" + "testing" + "time" + + "github.com/bililive-go/bililive-go/src/configs" + "github.com/bililive-go/bililive-go/src/live" +) + +func setCfgForTest(t *testing.T, cfg *configs.Config) { + t.Helper() + orig := configs.GetCurrentConfig() + configs.SetCurrentConfig(cfg) + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) +} + +func newLiveWithCookie(t *testing.T, cookie string) *Live { + t.Helper() + u, err := url.Parse("https://www.douyu.com/123") + if err != nil { + t.Fatal(err) + } + l := &Live{} + l.Url = u + l.Options = live.MustNewOptions(live.WithKVStringCookies(u, cookie)) + return l +} + +const testLoginCookie = "acf_uid=42; acf_auth=auth.value; acf_devid=devid.value" + +// 主站 cookie 有效期约 6.1 天,判死阈值 = 有效期 + 24h 余量 ≈ 171h。 +// 太早判死会把还能用的登录态主动降级成匿名(丢清晰度),太晚则继续把确凿过期的票据发出去。 +func TestLoginCookieKnownExpiredBoundary(t *testing.T) { + now := time.Unix(1893456000, 0) + day := int64(24 * time.Hour / time.Second) + cases := []struct { + name string + ltp0 string + lastSuccess int64 + want bool + }{ + {"无长期凭证(手填 cookie)不判死", "", now.Unix() - 30*day, false}, + {"从未成功过(历史配置)不判死", "ticket", 0, false}, + {"刚续期", "ticket", now.Unix(), false}, + {"6 天前(仍在有效期内)", "ticket", now.Unix() - 6*day, false}, + {"170 小时前(阈值之内)", "ticket", now.Unix() - 170*3600, false}, + {"172 小时前(已过阈值)", "ticket", now.Unix() - 172*3600, true}, + {"进程停摆 10 天后重启", "ticket", now.Unix() - 10*day, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := LoginCookieKnownExpired(c.ltp0, c.lastSuccess, now); got != c.want { + t.Fatalf("判定=%v 期望=%v", got, c.want) + } + }) + } +} + +// 闸门真正作用在取流侧:判死之后 cookie 不再发出,回到"从未登录过"那条行为已验证的路径。 +// 基准必须用真实时间:闸门内部读的是 time.Now(),用未来时间戳会让"过去"变成"未来"而永不判死。 +func TestGetCookieStringGate(t *testing.T) { + now := time.Now() + day := int64(24 * time.Hour / time.Second) + cases := []struct { + name string + auth configs.DouyuAuth + want string + empty bool + }{ + {"健康续期状态照常发出", configs.DouyuAuth{LTP0: "ticket", LastRenewSuccessAt: now.Unix()}, testLoginCookie, false}, + {"手填 cookie(无票据)照常发出", configs.DouyuAuth{}, testLoginCookie, false}, + {"票据在但从未成功过照常发出", configs.DouyuAuth{LTP0: "ticket"}, testLoginCookie, false}, + {"确凿过期则停用", configs.DouyuAuth{LTP0: "ticket", LastRenewSuccessAt: now.Add(-10 * time.Duration(day) * time.Second).Unix()}, "", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + setCfgForTest(t, &configs.Config{DouyuAuth: c.auth, Cookies: map[string]string{CookieHost: testLoginCookie}}) + l := newLiveWithCookie(t, testLoginCookie) + got := l.getCookieString() + if c.empty { + if got != "" { + t.Fatalf("已判死的 cookie 仍被发出: %q", got) + } + return + } + for _, kv := range strings.Split(c.want, "; ") { + if !strings.Contains(got, kv) { + t.Errorf("cookie 缺少 %q, got=%q", kv, got) + } + } + }) + } + // Options 尚未就绪(房间初始化早期)不得 panic + setCfgForTest(t, &configs.Config{}) + if got := (&Live{}).getCookieString(); got != "" { + t.Errorf("无 Options 时应返回空串, got=%q", got) + } +} + +// 设备号与"登录态是否可用"无关(斗鱼侧设备号本身不过期):cookie 被停用后仍应沿用同一个 did, +// 换成随机 did 等于换一台新设备,反而是签名侧要避免的风控诱因。 +func TestGetDIDSurvivesDeadCookieGate(t *testing.T) { + setCfgForTest(t, &configs.Config{ + DouyuAuth: configs.DouyuAuth{LTP0: "ticket", LastRenewSuccessAt: time.Now().Add(-10 * 24 * time.Hour).Unix()}, + Cookies: map[string]string{CookieHost: testLoginCookie}, + }) + l := newLiveWithCookie(t, testLoginCookie) + if got := l.getDID(); got != "devid.value" { + t.Fatalf("cookie 停用后 did 未沿用: %q", got) + } + if got := l.getCookieString(); got != "" { + t.Fatalf("闸门未生效: %q", got) + } +} + +// 现有登录 cookie 已有设备号时挡掉续期新签发的随机值;一个都没有时补种,避免每次续期换一台设备。 +func TestApplyDeviceIDPolicy(t *testing.T) { + fresh := map[string]string{ + "acf_uid": "42", "acf_auth": "new", "dy_did": "fresh-dy", "acf_devid": "fresh-devid", "acf_did": "fresh-acfdid", + } + cases := []struct { + name string + stored string + wantDropped bool + }{ + {"已有 dy_did 时全部设备号字段都被挡", "acf_uid=42; dy_did=scan-did", true}, + {"已有 acf_devid 同样挡住", "acf_uid=42; acf_devid=scan-devid", true}, + {"已有 acf_did 同样挡住", "acf_uid=42; acf_did=scan-did", true}, + {"一个设备号都没有时补种", "acf_uid=42; acf_auth=old", false}, + {"空设备号视为没有(不得把随机值挡掉)", "acf_uid=42; dy_did=", false}, + {"完全空的 cookie 串", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := ApplyDeviceIDPolicy(c.stored, fresh) + for _, k := range deviceIDCookieKeys { + _, present := out[k] + if c.wantDropped && present { + t.Errorf("设备号字段 %s 未被挡掉", k) + } + if !c.wantDropped && out[k] != fresh[k] { + t.Errorf("设备号字段 %s 未补种: got=%q want=%q", k, out[k], fresh[k]) + } + } + // 与设备号无关的登录字段一律保留 + if out["acf_uid"] != "42" || out["acf_auth"] != "new" { + t.Errorf("登录字段被误伤: %v", out) + } + // 必须返回副本:就地改写会让调用方持有的 map 被悄悄改掉 + if _, ok := fresh["acf_uid"]; !ok { + t.Fatal("入参 map 被改写") + } + for _, k := range deviceIDCookieKeys { + if _, ok := fresh[k]; !ok { + t.Fatalf("入参 map 的设备号 %s 被就地删除", k) + } + } + }) + } +} + +// 热应用(重新扫码 / 自动续期)之后必须作废缓存的 did,否则新 cookie 配上旧设备号。 +func TestUpdateLiveOptionsbyConfigClearsCachedDID(t *testing.T) { + setCfgForTest(t, &configs.Config{ + DouyuAuth: configs.DouyuAuth{LTP0: "ticket", LastRenewSuccessAt: time.Now().Unix()}, + Cookies: map[string]string{CookieHost: "acf_uid=99; acf_auth=n; dy_did=new-account-did"}, + }) + l := newLiveWithCookie(t, "acf_uid=42; acf_auth=o; dy_did=old-account-did") + if got := l.getDID(); got != "old-account-did" { + t.Fatalf("前置条件不成立: did=%q", got) + } + room := &configs.LiveRoom{Url: "https://www.douyu.com/123"} + if err := l.UpdateLiveOptionsbyConfig(context.Background(), room); err != nil { + t.Fatal(err) + } + if got := l.getDID(); got != "new-account-did" { + t.Fatalf("热应用后 did 未跟随新 cookie: %q", got) + } + if got := l.getCookieString(); !strings.Contains(got, "acf_uid=99") { + t.Fatalf("热应用未把新 cookie 落到 Options: %q", got) + } +} + +// 别名 URL 房间热应用后必须仍能取到 www.douyu.com 那份 cookie(房间 URL 在加载时已归一) +func TestNormalizeLiveRoomUrlAliases(t *testing.T) { + cases := map[string]string{ + "https://m.douyu.com/123": "https://www.douyu.com/123", + "https://douyu.com/123": "https://www.douyu.com/123", + "https://www.douyu.com/123": "https://www.douyu.com/123", + "https://live.bilibili.com/1": "https://live.bilibili.com/1", + } + for in, want := range cases { + if got := configs.NormalizeLiveRoomUrl(in); got != want { + t.Errorf("%s 归一为 %q, 期望 %q", in, got, want) + } + } +} diff --git a/src/live/douyu/refresh.go b/src/live/douyu/refresh.go new file mode 100644 index 0000000..bfdae27 --- /dev/null +++ b/src/live/douyu/refresh.go @@ -0,0 +1,299 @@ +// Package douyu 中的 cookie 自动续期实现(refresh.go) +// +// 背景:斗鱼主站登录 cookie(acf_* 全家桶)仅约 6 天有效(Set-Cookie Max-Age≈529200s), +// 且网页端不做被动 Set-Cookie 轮换,长时间运行的录制会因 cookie 过期而退回匿名流(约 5 分钟被 CDN 掐断)。 +// 扫码登录确认成功那一次,passport 域会通过 /japi/scan/auth 轮询响应下发长期凭证 LTP0(数月有效)。 +// 续期协议(实测抓包): +// 1. GET passport.douyu.com/lapi/passport/iframe/safeAuth?...(Cookie: LTP0=..) +// -> 302 Location 指向 www.douyu.com/api/passport/login?...&loginType=safeAuth 的一次性登录 URL +// 2. 带 LTP0 跟一跳该 URL -> 下发全套新的 acf_* 登录 cookie(safeAuth 不会重发 LTP0) +// 3. 用 www.douyu.com/wgapi/livenc/liveweb/follow/top3 探针校验 HTTP 200 且业务 error==0 => 登录态有效 +// (关注主播此刻可能都没开播,room_list 为空同样算有效) +package douyu + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/tidwall/gjson" +) + +// ErrLoginInvalid 表示"凭证本身失效"(LTP0 过期、换票缺字段、探针判定未登录), +// 需要用户重新扫码;区别于网络抖动等临时性错误(不应据此打扰用户)。 +var ErrLoginInvalid = errors.New("斗鱼登录凭证已失效") + +// safeAuthUrl/probeUrl 声明为 var 而非 const:两者都是"续期是否真的成功"的唯一判定点, +// 需要在单元测试里指向 httptest 服务器,才能覆盖风控页/非 JSON 响应等真实故障形态。 +var ( + safeAuthUrl = "https://passport.douyu.com/lapi/passport/iframe/safeAuth" + probeUrl = "https://www.douyu.com/wgapi/livenc/liveweb/follow/top3" +) + +const ( + // cookieRenewInterval 主站 acf_* 实际约 6 天有效(Set-Cookie Max-Age≈529200s), + // 取 3 天续期一次,留足一倍缓冲,兼顾"不频繁轮换"与"宕机后仍能赶在过期前补续"。 + cookieRenewInterval = 3 * 24 * time.Hour + // renewRetryBackoff 换票失败(LTP0 疑似已失效)时的重试退避,避免每小时疯狂打接口。 + renewRetryBackoff = 6 * time.Hour + // renewInvalidRetryBackoff 已经判定票据失效(NeedRescan 已置位)之后的重试退避。 + // 6 小时那一档是为"可能只是一次风控误判"留的自愈窗口;但用户不会在几小时内重扫, + // 若一直用同一张已判死的票据每 6 小时打一次 safeAuth,就是每天 4 次反复敲登录类接口 + // (正是最容易触发风控的那一类),同时每天 4 次重写 config.yml。 + renewInvalidRetryBackoff = 24 * time.Hour + // renewEscalateAfter 网络类失败的静默上限:主站 cookie Max-Age≈6.1 天, + // 距上次成功超过这个天数还续不上,就不再是"稍后再试",登录态必然已过期。 + renewEscalateAfter = 6 * 24 * time.Hour + // mainSiteCookieMaxAge 是主站 acf_* 登录 cookie 的有效期(实测 Set-Cookie Max-Age=529200s≈6.1 天)。 + mainSiteCookieMaxAge = 529200 * time.Second + // deadCookieMargin 判定"这份 cookie 确实已经过期"时额外留的余量。 + // 必须留:有效期是 6.1 天,若按 6 天整数就认定过期,会在最后几个小时里 + // 把一份**还能用**的登录态主动降级成匿名(丢清晰度、丢权益)。 + deadCookieMargin = 24 * time.Hour +) + +// ShouldEscalateTempFailure 判断一次"临时性失败"是否应升级为需重新扫码的提醒。 +// lastSuccessAt==0 表示该字段引入后还从未自动续期成功过,此时不升级(避免对历史配置 +// 凭空报错);一旦某次续期成功写入该时间戳,判定即生效。 +func ShouldEscalateTempFailure(lastSuccessAt int64, now time.Time) bool { + if lastSuccessAt == 0 { + return false + } + return now.Unix()-lastSuccessAt > int64(renewEscalateAfter/time.Second) +} + +// ShouldRenewNow 判断本次是否该真正发起换票。续期日程(NextRefreshAt)是相对当前时钟写下的绝对时间戳, +// 一旦系统时钟被回拨或纠正(时区修正、NTP 校正、长期睡眠的虚拟机),"还没到点"就成了假信号: +// 按日程还要等好几天,而主站 cookie 的 6 天有效期是按真实流逝的时间在耗,结果静默退回匿名录制。 +// 因此除了"到点",再允许两个提前理由:日程基准落在未来(时钟被改过)、距上次成功已超过整个有效期。 +// 后者复用 renewEscalateAfter,避免与"临时失败升级"各存一个阈值而漂移。 +// lastSuccessAt==0 时不参与提前判断:那只是引入该字段前的历史配置,否则会绕过 6 小时退避反复打接口。 +// +// 两个提前理由都必须让位给"最近刚写下过的退避日程":退避时间是 now+6h(临时失败)或 now+24h +// (已判定票据失效),落在最长那一档之内说明这是上一次失败后特意安排的下次重试点。 +// 若此时仍按"时钟异常/距上次成功过久"提前换票,safeAuth 这种登录类接口就会被每小时的检查循环 +// 反复调用(正是退避想避免的),且有触发风控的风险。 +// 成功后的日程是 now+3 天,远在窗口之外,所以时钟被回拨时的兜底逻辑不受影响。 +func ShouldRenewNow(nextRefreshAt, lastSuccessAt int64, now time.Time) bool { + if nextRefreshAt == 0 { + return true + } + if now.Unix() >= nextRefreshAt { + return true + } + if lastSuccessAt == 0 { + return false + } + if nextRefreshAt-now.Unix() <= int64(renewInvalidRetryBackoff/time.Second) { + return false + } + return now.Unix() < lastSuccessAt || now.Unix()-lastSuccessAt > int64(renewEscalateAfter/time.Second) +} + +// NextRenewAt 返回续期成功后"下次应续期"的 Unix 秒时间戳。 +func NextRenewAt(now time.Time) int64 { return now.Add(cookieRenewInterval).Unix() } + +// RenewBackoffAt 返回换票失败后"下次重试"的 Unix 秒时间戳(较短退避)。 +func RenewBackoffAt(now time.Time) int64 { return now.Add(renewRetryBackoff).Unix() } + +// RenewInvalidRetryAt 返回"已判定失效(NeedRescan 已置位)之后下次重试"的 Unix 秒时间戳(较长退避)。 +func RenewInvalidRetryAt(now time.Time) int64 { return now.Add(renewInvalidRetryBackoff).Unix() } + +// LoginCookieKnownExpired 判断"这份主站登录 cookie 是否已有确凿证据表明彻底失效": +// 必须同时满足「存有长期凭证(说明这份登录态本来该被自动续期)」与 +// 「距上次拿到新鲜登录态已超过主站 cookie 的整个有效期 + 余量」。 +// +// 为什么需要它:斗鱼的 cookie 与签名请求里我们一律"有值就发",而一份确凿过期的 acf_* +// 再发出去不可能换到任何登录权益,只可能带来更坏的结果(服务端把"带非法登录态的请求" +// 整条拒掉,表现为房间完全录不上,而不是退回匿名流)。所以到了这个判定就主动停用, +// 让行为回到"从未登录过"那条已被验证过的路径上。 +// +// 之所以不看 NeedRescan:进程停摆十天后重启,盘上标记仍是"健康",但那份 cookie 显然已过期, +// 此时后台还在 30 秒后才第一次尝试换票,录制侧不该继续拿它当有效登录态用。 +// 反过来,没有 LTP0 就没有"本该续期却续不上"的证据,那份手填 cookie 无从判断,照旧发出。 +func LoginCookieKnownExpired(ltp0 string, lastSuccessAt int64, now time.Time) bool { + if ltp0 == "" || lastSuccessAt == 0 { + return false + } + return now.Unix()-lastSuccessAt > int64((mainSiteCookieMaxAge+deadCookieMargin)/time.Second) +} + +// RenewEscalateAfter 返回"临时失败该升级为失效提醒"的时间阈值。 +func RenewEscalateAfter() time.Duration { return renewEscalateAfter } + +// refreshRequiredKeys 换票后判定"登录态成功"的必需字段集(实测抓包:缺任一字段即视为未真正登录) +var refreshRequiredKeys = []string{"acf_uid", "acf_auth", "acf_stk", "acf_ltkid", "acf_username", "acf_biz", "acf_ct"} + +// RefreshLoginCookie 用 passport 域长期凭证 LTP0 通过 safeAuth 换取全新的主站登录 cookie。 +// 返回字段级 map(仅含本次换到的 cookie 字段);调用方负责与已有 Cookies 合并写回。 +// 若必需字段缺失或探针判定登录态无效则返回 error(通常意味着 LTP0 也已过期,需重新扫码)。 +func RefreshLoginCookie(ctx context.Context, ltp0, dyDid string) (map[string]string, error) { + if strings.TrimSpace(ltp0) == "" { + return nil, fmt.Errorf("缺少 LTP0,无法续期") + } + cookie := "LTP0=" + ltp0 + if dyDid != "" { + cookie = "dy_did=" + dyDid + "; " + cookie + } + ts := fmt.Sprintf("%d", time.Now().UnixMilli()) + start := fmt.Sprintf("%s?client_id=1&t=%s&_=%s&callback=cb", safeAuthUrl, ts, ts) + + fields, status, err := followExchangeChain(ctx, start, cookie) + if err != nil { + return nil, err + } + // 只有终点是 200 的响应才可能携带 acf_*;4xx/5xx(风控、限流、接口变更)与"LTP0 过期"无关, + // 必须按临时性错误返回,否则会因为"字段缺失"误判凭证失效而推送假的重扫提醒。 + // status==0 表示跳数用尽仍未落到终态响应,同样按临时错误处理。 + if status != http.StatusOK { + return nil, fmt.Errorf("safeAuth 换票链未正常结束(终点 HTTP %d),暂不判定登录态是否失效", status) + } + var missing []string + for _, k := range refreshRequiredKeys { + if fields[k] == "" { + missing = append(missing, k) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("%w:换票响应缺少必需字段 %v,LTP0 可能已过期", ErrLoginInvalid, missing) + } + if err := verifyLoginCookie(ctx, cookieString(fields)); err != nil { + return nil, err + } + // 设备号原样带回,"要不要落盘"由写回侧决定(见 ApplyDeviceIDPolicy): + // safeAuth 换票时我们不带站点设备号,斗鱼这次可能顺手签发一个全新随机 dy_did/acf_devid, + // 它既可能是唯一可用的设备号来源,也可能是每 3 天轮换一次的诱因,只有写回侧看得见现有 cookie。 + return fields, nil +} + +// followExchangeChain 从 safeAuth 起始 URL 手动逐跳跟随 302(每跳都带 LTP0 cookie), +// 累积中间跳与终点跳下发的 Set-Cookie。斗鱼在 302 Location 的下一跳才真正下发 acf_*。 +// 额外返回终点状态码(0 表示跳数用尽仍未落到终点),供调用方区分"接口异常"与"凭证失效"。 +func followExchangeChain(ctx context.Context, startURL, cookie string) (map[string]string, int, error) { + fields := make(map[string]string) + cur := startURL + finalStatus := 0 + for hop := 0; hop < 8; hop++ { + req, err := http.NewRequestWithContext(ctx, "GET", cur, nil) + if err != nil { + return nil, 0, err + } + req.Header.Set("User-Agent", douyuWebUAString) + req.Header.Set("Referer", "https://www.douyu.com/") + req.Header.Set("Cookie", cookie) + resp, err := douyuHTTPClient.Do(req) + if err != nil { + return nil, 0, err + } + for _, c := range resp.Cookies() { + // 空值 Set-Cookie 是"清除该 cookie"的写法:若后一跳把前一跳刚拿到的字段清成空串, + // 会让必需字段校验误判为"LTP0 已过期"、或把配置里已有的好值覆盖掉。 + if c.Value == "" { + continue + } + fields[c.Name] = c.Value + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + if loc == "" { + finalStatus = resp.StatusCode + break + } + next, err := resolveDouyuRedirect(cur, loc) + if err != nil { + return nil, 0, err + } + cur = next + continue + } + finalStatus = resp.StatusCode + io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + break + } + return fields, finalStatus, nil +} + +// resolveDouyuRedirect 解析(可能为 //host 或相对形式的)Location,并做斗鱼域名白名单校验,防 SSRF。 +func resolveDouyuRedirect(base, loc string) (string, error) { + if strings.HasPrefix(loc, "//") { + loc = "https:" + loc + } + bu, err := url.Parse(base) + if err != nil { + return "", err + } + lu, err := url.Parse(loc) + if err != nil { + return "", err + } + ref := bu.ResolveReference(lu) + if err := checkDouyuURL(ref.String()); err != nil { + return "", err + } + return ref.String(), nil +} + +// verifyLoginCookie 用换到的 cookie 打关注房间探针,确认登录态真实有效。 +// 判定标准:HTTP 200 且响应 JSON 里确实带 error 字段、其值为 0 才算已登录 +// (关注主播此刻可能都没开播,room_list 为空同样算有效)。 +// 探针返回明确的"未登录"业务码 -> 归为 ErrLoginInvalid;HTTP/网络异常/响应不是预期 JSON +// -> 视为临时错误。特别注意:风控页会以 HTTP 200 + HTML 形态返回,此时 error 字段不存在, +// 若按"缺省即 0"判定就会把无效 cookie 当成功落盘并清掉失效标记,导致数日静默退回匿名录制。 +func verifyLoginCookie(ctx context.Context, cookie string) error { + req, err := http.NewRequestWithContext(ctx, "GET", probeUrl, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", douyuWebUAString) + req.Header.Set("Referer", "https://www.douyu.com/") + req.Header.Set("Cookie", cookie) + resp, err := douyuHTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("续期后探针 HTTP %d(临时性错误)", resp.StatusCode) + } + errField := gjson.GetBytes(body, "error") + if !errField.Exists() { + return fmt.Errorf("续期后探针响应不含 error 字段(疑似风控页或接口变更),暂不判定登录态有效, body=%s", truncateForLog(body)) + } + if e := errField.Int(); e != 0 { + return fmt.Errorf("%w:探针 error=%d, msg=%q", ErrLoginInvalid, e, gjson.GetBytes(body, "msg").String()) + } + return nil +} + +// cookieString 把字段 map 拼成 "k1=v1; k2=v2" 形式的 cookie 串(按 key 排序,保证输出稳定) +func cookieString(fields map[string]string) string { + return strings.Join(sortedCookiePairs(fields), "; ") +} + +// sortedCookiePairs 按字段名有序地输出 "k=v" 列表。 +// map 遍历顺序随机,不排序会让每次续期都把 config.yml 里的 cookie 整行重排,产生无意义的配置 diff。 +func sortedCookiePairs(fields map[string]string) []string { + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+fields[k]) + } + return parts +} diff --git a/src/live/douyu/refresh_test.go b/src/live/douyu/refresh_test.go new file mode 100644 index 0000000..fc8ea8b --- /dev/null +++ b/src/live/douyu/refresh_test.go @@ -0,0 +1,373 @@ +package douyu + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// 续期链的每一跳都要过域名白名单,这里把可接受/可拒绝的形态固定下来(防 SSRF) +func TestCheckDouyuURL(t *testing.T) { + cases := []struct { + raw string + wantErr bool + }{ + {"https://www.douyu.com/api/passport/login?a=1", false}, + {"https://douyu.com/", false}, + {"https://passport.douyu.com/lapi/passport/iframe/safeAuth", false}, + {"http://www.douyu.com/", true}, // 明文一律拒绝,LTP0 不允许裸传输 + {"https://douyu.com.evil.com/x", true}, // 后缀伪装 + {"https://evil.com/#douyu.com", true}, // 片段里藏域名 + {"https://douyu.com:8443/@evil.com", true}, // 带端口的 host 不匹配白名单,保守拒绝 + {"ftp://www.douyu.com/", true}, + {"://bad", true}, + } + for _, c := range cases { + if err := checkDouyuURL(c.raw); (err != nil) != c.wantErr { + t.Errorf("checkDouyuURL(%q) err=%v 期望报错=%v", c.raw, err, c.wantErr) + } + } +} + +func TestResolveDouyuRedirect(t *testing.T) { + const base = "https://passport.douyu.com/lapi/passport/iframe/safeAuth" + // 斗鱼 safeAuth 的 Location 实测是协议相对形式 //www.douyu.com/... + got, err := resolveDouyuRedirect(base, "//www.douyu.com/api/passport/login?uid=1") + if err != nil || got != "https://www.douyu.com/api/passport/login?uid=1" { + t.Fatalf("协议相对 Location 解析错误: %q err=%v", got, err) + } + got, err = resolveDouyuRedirect(base, "/lapi/passport/other") + if err != nil || got != "https://passport.douyu.com/lapi/passport/other" { + t.Fatalf("相对 Location 解析错误: %q err=%v", got, err) + } + if _, err := resolveDouyuRedirect(base, "https://evil.com/cb"); err == nil { + t.Error("跨域 Location 未被拒绝") + } + if _, err := resolveDouyuRedirect(base, "javascript:alert(1)"); err == nil { + t.Error("非 http(s) Location 未被拒绝") + } +} + +func TestCookieValueAndFromList(t *testing.T) { + ck := "acf_uid=42; LTP0=long.ticket-value; empty=" + if v := cookieValue(ck, "acf_uid"); v != "42" { + t.Errorf("cookieValue=%q", v) + } + if v := cookieValue(ck, "missing"); v != "" { + t.Errorf("不存在的字段应为空串, got=%q", v) + } + list := []*http.Cookie{{Name: "LTP0", Value: "abc"}, {Name: "dy_did", Value: "did"}} + if v := cookieFromList(list, "dy_did"); v != "did" { + t.Errorf("cookieFromList=%q", v) + } + if v := cookieFromList(list, "nope"); v != "" { + t.Errorf("cookieFromList 缺失字段应为空串, got=%q", v) + } +} + +// 缺 LTP0 属于"根本无法续期",必须是普通错误而非"凭证失效",避免误报"登录已失效" +func TestRefreshLoginCookieWithoutLTP0(t *testing.T) { + _, err := RefreshLoginCookie(context.Background(), " ", "") + if err == nil { + t.Fatal("期望返回错误") + } + if errors.Is(err, ErrLoginInvalid) { + t.Error("缺少 LTP0 不应归类为凭证失效(此时应静默跳过,不提醒用户)") + } +} + +// 扫码结果的序列化面:cookie 与长期凭证绝不能进 JSON(前端只需 state/uid/nickname)。 +// handler 里另有"下发前置空"的第二道保险,这里锁住结构体本身。 +func TestQRPollResultHidesCredentials(t *testing.T) { + b, err := json.Marshal(QRPollResult{ + State: QRStateSuccess, + Nickname: "nick", + UserID: "42", + Cookie: "acf_auth=secret", + LTP0: "LTP0=secret-ticket", + DyDid: "dy_did=secret-device", + }) + if err != nil { + t.Fatal(err) + } + got := string(b) + for _, forbidden := range []string{"secret", "ltp0", "cookie", "dy_did"} { + if strings.Contains(strings.ToLower(got), forbidden) { + t.Errorf("响应体泄露了 %q: %s", forbidden, got) + } + } + if !strings.Contains(got, `"state":"success"`) || !strings.Contains(got, `"uid":"42"`) { + t.Errorf("状态字段丢失: %s", got) + } +} + +func TestStripJSONP(t *testing.T) { + if got := stripJSONP("cb({\"error\":0,\"data\":{\"url\":\"x\"}});"); got != `{"error":0,"data":{"url":"x"}}` { + t.Errorf("stripJSONP=%q", got) + } + if got := stripJSONP("no json here"); got != "" { + t.Errorf("无 JSON 时应返回空串, got=%q", got) + } +} + +// withProbeURL 把探针地址临时指向测试服务器(probeUrl 声明为 var 正是为此) +func withProbeURL(t *testing.T, url string) { + t.Helper() + old := probeUrl + probeUrl = url + t.Cleanup(func() { probeUrl = old }) +} + +// 探针是"换到的 cookie 是否真的可用"的唯一依据,必须失败关闭: +// 斗鱼风控会以 HTTP 200 + HTML 形态返回,若把缺失的 error 字段当成 0 就误报成功, +// 后台随即清掉失效标记并把无效 cookie 再用三天——全程无通知,录制静默退回匿名、约 5 分钟断一次。 +func TestVerifyLoginCookieIsFailClosed(t *testing.T) { + var status int32 = http.StatusOK + var body atomic.Value + body.Store(`{"error":0}`) + var gotCookie atomic.Value + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCookie.Store(r.Header.Get("Cookie")) + w.WriteHeader(int(atomic.LoadInt32(&status))) + _, _ = io.WriteString(w, body.Load().(string)) + })) + t.Cleanup(srv.Close) + withProbeURL(t, srv.URL) + + cases := []struct { + name string + httpStatus int32 + respBody string + wantErr bool + wantInvalid bool // 是否应归类为"凭证失效"(需扫码),否则视为临时错误 + }{ + {name: "已登录", respBody: `{"error":0,"data":{"room_list":[]}}`}, + {name: "业务码未登录", respBody: `{"error":-3,"msg":"未登录"}`, wantErr: true, wantInvalid: true}, + {name: "200加HTML风控页", respBody: "verify", wantErr: true}, + {name: "200加JSON无error字段", respBody: `{"code":0}`, wantErr: true}, + {name: "200加空响应体", respBody: "", wantErr: true}, + {name: "服务端500", httpStatus: 500, respBody: `{"error":0}`, wantErr: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + st := c.httpStatus + if st == 0 { + st = http.StatusOK // 用例里省略即表示 200 + } + atomic.StoreInt32(&status, st) + body.Store(c.respBody) + err := verifyLoginCookie(context.Background(), "acf_uid=1; acf_auth=x") + if (err != nil) != c.wantErr { + t.Fatalf("err=%v 期望报错=%v", err, c.wantErr) + } + if err != nil && c.wantInvalid != errors.Is(err, ErrLoginInvalid) { + t.Fatalf("错误归类不对: err=%v 是否凭证失效=%v 期望=%v", err, errors.Is(err, ErrLoginInvalid), c.wantInvalid) + } + if err == nil && gotCookie.Load().(string) == "" { + t.Error("探针请求未携带待验证的 cookie") + } + }) + } +} + +// 后一跳用空值清除前一跳刚拿到的字段时不得覆盖有效值, +// 否则必需字段校验会把仍然有效的 LTP0 误判为已过期,让用户白扫一次码。 +func TestFollowExchangeChainIgnoresEmptyCookies(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "acf_auth", Value: "good.value"}) + http.SetCookie(w, &http.Cookie{Name: "acf_stk", Value: ""}) // 清除写法 + http.SetCookie(w, &http.Cookie{Name: "acf_uid", Value: "42"}) + })) + t.Cleanup(srv.Close) + + fields, _, err := followExchangeChain(context.Background(), srv.URL, "LTP0=x") + if err != nil { + t.Fatal(err) + } + if fields["acf_auth"] != "good.value" || fields["acf_uid"] != "42" { + t.Fatalf("有效字段丢失: %+v", fields) + } + if _, ok := fields["acf_stk"]; ok { + t.Errorf("空值 Set-Cookie 不应写入字段表: %+v", fields) + } +} + +// 换票链终点不是 200(风控 403、网关 500)时响应里必然没有 acf_*。 +// 若按"缺字段"判凭证失效,用户会收到一条"登录已失效请重新扫码"的假告警, +// 而实际只是斗鱼接口瞬时异常——正确处置是按临时错误静默退避。 +func TestRefreshLoginCookieClassifiesChainStatus(t *testing.T) { + cases := []struct { + name string + finalStatus int + wantInvalid bool + }{ + {name: "终点500属临时错误", finalStatus: http.StatusInternalServerError}, + {name: "终点403属临时错误", finalStatus: http.StatusForbidden}, + {name: "终点200缺字段属失效", finalStatus: http.StatusOK, wantInvalid: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "follow/top3") { + _, _ = io.WriteString(w, `{"error":0,"data":{"room_list":[]}}`) + return + } + w.WriteHeader(c.finalStatus) + })) + t.Cleanup(srv.Close) + withRefreshURLs(t, srv.URL) + + _, err := RefreshLoginCookie(context.Background(), "some.ticket", "") + if err == nil { + t.Fatal("换票链异常时不应报告成功") + } + if errors.Is(err, ErrLoginInvalid) != c.wantInvalid { + t.Fatalf("错误归类不对: err=%v 判为凭证失效=%v 期望=%v", err, errors.Is(err, ErrLoginInvalid), c.wantInvalid) + } + }) + } +} + +// 302 但没带 Location 时链条就此中断:拿到的状态码必须是这一个 3xx, +// 让调用方按临时错误处理,而不是把"没有终态响应"当成 200 后判成凭证失效。 +func TestFollowExchangeChainReportsNonTerminalStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusFound) // 故意不设 Location + })) + t.Cleanup(srv.Close) + _, status, err := followExchangeChain(context.Background(), srv.URL, "LTP0=x") + if err != nil { + t.Fatal(err) + } + if status != http.StatusFound { + t.Fatalf("应报告中断处的 302, got %d", status) + } +} + +// cookie 串顺序稳定是 config.yml 不产生无意义 diff 的前提(map 遍历本身是随机的) +func TestCookieStringIsStable(t *testing.T) { + fields := map[string]string{"acf_uid": "1", "acf_auth": "x", "acf_stk": "y"} + first := cookieString(fields) + for i := 0; i < 50; i++ { + if got := cookieString(fields); got != first { + t.Fatalf("第 %d 次拼接不一致:\n%s\n%s", i, first, got) + } + } + if !strings.HasPrefix(first, "acf_auth=") { + t.Errorf("应按字段名升序拼接, got=%s", first) + } +} + +// withRefreshURLs 把换票起始地址与探针地址一并指向测试服务器(两者声明为 var 正是为此) +func withRefreshURLs(t *testing.T, base string) { + t.Helper() + oldSafe, oldProbe := safeAuthUrl, probeUrl + safeAuthUrl = base + "/lapi/passport/iframe/safeAuth" + probeUrl = base + "/wgapi/livenc/liveweb/follow/top3" + t.Cleanup(func() { + safeAuthUrl, probeUrl = oldSafe, oldProbe + }) +} + +// 换票时我们不带站点设备号,斗鱼会顺手签发一个全新的随机 dy_did/acf_devid。 +// 续期侧只负责原样带回(探针也要用这套字段),"丢还是落盘"由写回侧单点决定: +// 现有 cookie 已有设备号则挡掉这次的新值,一个都没有则补种,见 ApplyDeviceIDPolicy。 +// 若在此处就剥离,写回侧将无法区分"上游没发设备号"与"上游发了但要挡",缺失场景补种不了。 +func TestRefreshLoginCookieReturnsDeviceIDForWriteBackPolicy(t *testing.T) { + var probeCookie atomic.Value + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "follow/top3") { + probeCookie.Store(r.Header.Get("Cookie")) + _, _ = io.WriteString(w, `{"error":0,"data":{"room_list":[]}}`) + return + } + for _, kv := range [][2]string{ + {"acf_uid", "42"}, {"acf_auth", "auth"}, {"acf_stk", "stk"}, {"acf_ltkid", "ltk"}, + {"acf_username", "uname"}, {"acf_biz", "biz"}, {"acf_ct", "ct"}, + {"dy_did", "fresh-random-did"}, {"acf_devid", "fresh-random-devid"}, + } { + http.SetCookie(w, &http.Cookie{Name: kv[0], Value: kv[1]}) + } + })) + t.Cleanup(srv.Close) + withRefreshURLs(t, srv.URL) + + fields, err := RefreshLoginCookie(context.Background(), "valid.ticket", "") + if err != nil { + t.Fatal(err) + } + for _, k := range refreshRequiredKeys { + if fields[k] == "" { + t.Errorf("必需字段 %s 丢失", k) + } + } + // 换票侧只负责"原样带回",设备号要不要落盘由写回侧单点决定(见 ApplyDeviceIDPolicy): + // 现有 cookie 已有设备号则挡掉这次的新值,一个都没有则补种。 + for _, kv := range [][2]string{{"dy_did", "fresh-random-did"}, {"acf_devid", "fresh-random-devid"}} { + if fields[kv[0]] != kv[1] { + t.Errorf("设备号字段 %s 应原样带回, got=%q", kv[0], fields[kv[0]]) + } + } + if pc, _ := probeCookie.Load().(string); !strings.Contains(pc, "dy_did=fresh-random-did") { + t.Errorf("探针应在写回侧取舍之前完成校验, got=%q", pc) + } +} + +// 网络类失败默认静默退避,但连续失败到 cookie 必然过期时必须升级为"需重新扫码", +// 否则用户侧表现为长期匿名录制(约 5 分钟断一次)却任何提示都没有。 +func TestShouldEscalateTempFailure(t *testing.T) { + now := time.Unix(1893456000, 0) + day := int64(24 * time.Hour / time.Second) + cases := []struct { + name string + lastSuccess int64 + want bool + }{ + {"字段引入前从未成功过:不据此报错", 0, false}, + {"3 天前刚续期成功:仍按临时错误退避", now.Add(-3 * 24 * time.Hour).Unix(), false}, + {"刚到 6 天边界:尚未确定过期", now.Add(-6*24*time.Hour + time.Hour).Unix(), false}, + {"超过 cookie 整个有效期:升级为失效提醒", now.Add(-7 * 24 * time.Hour).Unix(), true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := ShouldEscalateTempFailure(c.lastSuccess, now); got != c.want { + t.Fatalf("升级=%v 期望=%v(距上次成功 %d 天)", got, c.want, (now.Unix()-c.lastSuccess)/day) + } + }) + } +} + +// 续期日程是相对时钟写下的绝对时间戳:时钟回拨/纠正后"未到点"就成了假信号, +// 而 cookie 有效期按真实时间流逝,静默过期就是最坏的失效形态。 +func TestShouldRenewNow(t *testing.T) { + now := time.Unix(1893456000, 0) + day := 24 * time.Hour + cases := []struct { + name string + next, last int64 + want bool + }{ + {"历史配置无日程:立即续一次以自举", 0, 0, true}, + {"正常到点", now.Unix(), now.Add(-3 * day).Unix(), true}, + {"日程已过", now.Add(-time.Hour).Unix(), now.Add(-3 * day).Unix(), true}, + {"未到点且基准可信:尊重日程", now.Add(2 * day).Unix(), now.Add(-1 * day).Unix(), false}, + {"刚失败退避 6 小时:不被时钟理由绕过", now.Add(5 * time.Hour).Unix(), now.Add(-time.Hour).Unix(), false}, + {"时钟回拨到上次成功之前:日程不可信", now.Add(2 * day).Unix(), now.Add(time.Hour).Unix(), true}, + {"时钟前进后回正:距上次成功超有效期", now.Add(2 * day).Unix(), now.Add(-7 * day).Unix(), true}, + {"无成功时间戳的历史配置:只看到点", now.Add(2 * day).Unix(), 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := ShouldRenewNow(c.next, c.last, now); got != c.want { + t.Fatalf("续期=%v 期望=%v (next=%d last=%d now=%d)", got, c.want, c.next, c.last, now.Unix()) + } + }) + } +} diff --git a/src/notify/send.go b/src/notify/send.go index 48bfbc0..31b131b 100644 --- a/src/notify/send.go +++ b/src/notify/send.go @@ -385,6 +385,17 @@ func SendPipelineRecordingSummary( sendToAllChannels(cfg, logger, title, body) } +// SendSystemAlert 发送一条"系统级"告警(非房间开播/下播事件), +// 走用户已配置的全部通知渠道(Telegram/Email/Bark/ntfy/wxpusher)。 +// 未配置任何渠道时静默无操作。用于如"斗鱼登录失效需重新扫码"这类全局提醒。 +func SendSystemAlert(title, body string) { + cfg := configs.GetCurrentConfig() + if cfg == nil { + return + } + sendToAllChannels(cfg, livelogger.New(0, nil), title, body) +} + // sendToAllChannels 向所有已启用的通知通道推送摘要消息 func sendToAllChannels(cfg *configs.Config, logger *livelogger.LiveLogger, title, body string) { // Telegram diff --git a/src/servers/bilibili_login_test.go b/src/servers/bilibili_login_test.go new file mode 100644 index 0000000..3aa66a5 --- /dev/null +++ b/src/servers/bilibili_login_test.go @@ -0,0 +1,72 @@ +package servers + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +// decodePollData 解出轮询响应的 data 字段。 +func decodePollData(t *testing.T, body []byte) map[string]interface{} { + t.Helper() + var parsed struct { + Data map[string]interface{} `json:"data"` + } + assert.NoError(t, json.Unmarshal(body, &parsed)) + return parsed.Data +} + +func TestAttachBilibiliLoginCookiesOnSuccess(t *testing.T) { + body := []byte(`{"code":0,"message":"OK","data":{"code":0,"message":"成功","url":"https://www.bilibili.com","refresh_token":"rt-1","timestamp":1700000000}}`) + + out := attachBilibiliLoginCookies(body, []*http.Cookie{ + {Name: "SESSDATA", Value: "sd-value"}, + {Name: "bili_jct", Value: "jct-value"}, + {Name: "DedeUserID", Value: "12345"}, + {Name: "sid", Value: "sid-value"}, + {Name: "buvid3", Value: "tracking-should-be-dropped"}, + }) + + data := decodePollData(t, out) + cookies, ok := data["cookies"].(map[string]interface{}) + assert.True(t, ok, "登录成功时应回传 data.cookies") + assert.Equal(t, "sd-value", cookies["SESSDATA"]) + assert.Equal(t, "jct-value", cookies["bili_jct"]) + assert.Equal(t, "12345", cookies["DedeUserID"]) + assert.NotContains(t, cookies, "buvid3", "埋点字段不应写入配置") + + // 上游原有字段必须保留:这里曾是结构体重新序列化,会静默丢掉 refresh_token/timestamp + assert.Equal(t, "rt-1", data["refresh_token"]) + assert.NotEmpty(t, data["timestamp"]) +} + +func TestAttachBilibiliLoginCookiesKeepsPendingResponse(t *testing.T) { + body := []byte(`{"code":0,"message":"OK","ttl":1,"data":{"code":86101,"message":"未扫码","url":"","refresh_token":"","timestamp":0}}`) + + out := attachBilibiliLoginCookies(body, []*http.Cookie{{Name: "SESSDATA", Value: "leak"}}) + + assert.Equal(t, string(body), string(out), "未扫码时应原样透传") + assert.NotContains(t, decodePollData(t, out), "cookies") +} + +func TestAttachBilibiliLoginCookiesPrefersSetCookieOverURLQuery(t *testing.T) { + body := []byte(`{"code":0,"data":{"code":0,"url":"https://www.bilibili.com?SESSDATA=from-url&sid=from-url"}}`) + + out := attachBilibiliLoginCookies(body, []*http.Cookie{{Name: "SESSDATA", Value: "from-header"}}) + + cookies := decodePollData(t, out)["cookies"].(map[string]interface{}) + assert.Equal(t, "from-header", cookies["SESSDATA"]) + assert.Equal(t, "from-url", cookies["sid"]) +} + +func TestAttachBilibiliLoginCookiesPreservesLargeIntegers(t *testing.T) { + // 9007199254740993 = 2^53+1,已超出 float64 的精确整数范围: + // 若按默认 float64 解析再序列化,会被静默舍入成 9007199254740992 + body := []byte(`{"code":0,"data":{"code":0,"url":"https://www.bilibili.com","bp_offset":9007199254740993}}`) + + out := attachBilibiliLoginCookies(body, []*http.Cookie{{Name: "SESSDATA", Value: "sd"}}) + + assert.Contains(t, string(out), `"bp_offset":9007199254740993`, "大整数必须逐字节保留") +} diff --git a/src/servers/douyu_cookie_api_test.go b/src/servers/douyu_cookie_api_test.go new file mode 100644 index 0000000..96e6156 --- /dev/null +++ b/src/servers/douyu_cookie_api_test.go @@ -0,0 +1,463 @@ +package servers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/bililive-go/bililive-go/src/configs" + "github.com/bililive-go/bililive-go/src/instance" + "github.com/bililive-go/bililive-go/src/live" + dy "github.com/bililive-go/bililive-go/src/live/douyu" + "github.com/bililive-go/bililive-go/src/pkg/livelogger" + "github.com/bililive-go/bililive-go/src/types" +) + +func ctxWithInstance(inst *instance.Instance) context.Context { + return context.WithValue(context.Background(), instance.Key, inst) +} + +func writableConfig(t *testing.T) *configs.Config { + t.Helper() + cfg := configs.NewConfig() + cfg.File = filepath.Join(t.TempDir(), "config.yml") + configs.SetCurrentConfig(cfg) + return cfg +} + +// /api/cookies 是"手改登录态"的入口。斗鱼分支必须只在换账号或显式退出时作废长期凭证: +// 同账号补/删字段很常见,顺手清票据会让自动续期从此静默失效(用户只看到"过几天又断流")。 +func TestPutLiveHostCookieDouyuBranches(t *testing.T) { + const ticket = "ticket.value" + put := func(t *testing.T, host, cookie string) *configs.Config { + t.Helper() + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + cfg := writableConfig(t) + cfg.Cookies = map[string]string{dy.CookieHost: "acf_uid=111; acf_auth=old; dy_did=keep-did"} + cfg.DouyuAuth = configs.DouyuAuth{LTP0: ticket, DyDid: "scan-did", NextRefreshAt: 1893456000, LastRenewSuccessAt: 1893000000, NeedRescan: true} + configs.SetCurrentConfig(cfg) + + req := httptest.NewRequest(http.MethodPost, "/api/cookies/"+host, strings.NewReader(`{"Host":"`+host+`","Cookie":"`+cookie+`"}`)) + req = req.WithContext(ctxWithInstance(&instance.Instance{Ctx: context.Background()})) + rec := httptest.NewRecorder() + putLiveHostCookie(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("保存失败: code=%d body=%s", rec.Code, rec.Body.String()) + } + return configs.GetCurrentConfig() + } + + t.Run("同账号微调保留票据并剥离 LTP0", func(t *testing.T) { + got := put(t, dy.CookieHost, "acf_uid=111; acf_auth=new; LTP0=leaky.ticket") + if got.DouyuAuth.LTP0 != ticket { + t.Errorf("同账号修改不应作废长期凭证: %q", got.DouyuAuth.LTP0) + } + stored := got.Cookies[dy.CookieHost] + if strings.Contains(stored, "LTP0") { + t.Errorf("浏览器导出的 LTP0 未被剥离: %q", stored) + } + if !strings.Contains(stored, "acf_auth=new") { + t.Errorf("新 cookie 未落盘: %q", stored) + } + }) + + t.Run("换成另一账号的 cookie 时作废票据", func(t *testing.T) { + got := put(t, dy.CookieHost, "acf_uid=222; acf_auth=other") + if got.DouyuAuth.LTP0 != "" || got.DouyuAuth.DyDid != "" { + t.Errorf("换账号后长期凭证未清除: %+v", got.DouyuAuth) + } + }) + + t.Run("存一份未登录 cookie 视为退出登录", func(t *testing.T) { + got := put(t, dy.CookieHost, "PHPSESSID=anon") + if got.DouyuAuth.LTP0 != "" { + t.Errorf("显式退出后票据未清除: %q", got.DouyuAuth.LTP0) + } + }) + + t.Run("别名 host 也要走斗鱼分支", func(t *testing.T) { + got := put(t, "m.douyu.com", "acf_uid=222; acf_auth=other") + if got.DouyuAuth.LTP0 != "" { + t.Errorf("别名 host 未走斗鱼分支(票据未清): %+v", got.DouyuAuth) + } + if _, ok := got.Cookies["m.douyu.com"]; ok { + t.Errorf("别名键未归一,房间将查不到 cookie: %v", got.Cookies) + } + }) + + t.Run("他平台 host 不受斗鱼分支影响", func(t *testing.T) { + got := put(t, "live.bilibili.com", "SESSDATA=abc") + if got.DouyuAuth.LTP0 != ticket { + t.Errorf("改 B 站 cookie 误清了斗鱼票据: %+v", got.DouyuAuth) + } + if got.Cookies["live.bilibili.com"] != "SESSDATA=abc" { + t.Errorf("B 站 cookie 未写入: %v", got.Cookies) + } + }) +} + +// 落盘只能发生一次且必须在锁内:曾经的做法是 handler 在锁外再 Marshal 一次自己那份快照, +// 会把期间后台续期刚写好的 DouyuAuth/新 cookie 盖回旧值(盘上比内存旧,重启才显现)。 +func TestPutLiveHostCookiePersistsOnceInsideLock(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + cfg := writableConfig(t) + cfg.Cookies = map[string]string{dy.CookieHost: "acf_uid=111; acf_auth=old"} + cfg.DouyuAuth = configs.DouyuAuth{LTP0: "ticket.value"} + configs.SetCurrentConfig(cfg) + + req := httptest.NewRequest(http.MethodPost, "/api/cookies/"+dy.CookieHost, + strings.NewReader(`{"Host":"www.douyu.com","Cookie":"acf_uid=111; acf_auth=fresh"}`)) + req = req.WithContext(ctxWithInstance(&instance.Instance{Ctx: context.Background()})) + rec := httptest.NewRecorder() + putLiveHostCookie(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String()) + } + + onDisk, err := os.ReadFile(cfg.File) + if err != nil { + t.Fatal(err) + } + text := string(onDisk) + if !strings.Contains(text, "acf_auth=fresh") { + t.Errorf("盘上仍是旧 cookie(写盘被跳过或顺序错了): %s", tail(text)) + } + if strings.Contains(text, "acf_auth=old") { + t.Errorf("盘上留有旧 cookie(被并发快照覆盖)") + } +} + +// 用户删掉全部斗鱼房间后,列表行只由"运行中的房间 host"推导 —— 那时既看不到红标也无法重扫。 +func TestGetLiveHostCookieAddsDouyuRowWithoutRooms(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + + cases := []struct { + name string + cfg *configs.Config + wantRows int + }{ + {"无房间但存有长期凭证", &configs.Config{DouyuAuth: configs.DouyuAuth{LTP0: "t"}}, 1}, + {"无房间只有登录 cookie", &configs.Config{Cookies: map[string]string{dy.CookieHost: "acf_uid=1"}}, 1}, + {"完全没有斗鱼痕迹", &configs.Config{}, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + configs.SetCurrentConfig(c.cfg) + rec := httptest.NewRecorder() + getLiveHostCookie(rec, httptest.NewRequest(http.MethodGet, "/api/cookies", nil). + WithContext(ctxWithInstance(&instance.Instance{Ctx: context.Background()}))) + var rows []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil { + t.Fatalf("响应不是数组: %v body=%s", err, rec.Body.String()) + } + if len(rows) != c.wantRows { + t.Fatalf("行数=%d 期望=%d, body=%s", len(rows), c.wantRows, rec.Body.String()) + } + for _, r := range rows { + if r["Host"] != dy.CookieHost { + t.Errorf("补的行 host 不对: %v", r) + } + if _, ok := r["Cookie"].(string); !ok { + t.Errorf("缺少 Cookie 字段: %v", r) + } + } + }) + } +} + +// 记录被热应用了哪些房间,用于验证 applyCookiesToLives 真的触达 Live 且只触达目标 host +type recordingLive struct { + rawUrl string + cnName string + mu sync.Mutex + rooms []configs.LiveRoom +} + +func (r *recordingLive) GetPlatformCNName() string { return r.cnName } +func (r *recordingLive) GetOptions() *live.Options { return nil } +func (r *recordingLive) SetLiveIdByString(string) {} +func (r *recordingLive) GetLiveId() types.LiveID { return types.LiveID(r.rawUrl) } +func (r *recordingLive) GetRawUrl() string { return r.rawUrl } +func (r *recordingLive) GetInfo() (*live.Info, error) { + return nil, nil +} +func (r *recordingLive) GetInfoWithInterval(context.Context) (*live.Info, error) { + return nil, nil +} +func (r *recordingLive) GetStreamUrls() ([]*url.URL, error) { return nil, nil } +func (r *recordingLive) GetStreamInfos() ([]*live.StreamUrlInfo, error) { return nil, nil } +func (r *recordingLive) Close() {} +func (r *recordingLive) GetLastStartTime() time.Time { return time.Time{} } +func (r *recordingLive) SetLastStartTime(time.Time) {} +func (r *recordingLive) GetLogger() *livelogger.LiveLogger { return nil } +func (r *recordingLive) UpdateLiveOptionsbyConfig(_ context.Context, room *configs.LiveRoom) error { + r.mu.Lock() + defer r.mu.Unlock() + r.rooms = append(r.rooms, *room) + return nil +} + +// 续期/扫码成功后必须把新登录态推给运行中的房间,否则要等到下次重启才生效。 +func TestApplyCookiesToLivesTargetsOnlyRequestedHost(t *testing.T) { + douyuRoom := configs.LiveRoom{Url: "https://www.douyu.com/123", LiveId: types.LiveID("https://www.douyu.com/123")} + huyaRoom := configs.LiveRoom{Url: "https://www.huya.com/456", LiveId: types.LiveID("https://www.huya.com/456")} + l1 := &recordingLive{rawUrl: douyuRoom.Url, cnName: "斗鱼"} + l2 := &recordingLive{rawUrl: huyaRoom.Url, cnName: "虎牙"} + inst := &instance.Instance{Ctx: context.Background()} + inst.Lives.Set(l1.GetLiveId(), l1) + inst.Lives.Set(l2.GetLiveId(), l2) + ctx := ctxWithInstance(inst) + + newCfg := configs.NewConfig() + newCfg.LiveRooms = []configs.LiveRoom{douyuRoom, huyaRoom} + applyCookiesToLives(ctx, newCfg, dy.CookieHost) + + if len(l1.rooms) != 1 { + t.Fatalf("斗鱼房间被热应用次数=%d 期望 1", len(l1.rooms)) + } + if len(l2.rooms) != 0 { + t.Errorf("虎牙房间被误伤: %d 次", len(l2.rooms)) + } +} + +// 配置写不进去(磁盘满/目录不可写)时的行为:updateCASImpl 在持久化失败时连内存都不替换。 +// 这条用例把"残余项"钉成可观察事实:内存里 NeedRescan 仍为 false,因此失效重试的 24h 升档 +// 在该场景下永远停在 6h(升档判据读的是配置里的 NeedRescan)。 +func TestHandleRenewInvalidWithUnwritableConfigKeepsMemoryBackoff(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + clearRenewScheduleOverride() + t.Cleanup(clearRenewScheduleOverride) + + cfg := configs.NewConfig() + cfg.File = filepath.Join(t.TempDir(), "no-such-dir", "config.yml") // 目录不存在 ⇒ 必然写盘失败 + cfg.DouyuAuth = configs.DouyuAuth{LTP0: "ticket.value", NextRefreshAt: 1} + configs.SetCurrentConfig(cfg) + + handleRenewInvalid("ticket.value", dy.ErrLoginInvalid) + + if configs.GetCurrentConfig().DouyuAuth.NeedRescan { + t.Errorf("写盘失败时内存被改写(与注释口径不符)") + } + override := renewScheduleOverride.Load() + if override == 0 { + t.Fatal("写盘失败未记内存退避 ⇒ 下一轮会立刻拿同一张失效票据再打 safeAuth") + } + now := time.Now().Unix() + if delta := override - now; delta < 5*3600 || delta > 7*3600 { + t.Errorf("内存退避不是 6 小时档: delta=%ds", delta) + } +} + +// 界面与录制侧必须同口径:cookie 已判死时 logged_in 也要为 false, +// 否则出现"界面绿着却一直在匿名录制"。 +func TestGetDouyuAuthStatusFollowsDeadCookieGate(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + configs.SetCurrentConfig(&configs.Config{ + Cookies: map[string]string{dy.CookieHost: "acf_uid=42; acf_auth=x"}, + DouyuAuth: configs.DouyuAuth{LTP0: "ticket", LastRenewSuccessAt: time.Now().Add(-10 * 24 * time.Hour).Unix()}, + }) + rec := httptest.NewRecorder() + getDouyuAuthStatus(rec, httptest.NewRequest(http.MethodGet, "/api/douyu/auth/status", nil)) + var resp struct { + Data struct { + LoggedIn bool `json:"logged_in"` + HasLTP0 bool `json:"has_ltp0"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Data.LoggedIn { + t.Errorf("cookie 已判死但界面仍显示已登录: %s", rec.Body.String()) + } + if !resp.Data.HasLTP0 { + t.Errorf("has_ltp0 应为 true(票据还在,只是登录态过期)") + } + + // 手填 cookie(无票据、无续期记录)时不得据过期判未登录 + configs.SetCurrentConfig(&configs.Config{Cookies: map[string]string{dy.CookieHost: "acf_uid=42; acf_auth=x"}}) + rec2 := httptest.NewRecorder() + getDouyuAuthStatus(rec2, httptest.NewRequest(http.MethodGet, "/api/douyu/auth/status", nil)) + if err := json.Unmarshal(rec2.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !resp.Data.LoggedIn { + t.Errorf("手填 cookie 被判未登录: %s", rec2.Body.String()) + } +} + +// 状态接口在配置尚未加载时不得 panic(启动早期前端就会开始轮询) +func TestGetDouyuAuthStatusWithoutConfig(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + configs.SetCurrentConfig(nil) + rec := httptest.NewRecorder() + getDouyuAuthStatus(rec, httptest.NewRequest(http.MethodGet, "/api/douyu/auth/status", nil)) + if rec.Code != http.StatusInternalServerError { + t.Errorf("code=%d 期望 500", rec.Code) + } +} + +// 回归:"设置明文"页的一次普通保存绝不能抹掉不可再生的长期凭证。 +// 语义约定:YAML 里整节缺失 = "这一节没改"(旧前端缓存、不认识斗鱼字段的局部客户端), +// 写了这一节但 ltp0 留空 = 用户显式清除。不区分的话,一次保存就把票据连同续期日程 +// 一起静默抹掉 —— 自动续期永久失效、界面无任何提示,只能重新扫码。 +func TestPutRawConfigDouyuAuthSectionPresence(t *testing.T) { + const ticket = "ticket.value" + seed := func(t *testing.T) *configs.Config { + t.Helper() + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + cfg := writableConfig(t) + cfg.Cookies = map[string]string{dy.CookieHost: "acf_uid=111; acf_auth=old"} + cfg.DouyuAuth = configs.DouyuAuth{ + LTP0: ticket, DyDid: "scan-did", + NextRefreshAt: 1893456000, LastRenewSuccessAt: 1893000000, + } + configs.SetCurrentConfig(cfg) + return cfg + } + // 明文页正常展示的 YAML:走 getRawConfig 拿,凭证以掩码形态出现 + maskedYaml := func(t *testing.T) string { + t.Helper() + rec := httptest.NewRecorder() + getRawConfig(rec, httptest.NewRequest(http.MethodGet, "/api/raw-config", nil)) + var body struct { + Config string `json:"config"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !strings.Contains(body.Config, "douyu_auth:") { + t.Fatalf("明文页 YAML 里没有 douyu_auth 节: %s", body.Config) + } + return body.Config + } + // 返回"内存 + 盘上"两份结果:内存对了盘上没写,等于重启就回退 + submit := func(t *testing.T, cfg *configs.Config, yamlText string) (inMemory *configs.Config, onDisk *configs.Config) { + t.Helper() + body, err := json.Marshal(map[string]string{"config": yamlText}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPut, "/api/raw-config", strings.NewReader(string(body))) + req = req.WithContext(ctxWithInstance(&instance.Instance{Ctx: context.Background()})) + rec := httptest.NewRecorder() + putRawConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("保存失败: code=%d body=%s", rec.Code, rec.Body.String()) + } + text, err := os.ReadFile(cfg.File) + if err != nil { + t.Fatal(err) + } + disk, err := configs.NewConfigWithBytes(text) + if err != nil { + t.Fatalf("落盘配置不可读: %v\n%s", err, tail(string(text))) + } + return configs.GetCurrentConfig(), disk + } + checkTicket := func(t *testing.T, mem, disk *configs.Config, want string) { + t.Helper() + if got := mem.DouyuAuth.LTP0; got != want { + t.Errorf("内存票据=%q 期望=%q", got, want) + } + if got := disk.DouyuAuth.LTP0; got != want { + t.Errorf("盘上票据=%q 期望=%q", got, want) + } + } + + t.Run("掩码原样保存:票据日程与 cookie 全部不动", func(t *testing.T) { + cfg := seed(t) + mem, disk := submit(t, cfg, maskedYaml(t)) + checkTicket(t, mem, disk, ticket) + if mem.DouyuAuth.DyDid != "scan-did" { + t.Errorf("设备号被掩码冲掉: %q", mem.DouyuAuth.DyDid) + } + if mem.DouyuAuth.NextRefreshAt != 1893456000 { + t.Errorf("续期日程被改动: %d", mem.DouyuAuth.NextRefreshAt) + } + if mem.Cookies[dy.CookieHost] != "acf_uid=111; acf_auth=old" { + t.Errorf("登录 cookie 被改掉: %q", mem.Cookies[dy.CookieHost]) + } + }) + + t.Run("两节均未提交:票据保留,登录态缺失时排期改为立即", func(t *testing.T) { + mem, disk := submit(t, seed(t), "out_put_path: ./\n") + checkTicket(t, mem, disk, ticket) + // cookies 节没提交 ⇒ 登录 cookie 被默认值带走(它可由票据换回),此时必须立刻续期, + // 否则要空转到原排期(最长 3 天)才恢复登录录制,中间一直是匿名态。 + if douyuCookieUid(mem.Cookies[dy.CookieHost]) != "" { + t.Fatalf("用例前提不成立:cookie 并未丢失") + } + if mem.DouyuAuth.NextRefreshAt != 0 { + t.Errorf("登录态已丢却仍按旧日程排队: %d", mem.DouyuAuth.NextRefreshAt) + } + if disk.DouyuAuth.NextRefreshAt != 0 { + t.Errorf("盘上日程未同步为立即: %d", disk.DouyuAuth.NextRefreshAt) + } + }) + + t.Run("把接口响应体整份当 YAML 提交:同样不得丢票", func(t *testing.T) { + // 这是实际的事故形态:客户端没取出 JSON 里的 config 字段,而是把整段响应体提交了回去。 + // 它按 YAML 解析是一个只含 "config" 键的流式映射,两节都不存在。 + cfg := seed(t) + rec := httptest.NewRecorder() + getRawConfig(rec, httptest.NewRequest(http.MethodGet, "/api/raw-config", nil)) + body, err := json.Marshal(map[string]string{"config": rec.Body.String()}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPut, "/api/raw-config", strings.NewReader(string(body))) + req = req.WithContext(ctxWithInstance(&instance.Instance{Ctx: context.Background()})) + rec2 := httptest.NewRecorder() + putRawConfig(rec2, req) + // 接受(视为未提交这些节)或拒绝都必须保住票据 + if code := rec2.Code; code == http.StatusOK { + text, readErr := os.ReadFile(cfg.File) + if readErr != nil { + t.Fatal(readErr) + } + disk, err := configs.NewConfigWithBytes(text) + if err != nil { + t.Fatal(err) + } + if disk.DouyuAuth.LTP0 != ticket { + t.Errorf("事故形态提交后盘上票据丢失: %q", disk.DouyuAuth.LTP0) + } + } + if got := configs.GetCurrentConfig().DouyuAuth.LTP0; got != ticket { + t.Errorf("事故形态提交后内存票据丢失: %q", got) + } + }) + + t.Run("保留该节但 ltp0 留空:显式清除仍然生效", func(t *testing.T) { + mem, disk := submit(t, seed(t), "douyu_auth:\n ltp0: \"\"\n next_refresh_at: 0\n") + checkTicket(t, mem, disk, "") + }) + + t.Run("cookies 节内换了账号:票据作废以防静默混号", func(t *testing.T) { + mem, disk := submit(t, seed(t), "douyu_auth:\n ltp0: \""+ticket+"\"\ncookies:\n www.douyu.com: acf_uid=222; acf_auth=other\n") + checkTicket(t, mem, disk, "") + }) +} + +func tail(s string) string { + if len(s) > 200 { + return s[len(s)-200:] + } + return s +} diff --git a/src/servers/douyu_keeper.go b/src/servers/douyu_keeper.go new file mode 100644 index 0000000..2dc36e3 --- /dev/null +++ b/src/servers/douyu_keeper.go @@ -0,0 +1,319 @@ +package servers + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "time" + + "github.com/bililive-go/bililive-go/src/configs" + dy "github.com/bililive-go/bililive-go/src/live/douyu" + applog "github.com/bililive-go/bililive-go/src/log" + "github.com/bililive-go/bililive-go/src/notify" + bilisentry "github.com/bililive-go/bililive-go/src/pkg/sentry" +) + +const ( + // douyuCheckInterval 后台仅做"是否到续期时间"的廉价检查频率,不触发网络请求。 + douyuCheckInterval = time.Hour + // douyuFirstCheckDelay 启动后延迟首次检查,避开开机请求高峰并等待配置就绪。 + douyuFirstCheckDelay = 30 * time.Second + // douyuSecretMask 是"设置明文"(/api/raw-config)里代替斗鱼长期凭证原文的占位符。 + // 明文接口整体序列化 YAML,会把 json:"-" 挡在 /api/config 之外的数月票据原样吐出, + // 而默认 rpc.bind 是 :8080 且接口无鉴权;保存时识别该占位符即沿用原值。 + douyuSecretMask = "__BILILIVE_DOUYU_SECRET_HIDDEN__" +) + +// StartDouyuCookieKeeper 启动斗鱼 cookie 后台自动续期循环。 +// 只要存有 LTP0 长期凭证就按日程维护登录态(与当前是否有斗鱼房间无关,保证用户随时新增房间都能拿到新鲜 cookie)。 +// 平时每小时只做一次时间比较,临近 cookie 到期才真正发起 safeAuth 换票; +// 换票成功字段级合并进 Cookies[www.douyu.com] 并热应用;凭证失效则去重推送"重新扫码"通知。 +func StartDouyuCookieKeeper(ctx context.Context) { + bilisentry.GoWithContext(ctx, func(ctx context.Context) { + timer := time.NewTimer(douyuFirstCheckDelay) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + refreshDouyuCookieIfDue(ctx) + timer.Reset(douyuCheckInterval) + } + } + }) +} + +// renewScheduleOverride 是配置写盘失败时替代盘上 NextRefreshAt 的内存到期时间(Unix 秒,0 表示无)。 +// 写盘失败意味着盘上的"下次续期时间"没被推进,下一轮检查会立刻再换一次票; +// safeAuth 属登录类接口,反复调用有触发风控的风险,因此必须有个兜底的"这段时间别再试"。 +var renewScheduleOverride atomic.Int64 + +// noteRenewSchedule 记录一次内存中的下次续期时间(写盘失败时调用) +func noteRenewSchedule(at int64) { renewScheduleOverride.Store(at) } + +// clearRenewScheduleOverride 清除内存退避:一旦成功写盘或用户重扫,盘上日程重新可信 +func clearRenewScheduleOverride() { renewScheduleOverride.Store(0) } + +// effectiveNextRefreshAt 取"盘上日程"与"内存退避"中较晚的一个作为实际下次续期时间 +func effectiveNextRefreshAt(cfg *configs.Config) int64 { + next := cfg.DouyuAuth.NextRefreshAt + if override := renewScheduleOverride.Load(); override > next { + return override + } + return next +} + +// refreshDouyuCookieIfDue 到点才续期:需存有 LTP0 且按 ShouldRenewNow 判定该动作。 +// 换票链的串行性由调用方保证——只有 StartDouyuCookieKeeper 那一个 goroutine 会走到这里, +// 且它同步等本次换票返回后才重排下一次检查,因此不存在两条链并发合并字段的可能。 +func refreshDouyuCookieIfDue(ctx context.Context) { + cfg := configs.GetCurrentConfig() + if cfg == nil || cfg.DouyuAuth.LTP0 == "" { + return + } + // NextRefreshAt==0 视为"未知/立即续期"(如功能升级前已登录、没有该字段的历史配置) + if !dy.ShouldRenewNow(effectiveNextRefreshAt(cfg), cfg.DouyuAuth.LastRenewSuccessAt, time.Now()) { + return + } + doDouyuRefresh(ctx, cfg) +} + +// doDouyuRefresh 执行一次 safeAuth 换票并落盘、热应用;同时更新下次续期时间与失效提醒状态。 +func doDouyuRefresh(ctx context.Context, cfg *configs.Config) { + now := time.Now() + ltp0Used := cfg.DouyuAuth.LTP0 + fields, err := dy.RefreshLoginCookie(ctx, ltp0Used, cfg.DouyuAuth.DyDid) + if err != nil { + if errors.Is(err, dy.ErrLoginInvalid) { + handleRenewInvalid(ltp0Used, err) + } else { + // 临时性错误(网络/HTTP 异常):短退避重试,不打扰用户、不改失效标记。 + // 但"一直临时失败"和"永远续不上"在用户侧表现完全相同,所以距上次成功超过 + // 主站 cookie 整个有效期时升级为失效提醒,否则就是长期匿名录制而毫无线索。 + if dy.ShouldEscalateTempFailure(cfg.DouyuAuth.LastRenewSuccessAt, now) { + handleRenewInvalid(ltp0Used, fmt.Errorf("斗鱼 cookie 已连续 %d 天未能续期(最近一次报错:%w)", + int64(dy.RenewEscalateAfter()/(24*time.Hour)), err)) + return + } + setNextRefreshAt(ltp0Used, dy.RenewBackoffAt(now)) + applog.GetLogger().WithError(err).Warn("斗鱼 cookie 自动续期暂时失败(网络或接口异常),稍后自动重试") + } + return + } + skipped := false + newCfg, err := configs.UpdateWithRetry(func(c *configs.Config) error { + skipped = !applyDouyuRefreshResult(c, ltp0Used, fields, now) + return nil + }, 3, 10*time.Millisecond) + if err != nil { + // 票已换到但配置没写进去:updateCASImpl 在持久化失败时连内存都不替换, + // 所以这份新 cookie 完全作废,盘上 NextRefreshAt 也仍是旧值,下一轮检查会立刻再换一次票。 + // safeAuth 属登录类接口,反复调用有触发风控的风险,故在内存里记一次退避。 + noteRenewSchedule(dy.RenewBackoffAt(now)) + warnScheduleWriteFailed(ltp0Used, err, "本次换到的新 cookie 与下次续期时间") + return + } + // 配置已可写,内存退避不再需要,交回盘上日程 + clearRenewScheduleOverride() + if skipped { + applog.GetLogger().Info("斗鱼长期凭证在续期期间已被重新扫码替换,放弃写入本次换票结果(以新登录态为准)") + return + } + applyCookiesToLives(ctx, newCfg, dy.CookieHost) + noteRenewHealthy() + next := dy.NextRenewAt(now) + applog.GetLogger().Infof("斗鱼 cookie 自动续期成功,已热应用(更新字段数=%d,约 %d 小时后续期)", len(fields), (next-now.Unix())/3600) +} + +// applyDouyuRefreshResult 把一次换票结果落到配置上,返回是否真正写入。 +// ltp0Used 是本次换票实际使用的长期凭证:若配置里的 LTP0 已在其后被人换成新值 +// (重新扫码、甚至换了账号),本次结果就属于上一轮登录态,继续合并会把旧账号的 acf_* +// 覆盖到新登录态上(表现为静默混号),此时放弃写入、以新登录态为准。 +func applyDouyuRefreshResult(c *configs.Config, ltp0Used string, fields map[string]string, now time.Time) bool { + if c.DouyuAuth.LTP0 != ltp0Used { + return false + } + if c.Cookies == nil { + c.Cookies = make(map[string]string) + } + // LTP0 只归 douyu_auth 一处存放;设备号由写回侧统一取舍(现有 cookie 已有则挡新值,一个都没有则补种) + fresh := dy.ApplyDeviceIDPolicy(c.Cookies[dy.CookieHost], fields) + c.Cookies[dy.CookieHost] = configs.DropCookieFields(configs.MergeCookieFields(c.Cookies[dy.CookieHost], fresh), "LTP0") + c.DouyuAuth.NextRefreshAt = dy.NextRenewAt(now) + c.DouyuAuth.NeedRescan = false // 续期成功,清除失效提醒标记 + c.DouyuAuth.LastRenewSuccessAt = now.Unix() // 作为"连续临时失败该不该升级为提醒"的基准 + return true +} + +// applyDouyuScanResult 把一次扫码登录的结果落到配置上(供 handler 在 CAS 闭包内调用)。 +// +// 先无条件把"失效提醒"清掉并记下本次新鲜登录态的基准:扫码成功本身就意味着 Cookie 已翻新, +// 若只在"回传了 LTP0"时才清标记,那些"上游没回传 LTP0 且账号未变"的用户按红字提示重扫之后 +// 红标永远解不掉(面板文案还承诺"登录后自动消失"),并且之后 6 天 cookie 自然过期时 +// 再无第二次提醒——用户以为"我扫过了",实际已退回匿名录制。 +// +// LTP0 的归属才需要谨慎:本次没回传 LTP0 时配置里残留的是上一次的票据, +// 若换成了别的账号却继续留着,后台下次排期会用旧账号换票、把旧账号 acf_* 合并回来, +// 表现为面板显示新昵称却静默切回旧账号录制——宁可退化为"不能自动续期"也不能悄悄换人。 +// 由于 LTP0 本身不记录归属,只能用 cookie 里的 acf_uid 做代理判断。 +// +// 本函数只在 CAS 闭包内跑、除 c 之外不留副作用:写盘失败时配置根本没落地, +// 若在此处顺手清掉为"写盘失败"记的内存退避,反而会让 keeper 绕过 6 小时退避、 +// 每小时拿旧票据再打一次 safeAuth。作废退避改由调用方在写盘成功后做。 +func applyDouyuScanResult(c *configs.Config, res *dy.QRPollResult, now time.Time) { + if c.Cookies == nil { + c.Cookies = make(map[string]string) + } + oldUID := douyuCookieUid(c.Cookies[dy.CookieHost]) + // 扫码链跨 passport 域,Set-Cookie 里可能夹带 LTP0:主站并不认它,留在登录 cookie 里 + // 只是把数月有效的长期凭证多写到一个落盘出口(/api/raw-config 只掩码 douyu_auth 字段)。 + // 长期凭证的唯一归属地是 DouyuAuth.LTP0。 + loginCookie := configs.DropCookieFields(res.Cookie, "LTP0") + c.Cookies[dy.CookieHost] = loginCookie + newUID := douyuCookieUid(loginCookie) + + c.DouyuAuth.NeedRescan = false + c.DouyuAuth.NextRefreshAt = dy.NextRenewAt(now) + // 刚扫码得到的 cookie 与续期得到的一样是新鲜登录态,同样作为"临时失败升级"的基准 + c.DouyuAuth.LastRenewSuccessAt = now.Unix() + + if res.LTP0 != "" { + c.DouyuAuth.LTP0 = res.LTP0 + // 设备号必须与 LTP0 同源配对(safeAuth 要的是签发那次的设备号),因此一并覆盖, + // 未下发时清空而不是留下上一轮的设备号——实测缺设备号也能正常发权。 + c.DouyuAuth.DyDid = res.DyDid + } else if oldUID == "" || newUID != oldUID { + // 这次没回传长期凭证,又无法证明新登录态与上一张票据同属一个账号——要么是换了账号, + // 要么是这次扫码根本解析不出 acf_uid(斗鱼改了字段名)。留着旧票据就可能让后台下次排期 + // 用旧账号换票、把旧账号的 acf_* 合并回来(面板显示新昵称却静默换人录制)。 + // 口径与 /api/cookies、"设置明文"两个手改入口保持一致:宁可放弃自动续期,也不能悄悄换人。 + if c.DouyuAuth.LTP0 != "" { + applog.GetLogger().Warnf("斗鱼重新扫码未回传长期凭证,且无法确认与上一份登录态同属一个账号(uid %q -> %q),放弃上一轮的长期凭证(需再扫一次码才能启用自动续期)", oldUID, newUID) + } + c.DouyuAuth.LTP0 = "" + c.DouyuAuth.DyDid = "" + } +} + +// lastAlertedLTP0 记录本进程已为哪张长期凭证推过"需重新扫码"提醒。 +// NeedRescan 只能跨重启去重,一旦配置写盘失败(磁盘满/只读)它就形同不存在, +// 每小时重试会每小时推送一次;这个进程内标记补住该缺口(既不轰炸,也不至于完全静默)。 +var lastAlertedLTP0 atomic.Value // string + +// handleRenewInvalid 处理"凭证失效"(LTP0 过期等):安排退避,并在首次失效时推送一次重新扫码通知。 +// ltp0Used 必须是本次换票实际使用的那张票据:换票链在途可达分钟级,期间用户可能已经重扫 +// (LTP0 换新)或已在面板清空斗鱼 cookie(LTP0 置空),此时这张票据的失败与当前登录态无关, +// 若照旧标红并推送,用户刚做完系统要求的操作就收到一条"登录已失效"的假告警。 +func handleRenewInvalid(ltp0Used string, cause error) { + shouldNotify := false + stale := false + // 先给个兜底退避:若每次尝试都在进入闭包前就版本冲突退出(mutator 压根没跑), + // at 仍是 0,noteRenewSchedule(0) 等于"没有内存退避",下一轮立刻拿同一张失效票据再打 safeAuth。 + at := dy.RenewBackoffAt(time.Now()) + if _, err := configs.UpdateWithRetry(func(c *configs.Config) error { + stale = c.DouyuAuth.LTP0 != ltp0Used + shouldNotify = !stale && !c.DouyuAuth.NeedRescan + if stale { + return nil + } + at = dy.RenewBackoffAt(time.Now()) + if c.DouyuAuth.NeedRescan { + // NeedRescan 已经是置位的:说明这不是"可能误判",而是上一次失效之后反复敲都不通。 + // 此时每小时重试同一张已判定失效的票据毫无收益,只会把 safeAuth 这种登录类接口 + // 敲成风控诱因,并把配置文件写成一小时一次的频率,故升档为长退避。 + at = dy.RenewInvalidRetryAt(time.Now()) + } + c.DouyuAuth.NextRefreshAt = at + c.DouyuAuth.NeedRescan = true + return nil + }, 3, 10*time.Millisecond); err != nil { + if stale { + // 票据已被替换/清空,连"配置写不进去"这条告警都不该记在它头上 + applog.GetLogger().WithError(err).Debug("斗鱼续期失败状态写入配置出错(本次所用长期凭证已作废,忽略)") + return + } + applog.GetLogger().WithError(err).Warn("斗鱼续期失败状态写入配置出错") + // NeedRescan/退避日程都没留住:不记内存退避的话,下一轮检查会立刻拿同一张已失效的票据再试 + noteRenewSchedule(at) + warnScheduleWriteFailed(ltp0Used, err, "本次的失效标记与下次重试时间") + } + if stale { + applog.GetLogger().Info("斗鱼本次续期所用长期凭证已被替换或清空,忽略该次失败结果(以当前登录态为准)") + return + } + if alerted, _ := lastAlertedLTP0.Load().(string); alerted == ltp0Used { + shouldNotify = false + } + applog.GetLogger().WithError(cause).Warn("斗鱼 cookie 自动续期失败,长期凭证 LTP0 可能已过期,需要重新扫码登录") + if shouldNotify { + lastAlertedLTP0.Store(ltp0Used) + // 推送到独立 goroutine:通知渠道走外部网络(Telegram 在国内常连不上、SMTP 握手可达数十秒), + // 而续期循环只有一个 goroutine,同步推送会让它在此期间完全停摆, + // 连带把热应用、后续房间的到期检查一起卡住。 + bilisentry.Go(func() { + notify.SendSystemAlert( + "斗鱼登录已失效", + "bililive-go 无法自动续期斗鱼登录 Cookie(长期凭证可能已过期),录制的斗鱼房间将退回匿名录制、可能被反复切断。请到 Web 界面重新扫码登录斗鱼。", + ) + }) + } +} + +// setNextRefreshAt 仅更新下次续期时间点(临时性失败的退避)。 +// 同样要求 LTP0 仍是本次尝试所用那张,否则旧尝试的退避时间会覆盖掉用户重扫后排好的新日程。 +func setNextRefreshAt(ltp0Used string, at int64) { + if _, err := configs.UpdateWithRetry(func(c *configs.Config) error { + if c.DouyuAuth.LTP0 != ltp0Used { + return nil + } + c.DouyuAuth.NextRefreshAt = at + return nil + }, 3, 10*time.Millisecond); err != nil { + applog.GetLogger().WithError(err).Warn("斗鱼续期退避时间写入配置失败") + // 盘上日程没变,只能在内存里保住这次退避,否则下一轮立刻又用同一张票据重试 + noteRenewSchedule(at) + warnScheduleWriteFailed(ltp0Used, err, "本次的退避时间") + } +} + +// scheduleWriteAlerted 按长期凭证去重"配置不可写"提醒,避免每小时重复推送同一条 +var scheduleWriteAlerted atomic.Value // string + +// warnScheduleWriteFailed 处理"配置写不进去"(磁盘满/只读/权限): +// 只记一条 Warn 的话,用户侧表现是"cookie 再也没刷新过、也没有任何提示"—— +// 续期结果与日程都留不住,必须显式告诉用户配置文件有问题。 +// lost 说明这次具体没留住什么:三个调用点丢的东西并不一样(新 cookie / 失效标记 / 退避时间), +// 一律写成"续期结果没保存"会让用户以为票据已失效,跑去白扫一次码。 +func warnScheduleWriteFailed(ltp0Used string, cause error, lost string) { + if ltp0Used == "" { + return + } + applog.GetLogger().WithError(cause).Warnf("斗鱼 cookie 续期状态写入配置失败,%s未落盘也未生效,请检查配置文件是否可写、磁盘是否已满", lost) + if alerted, _ := scheduleWriteAlerted.Load().(string); alerted == ltp0Used { + return + } + scheduleWriteAlerted.Store(ltp0Used) + bilisentry.Go(func() { + notify.SendSystemAlert( + "斗鱼 cookie 续期状态无法保存", + fmt.Sprintf("bililive-go 已能续期斗鱼登录 Cookie,但写入配置文件失败(%v):%s没有保存,既没进内存也没落盘,等于这次白做。请检查配置文件是否可写、磁盘是否已满,解决后可在面板重新扫码。", cause, lost), + ) + }) +} + +// noteRenewHealthy 在一次续期成功(已写盘、已热应用)后复位进程内的去重标记。 +// 两张标记都按 LTP0 去重,而票据在整段有效期内根本不会变:若不复位,同一张票据只要 +// 曾经"写盘失败提醒过一次"或"失效提醒过一次",以后再出同样的问题就永远发不出第二条—— +// 用户侧表现为"明明又坏了,却一句话都没有"。 +func noteRenewHealthy() { + lastAlertedLTP0.Store("") + scheduleWriteAlerted.Store("") +} + +// douyuCookieUid 取 cookie 串中的斗鱼登录用户 ID,用于判断两份 cookie 是否同一账号。 +// 空串表示匿名/未登录(无 acf_uid)。 +func douyuCookieUid(cookie string) string { + return configs.ParseCookieFields(cookie)["acf_uid"] +} diff --git a/src/servers/douyu_keeper_backoff_test.go b/src/servers/douyu_keeper_backoff_test.go new file mode 100644 index 0000000..22a230b --- /dev/null +++ b/src/servers/douyu_keeper_backoff_test.go @@ -0,0 +1,115 @@ +package servers + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/bililive-go/bililive-go/src/configs" + dy "github.com/bililive-go/bililive-go/src/live/douyu" +) + +// keeper 的前置闸门:没有长期凭证、或按日程还没到点时,必须"什么都不做"。 +// 一旦闸门漏判,轻则每小时白打一次 safeAuth(登录类接口,反复调用有风控风险), +// 重则在完全没有票据的配置上写出一条莫名的续期日程。 +func TestRefreshDouyuCookieIfDueGate(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + clearRenewScheduleOverride() + t.Cleanup(clearRenewScheduleOverride) + + cases := []struct { + name string + auth configs.DouyuAuth + }{ + {"没有长期凭证(从未扫码)", configs.DouyuAuth{}}, + {"票据在但日程未到", configs.DouyuAuth{LTP0: "ticket.value", NextRefreshAt: time.Now().Add(48 * time.Hour).Unix(), LastRenewSuccessAt: time.Now().Add(-time.Hour).Unix()}}, + {"票据在、处于 6 小时退避期内", configs.DouyuAuth{LTP0: "ticket.value", NextRefreshAt: time.Now().Add(30 * time.Minute).Unix(), LastRenewSuccessAt: time.Now().Add(-time.Hour).Unix()}}, + } + ctx := context.Background() + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg := writableConfig(t) + cfg.Cookies = map[string]string{dy.CookieHost: "acf_uid=111; acf_auth=keep"} + cfg.DouyuAuth = c.auth + configs.SetCurrentConfig(cfg) + version, cookieBefore := cfg.Version, cfg.Cookies[dy.CookieHost] + + refreshDouyuCookieIfDue(ctx) + + after := configs.GetCurrentConfig() + if after.Version != version { + t.Errorf("闸门内不该提交任何配置更新(版本被推进说明走完了换票写回): %d -> %d", version, after.Version) + } + if after.Cookies[dy.CookieHost] != cookieBefore { + t.Errorf("登录 cookie 被动过: %q", after.Cookies[dy.CookieHost]) + } + if after.DouyuAuth.NextRefreshAt != c.auth.NextRefreshAt || after.DouyuAuth.NeedRescan { + t.Errorf("续期日程/失效标记被改写: %+v", after.DouyuAuth) + } + }) + } + + // 配置尚未加载(启动早期)时同样必须安静返回,而不是 panic + configs.SetCurrentConfig(nil) + refreshDouyuCookieIfDue(ctx) +} + +// 已经判定过失效之后,重试必须升档到 24 小时(而不是停在 6 小时): +// 否则一张已判死的票据会被每天敲 4 次,既最容易触发风控,也把配置文件写成每天 4 次重写。 +func TestHandleRenewInvalidEscalatesBackoffAfterFirstJudgement(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { + configs.SetCurrentConfig(orig) + lastAlertedLTP0.Store("") + clearRenewScheduleOverride() + }) + clearRenewScheduleOverride() + const ticket = "dead.ticket" + + // 第一次判定失效:6 小时档(给"可能只是风控误判"留自愈窗口) + configs.SetCurrentConfig(&configs.Config{DouyuAuth: configs.DouyuAuth{LTP0: ticket}}) + lastAlertedLTP0.Store("") + handleRenewInvalid(ticket, errors.New(dy.ErrLoginInvalid.Error())) + first := configs.GetCurrentConfig().DouyuAuth + if delta := first.NextRefreshAt - time.Now().Unix(); delta < 5*3600 || delta > 7*3600 { + t.Fatalf("首次失效应排 6 小时档: delta=%ds", delta) + } + if !first.NeedRescan { + t.Fatal("首次失效应置需重新扫码标记") + } + + // 第二次:NeedRescan 已置位 ⇒ 升档 + lastAlertedLTP0.Store("") + handleRenewInvalid(ticket, errors.New(dy.ErrLoginInvalid.Error())) + second := configs.GetCurrentConfig().DouyuAuth + if delta := second.NextRefreshAt - time.Now().Unix(); delta < 23*3600 || delta > 25*3600 { + t.Errorf("重复判定失效应升到 24 小时档: delta=%ds", delta) + } + + // 退避日程本身必须能挡住 ShouldRenewNow 的两个提前理由,否则等于每小时打一次 + if dy.ShouldRenewNow(second.NextRefreshAt, second.LastRenewSuccessAt, time.Now()) { + t.Errorf("24 小时退避期内仍被判为到点") + } + // 而到点之后(25 小时后)应当重新放行,不至于永久停摆 + if !dy.ShouldRenewNow(second.NextRefreshAt, second.LastRenewSuccessAt, time.Now().Add(25*time.Hour)) { + t.Errorf("退避到期后仍未放行续期") + } +} + +// 健康的一次续期/扫码必须复位进程内的提醒去重标记:票据在整段有效期内不会变, +// 若不复位,同一张票据曾经提醒过一次之后就再也发不出第二条。 +func TestNoteRenewHealthyReopensAlertDedup(t *testing.T) { + lastAlertedLTP0.Store("some.ticket") + scheduleWriteAlerted.Store("some.ticket") + + noteRenewHealthy() + + if v, _ := lastAlertedLTP0.Load().(string); v != "" { + t.Errorf("失效提醒去重标记未复位: %q", v) + } + if v, _ := scheduleWriteAlerted.Load().(string); v != "" { + t.Errorf("写盘失败提醒去重标记未复位: %q", v) + } +} diff --git a/src/servers/douyu_keeper_test.go b/src/servers/douyu_keeper_test.go new file mode 100644 index 0000000..36b1486 --- /dev/null +++ b/src/servers/douyu_keeper_test.go @@ -0,0 +1,564 @@ +package servers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/bililive-go/bililive-go/src/configs" + "github.com/bililive-go/bililive-go/src/instance" + dy "github.com/bililive-go/bililive-go/src/live/douyu" +) + +// 手工编辑"设置明文"里的斗鱼 cookie 等于换成另一份登录态,此时盘上的 LTP0 属于原账号。 +// 若不清掉,后台下次排期会拿原账号换票并把它的 acf_* 合并回来——用户手改的 cookie 过几天 +// 自己变了回去,且完全看不出原因。 +func TestPutRawConfigClearsDouyuAuthOnDouyuCookieEdit(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + + ctx := context.WithValue(context.Background(), instance.Key, &instance.Instance{ + Ctx: context.Background(), + }) + + // stored 是配置里已有的登录 cookie,submitted 是"设置明文"页保存回来的那一行 + putRaw := func(stored, submitted string) { + t.Helper() + base := configs.NewConfig() + base.File = filepath.Join(t.TempDir(), "config.yml") + base.Cookies = map[string]string{dy.CookieHost: stored} + base.DouyuAuth = configs.DouyuAuth{LTP0: "ticket.value", NextRefreshAt: 1893456000, LastRenewSuccessAt: 1893000000} + configs.SetCurrentConfig(base) + + yamlBody := "rpc:\n enable: true\n bind: \":8080\"\ninterval: 20\nout_put_path: ./\n" + + "cookies:\n www.douyu.com: \"" + submitted + "\"\n" + + "douyu_auth:\n ltp0: \"" + douyuSecretMask + "\"\n next_refresh_at: 1893456000\n" + body, err := json.Marshal(map[string]any{"config": yamlBody}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPut, "/api/raw-config", strings.NewReader(string(body))) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + putRawConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("保存明文失败: code=%d body=%s", rec.Code, rec.Body.String()) + } + } + + const storedCookie = "acf_uid=1; acf_auth=old.value" + putRaw(storedCookie, storedCookie) + // 原文未改动地保存一次:掩码要沿用真票据,否则"打开明文页点一次保存"就废掉自动续期 + if got := configs.GetCurrentConfig().DouyuAuth.LTP0; got != "ticket.value" { + t.Fatalf("未改 cookie 时不应清除长期凭证, got %q", got) + } + + putRaw(storedCookie, "acf_uid=2; acf_auth=another.value") + cfg := configs.GetCurrentConfig() + if cfg.DouyuAuth.LTP0 != "" { + t.Errorf("手改斗鱼 cookie 后应清除长期凭证, got %q", cfg.DouyuAuth.LTP0) + } + if cfg.DouyuAuth.NextRefreshAt != 0 { + t.Errorf("续期日程应一并清除, got %d", cfg.DouyuAuth.NextRefreshAt) + } +} + +// 状态接口是"续期失败"前端提示的唯一数据源:必须如实反映标记,且绝不泄露任何凭证原文。 +func TestGetDouyuAuthStatusNeverLeaksSecrets(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + configs.SetCurrentConfig(&configs.Config{ + Cookies: map[string]string{dy.CookieHost: "acf_uid=42; acf_auth=secret-auth-value"}, + DouyuAuth: configs.DouyuAuth{ + LTP0: "secret-ltp0-ticket", + DyDid: "secret-dy-did", + NextRefreshAt: 1893456000, + NeedRescan: true, + }, + }) + + rec := httptest.NewRecorder() + getDouyuAuthStatus(rec, httptest.NewRequest(http.MethodGet, "/api/douyu/auth/status", nil)) + + body := rec.Body.String() + for _, secret := range []string{"secret-ltp0-ticket", "secret-dy-did", "secret-auth-value", "acf_uid", "acf_auth"} { + if strings.Contains(body, secret) { + t.Fatalf("响应泄露了敏感内容 %q: %s", secret, body) + } + } + + var resp struct { + ErrNo int `json:"err_no"` + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("响应不是合法 JSON: %v body=%s", err, body) + } + if resp.ErrNo != 0 { + t.Fatalf("err_no=%d 期望 0", resp.ErrNo) + } + // 只允许这四个可见状态字段,多一个字段就是多一个凭证外泄面 + for _, k := range []string{"logged_in", "has_ltp0", "need_rescan", "next_refresh_at"} { + if _, ok := resp.Data[k]; !ok { + t.Errorf("缺少状态字段 %s, body=%s", k, body) + } + } + if len(resp.Data) != 4 { + t.Fatalf("字段数=%d 期望=4, got=%s", len(resp.Data), body) + } + if string(resp.Data["need_rescan"]) != "true" || string(resp.Data["has_ltp0"]) != "true" { + t.Errorf("状态未如实反映配置: %s", body) + } +} + +func TestParseCookieFields(t *testing.T) { + cases := []struct { + name string + in string + wantN int + }{ + {"正常串", "acf_uid=123; acf_auth=abc", 2}, + {"值中含等号", "acf_auth=a=b=c", 1}, + {"无等号的垃圾片段被忽略", "acf_uid=1; junktoken", 1}, + {"空串", "", 0}, + {"仅分隔符", ";;; ", 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := configs.ParseCookieFields(c.in) + if len(got) != c.wantN { + t.Fatalf("字段数=%d 期望=%d, got=%v", len(got), c.wantN, got) + } + }) + } + // 值中的等号必须完整保留,否则续期合并会截断 JWT 类字段 + if m := configs.ParseCookieFields("acf_auth=a=b=c"); m["acf_auth"] != "a=b=c" { + t.Fatalf("等号值被破坏: %q", m["acf_auth"]) + } +} + +func TestMergeCookieFields(t *testing.T) { + old := "acf_uid=111; acf_auth=old; dy_did=keepme" + merged := configs.MergeCookieFields(old, map[string]string{"acf_auth": "new", "acf_stk": "stk"}) + + fields := configs.ParseCookieFields(merged) + if fields["acf_auth"] != "new" { + t.Errorf("同名字段未被新值覆盖: %q", fields["acf_auth"]) + } + if fields["acf_stk"] != "stk" { + t.Errorf("新字段未写入: %q", fields["acf_stk"]) + } + // 换票响应不含的身份/设备字段必须保留,否则 UID 显示与签名 did 会丢 + if fields["acf_uid"] != "111" || fields["dy_did"] != "keepme" { + t.Errorf("未参与轮换的字段被丢弃: %v", fields) + } +} + +// 首次续期前 Cookies 中可能没有斗鱼条目(历史配置只存了 LTP0) +func TestMergeCookieFieldsFromEmpty(t *testing.T) { + merged := configs.MergeCookieFields("", map[string]string{"acf_uid": "1"}) + if configs.ParseCookieFields(merged)["acf_uid"] != "1" { + t.Fatalf("空旧值合并失败: %q", merged) + } +} + +func TestRenewSchedule(t *testing.T) { + now := time.Unix(1700000000, 0) + if got, want := dy.NextRenewAt(now)-now.Unix(), int64(3*24*time.Hour/time.Second); got != want { + t.Errorf("续期间隔=%d 期望=%d(3 天)", got, want) + } + if got, want := dy.RenewBackoffAt(now)-now.Unix(), int64(6*time.Hour/time.Second); got != want { + t.Errorf("退避间隔=%d 期望=%d(6 小时)", got, want) + } + // cookie 有效期约 6 天,续期点必须落在到期之前并留出一倍缓冲 + if dy.NextRenewAt(now) >= now.Add(6*24*time.Hour).Unix() { + t.Error("续期点晚于 cookie 到期时间,会出现续期前就失效") + } +} + +// 合并后的 cookie 必须能被录制侧重新解析,且长度单调不缩(防止字段被静默吃掉) +func TestMergedCookieStaysParseable(t *testing.T) { + fields := map[string]string{"acf_auth": "x.y.z", "acf_uid": "42"} + merged := configs.MergeCookieFields("acf_auth=old", fields) + if strings.Contains(merged, "old") { + t.Errorf("旧值残留: %q", merged) + } + if len(configs.ParseCookieFields(merged)) != 2 { + t.Errorf("合并结果无法解析为 2 个字段: %q", merged) + } +} + +// 续期结果必须按字段名有序写回:map 遍历随机,否则每次续期都把 config.yml 的 cookie 整行重排, +// 对把配置文件纳入 git 的用户产生无意义的持续 diff。 +func TestMergeCookieFieldsIsDeterministic(t *testing.T) { + old := "acf_uid=111; acf_auth=old; acf_stk=a; acf_ltkid=b; acf_ct=c" + nf := map[string]string{"acf_auth": "new", "acf_username": "u", "acf_biz": "z"} + first := configs.MergeCookieFields(old, nf) + for i := 0; i < 50; i++ { + if got := configs.MergeCookieFields(old, nf); got != first { + t.Fatalf("第 %d 次拼接不一致:\n%s\n%s", i, first, got) + } + } + pairs := strings.Split(first, "; ") + for i, kv := range pairs { + if i > 0 && kv < strings.Split(first, "; ")[i-1] { + t.Fatalf("未按字段名升序: %s", first) + } + } +} + +// 换票期间用户重新扫码(换了 LTP0)时必须放弃写入,否则旧账号的 acf_* 会覆盖新登录态。 +func TestApplyDouyuRefreshResultSkipsStaleCredential(t *testing.T) { + now := time.Unix(1893456000, 0) + fields := map[string]string{"acf_uid": "1", "acf_auth": "old-account", "acf_stk": "s", "acf_ltkid": "l", "acf_username": "u", "acf_biz": "b", "acf_ct": "c"} + + t.Run("LTP0未变则正常写入", func(t *testing.T) { + c := &configs.Config{ + Cookies: map[string]string{dy.CookieHost: "acf_uid=1; acf_auth=stale"}, + DouyuAuth: configs.DouyuAuth{LTP0: "same.ticket", NeedRescan: true}, + } + if !applyDouyuRefreshResult(c, "same.ticket", fields, now) { + t.Fatal("期望写入成功") + } + if got := cookieValueOf(t, c.Cookies[dy.CookieHost], "acf_auth"); got != "old-account" { + t.Errorf("新字段未写入, acf_auth=%q", got) + } + if c.DouyuAuth.NextRefreshAt != dy.NextRenewAt(now) { + t.Errorf("未排下次续期时间: %d", c.DouyuAuth.NextRefreshAt) + } + if c.DouyuAuth.NeedRescan { + t.Error("续期成功应清除失效提醒标记") + } + // 记下"这次真的续上了",连续临时失败才会以此为基准升级为失效提醒 + if c.DouyuAuth.LastRenewSuccessAt != now.Unix() { + t.Errorf("未记录本次成功续期时间: %d", c.DouyuAuth.LastRenewSuccessAt) + } + }) + + t.Run("LTP0已被重新扫码替换则放弃写入", func(t *testing.T) { + c := &configs.Config{ + Cookies: map[string]string{dy.CookieHost: "acf_uid=999; acf_auth=new-account"}, + DouyuAuth: configs.DouyuAuth{LTP0: "new.ticket", NextRefreshAt: dy.NextRenewAt(now)}, + } + if applyDouyuRefreshResult(c, "old.ticket", fields, now.Add(time.Hour)) { + t.Fatal("期望放弃写入") + } + if got := cookieValueOf(t, c.Cookies[dy.CookieHost], "acf_auth"); got != "new-account" { + t.Errorf("新登录态被旧结果覆盖: acf_auth=%q", got) + } + if got := cookieValueOf(t, c.Cookies[dy.CookieHost], "acf_uid"); got != "999" { + t.Errorf("新账号 UID 被旧结果覆盖: acf_uid=%q", got) + } + }) +} + +func cookieValueOf(t *testing.T, cookieStr, name string) string { + t.Helper() + fields := configs.ParseCookieFields(cookieStr) + v, ok := fields[name] + if !ok { + t.Fatalf("cookie 中缺少 %s: %s", name, cookieStr) + } + return v +} + +// 扫码链跨 passport 域,Set-Cookie 里可能夹带 LTP0。主站不认这个字段,留在登录 cookie 里 +// 只是让数月有效的长期凭证多一处落盘出口(/api/raw-config 只掩码 douyu_auth 字段), +// 因此长期凭证的唯一归属地必须是 DouyuAuth.LTP0。 +func TestDouyuLoginCookieNeverCarriesLongTermTicket(t *testing.T) { + now := time.Unix(1893456000, 0) + + scan := &configs.Config{} + applyDouyuScanResult(scan, &dy.QRPollResult{ + Cookie: "acf_uid=1; acf_auth=fresh; LTP0=secret.ticket", + LTP0: "secret.ticket", + }, now) + if strings.Contains(scan.Cookies[dy.CookieHost], "LTP0") { + t.Errorf("扫码结果 cookie 里残留长期票据: %q", scan.Cookies[dy.CookieHost]) + } + if got := cookieValueOf(t, scan.Cookies[dy.CookieHost], "acf_auth"); got != "fresh" { + t.Errorf("剥离长期票据时误删了登录字段: %q", got) + } + if scan.DouyuAuth.LTP0 != "secret.ticket" { + t.Errorf("长期票据应保存在 DouyuAuth.LTP0, got %q", scan.DouyuAuth.LTP0) + } + + merged := &configs.Config{ + Cookies: map[string]string{dy.CookieHost: "acf_uid=1; acf_auth=old; LTP0=stale.ticket"}, + DouyuAuth: configs.DouyuAuth{LTP0: "ticket"}, + } + if !applyDouyuRefreshResult(merged, "ticket", map[string]string{"acf_auth": "new", "LTP0": "exchanged.ticket"}, now) { + t.Fatal("未写入换票结果") + } + if strings.Contains(merged.Cookies[dy.CookieHost], "LTP0") { + t.Errorf("续期结果 cookie 里残留长期票据: %q", merged.Cookies[dy.CookieHost]) + } +} + +// 扫码成功是"长期凭证唯一来源",落盘时的归属判断错了,后台就会用错账号的票据续期。 +func TestApplyDouyuScanResult(t *testing.T) { + now := time.Unix(1893456000, 0) + + t.Run("带回LTP0则配对写入并排期", func(t *testing.T) { + c := &configs.Config{ + Cookies: map[string]string{dy.CookieHost: "acf_uid=1; acf_auth=old"}, + DouyuAuth: configs.DouyuAuth{ + LTP0: "stale.ticket", DyDid: "stale-did", NeedRescan: true, + }, + } + applyDouyuScanResult(c, &dy.QRPollResult{ + Cookie: "acf_uid=1; acf_auth=new", LTP0: "fresh.ticket", + }, now) + if c.DouyuAuth.LTP0 != "fresh.ticket" { + t.Errorf("LTP0 未更新: %q", c.DouyuAuth.LTP0) + } + // 设备号必须与 LTP0 同源配对:本次未下发就清空,不能留下上一轮的设备号与新票据配对 + if c.DouyuAuth.DyDid != "" { + t.Errorf("未配对的旧设备号应清空: %q", c.DouyuAuth.DyDid) + } + if c.DouyuAuth.NextRefreshAt != dy.NextRenewAt(now) { + t.Errorf("未排下次续期时间: %d", c.DouyuAuth.NextRefreshAt) + } + if c.DouyuAuth.NeedRescan || c.DouyuAuth.LastRenewSuccessAt != now.Unix() { + t.Errorf("失效标记未清除或未记录新鲜登录态基准: %+v", c.DouyuAuth) + } + if got := cookieValueOf(t, c.Cookies[dy.CookieHost], "acf_auth"); got != "new" { + t.Errorf("cookie 未整体替换: %q", got) + } + }) + + // 本次扫码没回传 LTP0(异常分支)时,配置里残留的是上一次的票据。 + // 若换成了别的账号却继续留着,后台下次排期就会用旧账号换票、把旧账号 acf_* 合并进来, + // 表现为面板显示新昵称却静默切回旧账号录制——宁可退化为"不能自动续期"也不能悄悄换人。 + t.Run("未带回LTP0且换了账号则放弃旧票据", func(t *testing.T) { + c := &configs.Config{ + Cookies: map[string]string{dy.CookieHost: "acf_uid=111; acf_auth=old"}, + DouyuAuth: configs.DouyuAuth{LTP0: "old-account.ticket", DyDid: "old-did", NextRefreshAt: dy.NextRenewAt(now)}, + } + applyDouyuScanResult(c, &dy.QRPollResult{Cookie: "acf_uid=999; acf_auth=new"}, now) + if c.DouyuAuth.LTP0 != "" || c.DouyuAuth.DyDid != "" { + t.Errorf("归属存疑的旧长期凭证未清除: %+v", c.DouyuAuth) + } + // 票据虽然没了,但这次扫码确实换到了新鲜 cookie:日程照排、提醒照撤, + // 只是 keeper 在 LTP0 为空时不会真的动作(见 refreshDouyuCookieIfDue 的门控)。 + if c.DouyuAuth.NextRefreshAt != dy.NextRenewAt(now) || c.DouyuAuth.LastRenewSuccessAt != now.Unix() { + t.Errorf("未按新鲜登录态记录日程与基准: %+v", c.DouyuAuth) + } + if got := cookieValueOf(t, c.Cookies[dy.CookieHost], "acf_uid"); got != "999" { + t.Errorf("新登录 cookie 被误伤: %q", got) + } + }) + + t.Run("未带回LTP0但同账号则保留旧票据并撤销失效提醒", func(t *testing.T) { + c := &configs.Config{ + Cookies: map[string]string{dy.CookieHost: "acf_uid=111; acf_auth=old"}, + DouyuAuth: configs.DouyuAuth{ + LTP0: "same-account.ticket", NextRefreshAt: dy.NextRenewAt(now), NeedRescan: true, + }, + } + applyDouyuScanResult(c, &dy.QRPollResult{Cookie: "acf_uid=111; acf_auth=new"}, now) + if c.DouyuAuth.LTP0 != "same-account.ticket" { + t.Errorf("同账号重扫不应丢掉自动续期能力: %q", c.DouyuAuth.LTP0) + } + // 这条是"红标死循环"的关键:用户按红字提示重扫后必须能解掉提醒, + // 否则红标永远挂着、6 天后 cookie 自然过期且再无第二次提醒。 + if c.DouyuAuth.NeedRescan { + t.Errorf("扫码成功后失效提醒未撤销: %+v", c.DouyuAuth) + } + if c.DouyuAuth.LastRenewSuccessAt != now.Unix() { + t.Errorf("未按本次扫码记录新鲜登录态基准: %+v", c.DouyuAuth) + } + }) + + t.Run("旧cookie无uid时不猜测归属,作废票据", func(t *testing.T) { + c := &configs.Config{ + Cookies: map[string]string{dy.CookieHost: "PHPSESSID=x"}, + DouyuAuth: configs.DouyuAuth{LTP0: "ticket"}, + } + applyDouyuScanResult(c, &dy.QRPollResult{Cookie: "acf_uid=999; acf_auth=new"}, now) + // 旧串里连 acf_uid 都没有,就无法证明这次登录与上一张票据同属一个账号; + // 留着旧票据可能让后台下次排期用旧账号换票、把旧账号 acf_* 合并回来(静默换人录制)。 + if c.DouyuAuth.LTP0 != "" { + t.Errorf("无法判断归属时应作废长期凭证: %q", c.DouyuAuth.LTP0) + } + if c.DouyuAuth.DyDid != "" { + t.Errorf("票据作废时设备号必须一并清空: %q", c.DouyuAuth.DyDid) + } + // 但这次扫码得到的登录 cookie 本身要留下,不能因作废票据而退回匿名 + if !strings.Contains(c.Cookies[dy.CookieHost], "acf_uid=999") { + t.Errorf("本次登录 cookie 未落盘: %q", c.Cookies[dy.CookieHost]) + } + }) +} + +// 明文配置接口("设置明文"页)整体序列化 YAML,json:"-" 挡不住它, +// 必须掩码斗鱼长期凭证:默认 rpc.bind 是 :8080 且接口无鉴权。 +func TestGetRawConfigMasksDouyuSecrets(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + configs.SetCurrentConfig(&configs.Config{ + Cookies: map[string]string{dy.CookieHost: "acf_uid=42; acf_auth=cookie-secret"}, + DouyuAuth: configs.DouyuAuth{ + LTP0: "ltp0-secret-ticket", + DyDid: "did-secret-value", + }, + }) + + rec := httptest.NewRecorder() + getRawConfig(rec, httptest.NewRequest(http.MethodGet, "/api/raw-config", nil)) + body := rec.Body.String() + // 长期票据必须掩码:它数月有效、且只能由扫码引导,泄露即等于账号登录态被人长期接管 + for _, secret := range []string{"ltp0-secret-ticket", "did-secret-value"} { + if strings.Contains(body, secret) { + t.Fatalf("明文配置接口泄露了长期凭证 %q: %s", secret, body) + } + } + if !strings.Contains(body, douyuSecretMask) { + t.Errorf("未按掩码输出,前端原样保存回去会把票据抹掉: %s", body) + } + // 主站 cookie 仍照旧可见:它约 6 天失效、可续期,且明文页是用户跨机备份登录态的唯一入口 + if !strings.Contains(body, "acf_auth=cookie-secret") { + t.Errorf("主站 cookie 不应被掩码(会破坏配置备份用途): %s", body) + } +} + +// 换票链在途可达分钟级(最多 8 跳 × 15s 超时),期间用户完全可能已经重扫成功或已清空凭证。 +// 那一次"属于旧票据"的失败如果照常标红、改日程、推通知,用户刚做完系统要求的操作就收到假告警。 +func TestHandleRenewInvalidIgnoresStaleCredential(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig); lastAlertedLTP0.Store("") }) + lastAlertedLTP0.Store("") + + const future int64 = 4102444800 // 2100 年:代表"刚扫码成功,已排到 3 天后" + configs.SetCurrentConfig(&configs.Config{ + DouyuAuth: configs.DouyuAuth{LTP0: "new.ticket", NextRefreshAt: future}, + }) + + handleRenewInvalid("old.ticket", errors.New("LTP0 可能已过期")) + + after := configs.GetCurrentConfig() + if after.DouyuAuth.NeedRescan { + t.Errorf("新登录态被旧凭证的失败标成需重新扫码: %+v", after.DouyuAuth) + } + if after.DouyuAuth.NextRefreshAt != future { + t.Errorf("新日程被旧尝试的退避覆盖: %d != %d", after.DouyuAuth.NextRefreshAt, future) + } + if alerted, _ := lastAlertedLTP0.Load().(string); alerted == "old.ticket" { + t.Errorf("为已作废的票据推送了重新扫码提醒") + } +} + +// 票据确实是当前这张时,才标失效、排退避,并记下"本进程已提醒过"用于去重 +// (写盘失败时 NeedRescan 落不了盘,只靠它去重会每小时重复推送)。 +func TestHandleRenewInvalidMarksRescanAndDedupsAlert(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig); lastAlertedLTP0.Store("") }) + lastAlertedLTP0.Store("") + configs.SetCurrentConfig(&configs.Config{ + DouyuAuth: configs.DouyuAuth{LTP0: "cur.ticket"}, + }) + + handleRenewInvalid("cur.ticket", errors.New("探针 error=-1")) + + after := configs.GetCurrentConfig() + if !after.DouyuAuth.NeedRescan { + t.Fatalf("当前票据续期失败应标记需重新扫码: %+v", after.DouyuAuth) + } + if after.DouyuAuth.NextRefreshAt <= time.Now().Unix() { + t.Errorf("未安排退避后的续期时间: %d", after.DouyuAuth.NextRefreshAt) + } + if alerted, _ := lastAlertedLTP0.Load().(string); alerted != "cur.ticket" { + t.Errorf("未记录本次提醒,写盘失败时将每重复推一次: %q", alerted) + } +} + +// 用户清空斗鱼 cookie 时 handler 会把 DouyuAuth 整个清零;此时在途尝试的退避不该再写回日程, +// 否则状态与"已无凭证"的事实不一致,面板会显示一条莫名的续期计划。 +func TestSetNextRefreshAtIgnoresStaleOrClearedCredential(t *testing.T) { + orig := configs.GetCurrentConfig() + t.Cleanup(func() { configs.SetCurrentConfig(orig) }) + + configs.SetCurrentConfig(&configs.Config{ + DouyuAuth: configs.DouyuAuth{LTP0: "new.ticket"}, + }) + setNextRefreshAt("old.ticket", 12345) + if got := configs.GetCurrentConfig().DouyuAuth.NextRefreshAt; got != 0 { + t.Errorf("旧尝试写回了新登录态的日程: %d", got) + } + + configs.SetCurrentConfig(&configs.Config{}) // 凭证已清空 + setNextRefreshAt("old.ticket", 12345) + if got := configs.GetCurrentConfig().DouyuAuth.NextRefreshAt; got != 0 { + t.Errorf("无凭证配置被写入日程: %d", got) + } + + configs.SetCurrentConfig(&configs.Config{ + DouyuAuth: configs.DouyuAuth{LTP0: "cur.ticket"}, + }) + setNextRefreshAt("cur.ticket", 12345) + if got := configs.GetCurrentConfig().DouyuAuth.NextRefreshAt; got != 12345 { + t.Errorf("当前票据的退避未写入: %d", got) + } +} + +// 两条换票链并发会把两次登录会话的字段合并落盘,而探针只分别验过各自字段集,混合结果未经验证。 +// 现行设计里 keeper 只有一个 goroutine、且同步等本次换票返回,因此不存在并发链(见 refreshDouyuCookieIfDue 注释)。 + +// 配置写不进去(磁盘满/只读)时盘上日程不会推进,若没有内存退避兜底, +// 下一轮检查会立刻拿同一张票据再换一次票——safeAuth 是登录类接口,反复调用有风控风险。 +func TestRenewScheduleOverrideKeepsBackoffWhenConfigUnwritable(t *testing.T) { + clearRenewScheduleOverride() + defer clearRenewScheduleOverride() + + cfg := &configs.Config{} + cfg.DouyuAuth.NextRefreshAt = 100 // 早已到点 + if got := effectiveNextRefreshAt(cfg); got != 100 { + t.Fatalf("无内存退避时应沿用盘上日程, got %d", got) + } + + backoff := time.Now().Add(time.Hour).Unix() + noteRenewSchedule(backoff) + if got := effectiveNextRefreshAt(cfg); got != backoff { + t.Fatalf("内存退避应覆盖过期的盘上日程, got %d", got) + } + if dy.ShouldRenewNow(effectiveNextRefreshAt(cfg), time.Now().Add(-time.Minute).Unix(), time.Now()) { + t.Error("退避期间不应判定为到点") + } + + // 盘上日程更晚时以盘上为准(例如用户重扫后排到了 3 天后) + cfg.DouyuAuth.NextRefreshAt = backoff + 3600 + if got := effectiveNextRefreshAt(cfg); got != backoff+3600 { + t.Fatalf("盘上日程更晚时应以盘上为准, got %d", got) + } + + clearRenewScheduleOverride() + if got := effectiveNextRefreshAt(cfg); got != backoff+3600 { + t.Fatalf("清除退避后应回到盘上日程, got %d", got) + } +} + +// 扫码成功本身不作废内存退避:写盘失败时配置根本没落地,若 mutator 里顺手清掉退避, +// keeper 会绕过 6 小时退避每小时拿旧票据再打一次 safeAuth。清退避是调用方(写盘成功后)的职责。 +func TestApplyDouyuScanResultKeepsRenewScheduleOverride(t *testing.T) { + clearRenewScheduleOverride() + defer clearRenewScheduleOverride() + backoff := time.Now().Add(10 * 24 * time.Hour).Unix() // 故意晚于扫码排下的 3 天后日程,便于识别谁在生效 + noteRenewSchedule(backoff) + + c := &configs.Config{} + applyDouyuScanResult(c, &dy.QRPollResult{Cookie: "acf_uid=1; acf_auth=x", LTP0: "new.ltp0"}, time.Now()) + if got := effectiveNextRefreshAt(c); got != backoff { + t.Fatalf("闭包内不该作废内存退避(写盘可能失败): got %d want %d", got, backoff) + } + // 调用方在写盘成功后清掉,之后交回盘上日程 + clearRenewScheduleOverride() + if got := effectiveNextRefreshAt(c); got != c.DouyuAuth.NextRefreshAt { + t.Fatalf("清除后应回到盘上日程: got %d want %d", got, c.DouyuAuth.NextRefreshAt) + } +} diff --git a/src/servers/handler.go b/src/servers/handler.go index 16f1c95..ce6baa8 100644 --- a/src/servers/handler.go +++ b/src/servers/handler.go @@ -998,7 +998,24 @@ func putConfig(writer http.ResponseWriter, r *http.Request) { } func getRawConfig(writer http.ResponseWriter, r *http.Request) { - b, err := yaml.Marshal(configs.GetCurrentConfig()) + // 掩码斗鱼长期凭证后再输出:/api/config 已经用 json:"-" 把 LTP0 挡在 JSON 之外, + // 若这里原样吐出 YAML,"设置明文"页就等于把数月有效的票据连同一堆 cookie 一起交给浏览器 + // (默认 rpc.bind 是 :8080 且接口无鉴权)。用户显式重新扫码才会更换该票据。 + cfg := configs.CloneConfigShallow(configs.GetCurrentConfig()) + if cfg == nil { + writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{ + ErrNo: http.StatusInternalServerError, + ErrMsg: "配置尚未加载", + }) + return + } + if cfg.DouyuAuth.LTP0 != "" { + cfg.DouyuAuth.LTP0 = douyuSecretMask + } + if cfg.DouyuAuth.DyDid != "" { + cfg.DouyuAuth.DyDid = douyuSecretMask + } + b, err := yaml.Marshal(cfg) if err != nil { writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{ ErrNo: http.StatusBadRequest, @@ -1011,6 +1028,16 @@ func getRawConfig(writer http.ResponseWriter, r *http.Request) { }) } +// rawConfigTopKeys 取"设置明文"YAML 的顶层键集合,仅用于区分"这一节没提交"与"提交了但为空"。 +// 解析失败返回 nil,此时按"未提交"处理(宁可沿用原凭证,也不静默清掉不可再生的长期票据)。 +func rawConfigTopKeys(yamlText string) map[string]any { + var top map[string]any + if err := yaml.Unmarshal([]byte(yamlText), &top); err != nil { + return nil + } + return top +} + func putRawConfig(writer http.ResponseWriter, r *http.Request) { inst := instance.GetInstance(r.Context()) b, err := io.ReadAll(r.Body) @@ -1023,8 +1050,22 @@ func putRawConfig(writer http.ResponseWriter, r *http.Request) { } ctx := inst.Ctx var jsonBody map[string]any - json.Unmarshal(b, &jsonBody) - newConfig, err := configs.NewConfigWithBytes([]byte(jsonBody["config"].(string))) + if err := json.Unmarshal(b, &jsonBody); err != nil { + writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{ + ErrNo: http.StatusBadRequest, + ErrMsg: "请求体不是合法 JSON: " + err.Error(), + }) + return + } + rawYaml, ok := jsonBody["config"].(string) + if !ok { + writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{ + ErrNo: http.StatusBadRequest, + ErrMsg: `缺少字符串字段 "config"`, + }) + return + } + newConfig, err := configs.NewConfigWithBytes([]byte(rawYaml)) if err != nil { writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{ ErrNo: http.StatusInternalServerError, @@ -1032,8 +1073,37 @@ func putRawConfig(writer http.ResponseWriter, r *http.Request) { }) return } - oldConfig := configs.GetCurrentConfig() + curConfig := configs.GetCurrentConfig() + if curConfig == nil { + writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{ + ErrNo: http.StatusInternalServerError, + ErrMsg: "配置尚未加载", + }) + return + } + // 在副本上刷新索引:直接用全局配置指针的话,RefreshLiveRoomIndexCache 会就地改写一份 + // 正被所有并发请求读取的配置(data race)。 + oldConfig := configs.CloneConfigShallow(curConfig) oldConfig.RefreshLiveRoomIndexCache() + // 明文页提交的是"整份配置",但"文档里根本没写这一节"与"写了但留空"含义不同:前者极可能是旧前端 + // 缓存或局部提交的客户端(它不认识斗鱼登录字段),后者才是用户显式清空。不区分的话,一次普通保存 + // 就把不可再生的长期凭证连同续期日程静默抹掉,表现为自动续期永久失效且没有任何提示(只能重新扫码)。 + // 实测:把 GET /raw-config 的响应体原样提交回去(YAML 里两节都缺)即可复现。 + topKeys := rawConfigTopKeys(rawYaml) + _, authSectionEdited := topKeys["douyu_auth"] + _, cookiesSectionEdited := topKeys["cookies"] + if !authSectionEdited { + newConfig.DouyuAuth = oldConfig.DouyuAuth + } + // "设置明文"页展示的是掩码,保存回来的仍是掩码:此时沿用原票据, + // 否则"打开明文页点一次保存"就会抹掉长期凭证,表现为自动续期悄悄失效。 + // 需要真正清除时,保留 douyu_auth 这一节并把 ltp0 留空即可(整节删掉视为"未修改")。 + if newConfig.DouyuAuth.LTP0 == douyuSecretMask { + newConfig.DouyuAuth.LTP0 = oldConfig.DouyuAuth.LTP0 + } + if newConfig.DouyuAuth.DyDid == douyuSecretMask { + newConfig.DouyuAuth.DyDid = oldConfig.DouyuAuth.DyDid + } // 继承原配置的文件路径 newConfig.File = oldConfig.File // 预先将旧配置中的 LiveId 迁移到新配置(相同 URL) @@ -1046,17 +1116,48 @@ func putRawConfig(writer http.ResponseWriter, r *http.Request) { newConfig.LiveRooms[i].LiveId = rOld.LiveId } } - // 先设置为当前全局配置,再驱动运行态差异变更 - configs.SetCurrentConfig(newConfig) - if err := applyLiveRoomsByConfig(ctx, oldConfig, newConfig); err != nil { + // 手工编辑"设置明文"里的斗鱼 cookie,只有换了登录账号才算换掉了一份登录态:按 acf_uid 判断。 + // 不能整串比较后一律清票据——补一个字段、删一个过期字段这类同账号微调非常常见, + // 顺手清掉长期凭证的结果是自动续期静默失效,用户只看到"过几天又断流"却毫无线索。 + // 反之若真的换了账号却保留盘上的 LTP0,后台下次排期会用原账号换票并把它的 acf_* 合并回来, + // 表现为"我手改的 cookie 过几天自己变了回去"(静默混号)。宁可放弃自动续期,也不能悄悄换人。 + if oldUid, newUid := douyuCookieUid(oldConfig.Cookies[dy.CookieHost]), douyuCookieUid(newConfig.Cookies[dy.CookieHost]); oldUid != newUid { + // cookies 整节缺失时 newUid 必然为空,那不是"换了账号"而是"没提交这一节",不能据此作废票据。 + if cookiesSectionEdited { + if newConfig.DouyuAuth.LTP0 != "" { + applog.GetLogger().Warnf("检测到斗鱼登录 Cookie 换了账号(acf_uid %s -> %s),已同时清除斗鱼长期凭证(需重新扫码才能启用自动续期)", oldUid, newUid) + } + newConfig.DouyuAuth = configs.DouyuAuth{} + } + } + // 票据因"未提交该节"而被沿用、登录 cookie 却已不在(cookies 整节被局部提交带走)时, + // 把排期改为"立即":否则要空转到原排期(最长 3 天)才换回登录态,中间一直是匿名录制。 + if newConfig.DouyuAuth.LTP0 != "" && douyuCookieUid(newConfig.Cookies[dy.CookieHost]) == "" { + newConfig.DouyuAuth.NextRefreshAt = 0 + } + // 整体替换必须走带锁的提交路径:SetCurrentConfig 是裸写全局指针,绕开 updateMu 会把与本次 + // 编辑同期完成的其它更新(保存 cookie、斗鱼自动续期)从内存里覆盖掉;锁外再 Marshal 更是 + // 直接用这份基于旧快照的配置盖掉别人刚落盘的内容。UpdateWithRetry 在锁内完成替换+持久化, + // 并在版本冲突时重试,读到新配置的其它写者不会丢失更新。 + committed, err := configs.UpdateWithRetry(func(c *configs.Config) error { + *c = *newConfig + return nil + }, 3, 10*time.Millisecond) + if err != nil { + writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{ + ErrNo: http.StatusInternalServerError, + ErrMsg: err.Error(), + }) + return + } + // 先落配置再驱动运行态差异:新增房间在初始化时会按全局配置取登录 cookie, + // 若此时配置还是旧的,新加的房间就以匿名态建连。 + if err := applyLiveRoomsByConfig(ctx, oldConfig, committed); err != nil { writeJSON(writer, map[string]any{ "error": err.Error(), }) return } - if err := newConfig.Marshal(); err != nil { - applog.GetLogger().Error("failed to save config: " + err.Error()) - } writeJSON(writer, commonResp{ Data: "OK", }) @@ -2894,6 +2995,11 @@ func getLiveHostCookie(writer http.ResponseWriter, r *http.Request) { inst := instance.GetInstance(r.Context()) hostCookieMap := make(map[string]*live.InfoCookie) keys := make([]string, 0) + // 配置在启动早期可能尚未就绪,取一次快照统一使用,避免逐房间重复解引用 + allCookies := map[string]string{} + if cfg := configs.GetCurrentConfig(); cfg != nil { + allCookies = cfg.Cookies + } inst.Lives.Range(func(_ types.LiveID, v live.Live) bool { urltmp, _ := url.Parse(v.GetRawUrl()) if _, ok := hostCookieMap[urltmp.Host]; ok { @@ -2901,7 +3007,7 @@ func getLiveHostCookie(writer http.ResponseWriter, r *http.Request) { } host := urltmp.Host platformName := v.GetPlatformCNName() - if cookie, ok := configs.GetCurrentConfig().Cookies[host]; ok { + if cookie, ok := allCookies[host]; ok { tmp := &live.InfoCookie{Platform_cn_name: platformName, Host: host, Cookie: cookie} hostCookieMap[host] = tmp } else { @@ -2911,6 +3017,18 @@ func getLiveHostCookie(writer http.ResponseWriter, r *http.Request) { keys = append(keys, host) return true }) + // 斗鱼扫码入口兜底:列表行只由"运行中的房间 host"推导,用户删掉全部斗鱼房间后 + // 既看不到自动续期失败提醒、也无法重新扫码。只要后端仍持有斗鱼凭证就固定补一行。 + if cfg := configs.GetCurrentConfig(); cfg != nil { + if _, ok := hostCookieMap[dy.CookieHost]; !ok && (cfg.DouyuAuth.LTP0 != "" || cfg.Cookies[dy.CookieHost] != "") { + hostCookieMap[dy.CookieHost] = &live.InfoCookie{ + Platform_cn_name: "斗鱼", + Host: dy.CookieHost, + Cookie: cfg.Cookies[dy.CookieHost], + } + keys = append(keys, dy.CookieHost) + } + } sort.Strings(keys) result := make([]*live.InfoCookie, 0) for _, v := range keys { @@ -2919,6 +3037,10 @@ func getLiveHostCookie(writer http.ResponseWriter, r *http.Request) { writeJSON(writer, result) } +// applyCookiesToLives 把 newCfg 中属于 hosts 的房间重新套用 cookie。 +// 注意:newCfg 只用于"枚举哪些房间要刷新",实际生效的 cookie 由 +// UpdateLiveOptionsbyConfig 从全局当前配置现读;因此调用方必须在 +// 配置更新(CAS 提交)成功之后调用,否则刷到的可能是并发写入的更新值。 func applyCookiesToLives(ctx context.Context, newCfg *configs.Config, hosts ...string) { inst := instance.GetInstance(ctx) hostSet := make(map[string]struct{}, len(hosts)) @@ -2965,7 +3087,9 @@ func putLiveHostCookie(writer http.ResponseWriter, r *http.Request) { ctx := r.Context() data := gjson.ParseBytes(b) - host := data.Get("Host").Str + // host 先归一为标准域名:房间 URL 与 Cookies 键都用标准 host,别名提交若不归一, + // 斗鱼分支(清长期凭证)和热应用(按 host 匹配房间)都会静默不命中。 + host := configs.NormalizeCookieHost(data.Get("Host").Str) cookie := data.Get("Cookie").Str if cookie == "" { @@ -2980,7 +3104,40 @@ func putLiveHostCookie(writer http.ResponseWriter, r *http.Request) { } } // 使用统一 Update 接口更新 Cookies - newCfg, err := configs.SetCookie(host, cookie) + // 斗鱼特殊处理:手动改写/清空 cookie 视为用户显式接管登录态,必须同时清除长期凭证 LTP0 与续期日程, + // 否则后台 keeper 会在下次续期时用旧 LTP0 换票,把用户刚写入(或删除)的 cookie 覆盖回去(幽灵恢复/静默换账号)。 + var newCfg *configs.Config + if host == dy.CookieHost { + newCfg, err = configs.UpdateWithRetry(func(c *configs.Config) error { + // 浏览器导出的 www.douyu.com cookie 常连带 passport 域的 LTP0(domain=.douyu.com)。 + // 长期凭证只应留在 DouyuAuth.LTP0 一处:混进 Cookies 后 /api/cookies 会把它明文回传浏览器, + // 而且请求主站本来也不需要这张票。 + stored := configs.DropCookieFields(strings.TrimSpace(cookie), "LTP0") + oldUid := douyuCookieUid(c.Cookies[host]) + if stored == "" { + delete(c.Cookies, host) + } else { + if c.Cookies == nil { + c.Cookies = make(map[string]string) + } + c.Cookies[host] = stored + } + // 只在"换账号"或"显式存一份未登录 cookie"时作废票据,而不是只要动过 cookie 就清: + // 同账号补/删非登录字段很常见,顺手清掉票据会让自动续期从此静默失效, + // 用户只看到"过几天又断流"。反之新的 cookie 里没有 acf_uid,说明用户在主动退出登录, + // 留着票据就会被后台换票恢复成登录态。 + newUid := douyuCookieUid(stored) + if newUid != oldUid || newUid == "" { + if c.DouyuAuth.LTP0 != "" { + applog.GetLogger().Warnf("斗鱼登录 Cookie 被手工替换为另一份登录态(acf_uid %s -> %s),已清除长期凭证(需重新扫码才能启用自动续期)", oldUid, newUid) + } + c.DouyuAuth = configs.DouyuAuth{} + } + return nil + }, 3, 10*time.Millisecond) + } else { + newCfg, err = configs.SetCookie(host, cookie) + } if err != nil { writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{ ErrNo: http.StatusInternalServerError, @@ -2989,9 +3146,8 @@ func putLiveHostCookie(writer http.ResponseWriter, r *http.Request) { return } applyCookiesToLives(ctx, newCfg, host) - if err := newCfg.Marshal(); err != nil { - applog.GetLogger().Error("failed to persistence config: " + err.Error()) - } + // 不再额外 Marshal:上面的 UpdateWithRetry/SetCookie 已在 updateMu 锁内落过盘。 + // 在锁外重写同一个快照,会把期间后台续期刚写好的 DouyuAuth/新 cookie 盖回旧值(盘上比内存旧,重启才显现)。 writeJSON(writer, commonResp{ Data: "OK", }) @@ -3608,48 +3764,81 @@ func pollBilibiliQRCode(writer http.ResponseWriter, r *http.Request) { return } - // 尝试解析响应,如果是成功状态,尝试从 Cookie 中提取 sid 并附加到 URL - var result struct { - Code int `json:"code"` - Data struct { - Code int `json:"code"` - Url string `json:"url"` - } `json:"data"` + // 登录成功时 B 站把凭证放在响应头 Set-Cookie 里下发,body 的 data.url 只有一个跳转地址, + // 前端光解析 data.url 拼不出 SESSDATA,所以这里把登录字段补进 data.cookies 再回传。 + body = attachBilibiliLoginCookies(body, resp.Cookies()) + + writer.Header().Set("Content-Type", "application/json") + writer.Write(body) +} + +// biliLoginCookieNames 是录制与弹幕真正需要的 B 站 Cookie 字段。 +// buvid、_uuid 这类埋点字段不回传,避免被写进配置文件。 +var biliLoginCookieNames = map[string]bool{ + "SESSDATA": true, + "bili_jct": true, + "DedeUserID": true, + "DedeUserID__ckMd5": true, + "sid": true, +} + +// attachBilibiliLoginCookies 在轮询返回登录成功时,把登录字段汇总到 data.cookies, +// 其余上游字段原样保留;非成功状态或解析失败都返回原始 body。 +func attachBilibiliLoginCookies(body []byte, cookies []*http.Cookie) []byte { + var result map[string]interface{} + decoder := json.NewDecoder(bytes.NewReader(body)) + // 必须用 UseNumber:默认会把所有数字解析成 float64,重新 Marshal 时 + // 超过 2^53 的整数(B 站以后可能回 mid/room_id 这类大整数)会被静默截断成近似值。 + decoder.UseNumber() + if err := decoder.Decode(&result); err != nil { + applog.GetLogger().Error("轮询登录状态解析 JSON 失败: " + err.Error()) + return body + } + data, ok := result["data"].(map[string]interface{}) + if !ok || !isJSONZero(result["code"]) || !isJSONZero(data["code"]) { + return body } - if err := json.Unmarshal(body, &result); err != nil { - applog.GetLogger().Error("轮询登录状态解析 JSON 失败: " + err.Error()) - } else if result.Code == 0 && result.Data.Code == 0 && result.Data.Url != "" { - // 登录成功,检查响应头中的 Cookie - u, err := url.Parse(result.Data.Url) - if err != nil { - applog.GetLogger().Error("解析登录回调 URL 失败: " + err.Error() + ", URL: " + result.Data.Url) - } else { - q := u.Query() - foundExtra := false - if resp.Response != nil { - for _, cookie := range resp.Cookies() { - if cookie.Name == "sid" && q.Get("sid") == "" { - q.Set("sid", cookie.Value) - foundExtra = true - } - } - } - if foundExtra { - u.RawQuery = q.Encode() - result.Data.Url = u.String() - newBody, err := json.Marshal(result) - if err != nil { - applog.GetLogger().Error("序列化增强后的登录结果失败: " + err.Error()) - } else { - body = newBody + login := make(map[string]string) + // 上游也可能把字段挂在 data.url 的 query 上(原实现只从 URL 里取 sid),这里一并收进来, + // 同名时 Set-Cookie 的值优先。 + if rawURL, _ := data["url"].(string); rawURL != "" { + if u, err := url.Parse(rawURL); err == nil { + for name, values := range u.Query() { + if biliLoginCookieNames[name] && len(values) > 0 { + login[name] = values[0] } } } } + for _, c := range cookies { + if biliLoginCookieNames[c.Name] && c.Value != "" { + login[c.Name] = c.Value + } + } + if len(login) == 0 { + return body + } - writer.Header().Set("Content-Type", "application/json") - writer.Write(body) + data["cookies"] = login + newBody, err := json.Marshal(result) + if err != nil { + applog.GetLogger().Error("序列化增强后的登录结果失败: " + err.Error()) + return body + } + return newBody +} + +// isJSONZero 判断 json 数值字段是否为 0(配合 UseNumber,数值是 json.Number)。 +func isJSONZero(v interface{}) bool { + switch number := v.(type) { + case json.Number: + return number.String() == "0" + case float64: + return number == 0 + default: + return false + } } // verifyBilibiliCookie 验证哔哩哔哩 Cookie 有效性 @@ -3714,28 +3903,72 @@ func pollDouyuQRCode(writer http.ResponseWriter, r *http.Request) { if result.State == dy.QRStateSuccess { newCfg, err := configs.UpdateWithRetry(func(c *configs.Config) error { - if c.Cookies == nil { - c.Cookies = make(map[string]string) - } - c.Cookies[dy.CookieHost] = result.Cookie + applyDouyuScanResult(c, result, time.Now()) return nil }, 3, 10*time.Millisecond) if err != nil { - writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{ - ErrNo: http.StatusInternalServerError, - ErrMsg: "斗鱼登录成功,但写入配置失败: " + err.Error(), - }) + // 手机端已经确认登录、票据已消耗,回 HTTP 500 会让前端只看到"查询连续失败", + // 用户以为是网络问题而反复重扫。用独立状态把"保存失败"这个真相送到界面上。 + applog.GetLogger().WithError(err).Error("斗鱼扫码登录成功但写入配置失败") + result.State = dy.QRStateSaveFailed + result.Msg = err.Error() + writeJSON(writer, commonResp{Data: result}) return } + // 扫码已经拿到全新登录态,为"上一轮写盘失败"记的内存退避到此失去意义: + // 不退避就会让 keeper 在几小时后拿刚扫到的新鲜 cookie 再打一次 safeAuth。 + clearRenewScheduleOverride() + // 同理复位进程内的提醒去重标记:用户按提示重扫成功后,若还是这张票据又出了同样的问题, + // 必须还能再提醒一次,否则"我扫过了却又断了"将彻底无声。 + noteRenewHealthy() applyCookiesToLives(r.Context(), newCfg, dy.CookieHost) - applog.GetLogger().Infof("斗鱼扫码登录成功: uid=%s nickname=%s cookieLength=%d", result.UserID, result.Nickname, len(result.Cookie)) + applog.GetLogger().Infof("斗鱼扫码登录成功: uid=%s nickname=%s cookieLength=%d gotLTP0=%t", result.UserID, result.Nickname, len(result.Cookie), result.LTP0 != "") } - // 不回传原始 cookie 内容,前端需要时可经 /api/cookies 查看 + // 不回传原始 cookie 内容,前端需要时可经 /api/cookies 查看; + // LTP0/dy_did 是 passport 域长期凭证,绝不能下发给前端,仅留在后端配置中用于续期。 result.Cookie = "" + result.LTP0 = "" + result.DyDid = "" writeJSON(writer, commonResp{Data: result}) } +// douyuAuthStatus 斗鱼登录态自动续期的前端可见状态。 +// 只暴露状态与时间点,绝不回传 LTP0/dy_did/cookie 原文。 +type douyuAuthStatus struct { + LoggedIn bool `json:"logged_in"` // 登录态是否可用(有 acf_uid,且续期链路没有长期停摆) + HasLTP0 bool `json:"has_ltp0"` // 是否已绑定扫码引导的长期凭证 + NeedRescan bool `json:"need_rescan"` // 自动续期是否已判定凭证失效、需重新扫码 + NextRefreshAt int64 `json:"next_refresh_at"` // 下次自动续期的 Unix 秒;0 表示尚未排期 +} + +// getDouyuAuthStatus 供 Web 界面展示斗鱼 cookie 自动续期健康状况(续期失败提醒等) +func getDouyuAuthStatus(writer http.ResponseWriter, r *http.Request) { + cfg := configs.GetCurrentConfig() + if cfg == nil { + writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{ + ErrNo: http.StatusInternalServerError, + ErrMsg: "配置尚未加载", + }) + return + } + auth := cfg.DouyuAuth + loggedIn := douyuCookieUid(cfg.Cookies[dy.CookieHost]) != "" + // cookie 里有 acf_uid 只代表"曾经登录过",不代表现在还在有效期内(主站 cookie 约 6 天)。 + // 判据与录制侧共用 dy.LoginCookieKnownExpired:界面说"未登录"和录制端"不再发这份 cookie" + // 必须同口径,两处各留一份阈值迟早会漂移(表现为界面绿着却一直在匿名录制,或反过来 + // 界面报未登录而实际还能用)。没有 LTP0(手填 cookie、历史配置)时没有任何过期证据,不据此判未登录。 + if loggedIn && dy.LoginCookieKnownExpired(auth.LTP0, auth.LastRenewSuccessAt, time.Now()) { + loggedIn = false + } + writeJSON(writer, commonResp{Data: douyuAuthStatus{ + LoggedIn: loggedIn, + HasLTP0: auth.LTP0 != "", + NeedRescan: auth.NeedRescan, + NextRefreshAt: effectiveNextRefreshAt(cfg), // 内存退避也要计入,否则界面显示的续期时间比实际早 + }}) +} + // startRecordDirect 直接启动录制(绕过 Listener,适用于 NotifyOnly 房间) func startRecordDirect(writer http.ResponseWriter, r *http.Request) { inst := instance.GetInstance(r.Context()) diff --git a/src/servers/server.go b/src/servers/server.go index 30e0652..ef1f5c2 100644 --- a/src/servers/server.go +++ b/src/servers/server.go @@ -110,6 +110,7 @@ func initMux(ctx context.Context) *mux.Router { // Douyu Login apiRoute.HandleFunc("/douyu/qrcode", getDouyuQRCode).Methods("GET") apiRoute.HandleFunc("/douyu/qrcode/poll", pollDouyuQRCode).Methods("GET") + apiRoute.HandleFunc("/douyu/auth/status", getDouyuAuthStatus).Methods("GET") apiRoute.HandleFunc("/sooplive/auth", getSoopLiveAuthConfig).Methods("GET") apiRoute.HandleFunc("/sooplive/auth", clearSoopLiveAuthConfig).Methods("DELETE") apiRoute.HandleFunc("/sooplive/login", loginSoopLive).Methods("POST") diff --git a/src/webapp/src/component/edit-cookie/bili-login-panel.tsx b/src/webapp/src/component/edit-cookie/bili-login-panel.tsx index 0d8a09c..6af4577 100644 --- a/src/webapp/src/component/edit-cookie/bili-login-panel.tsx +++ b/src/webapp/src/component/edit-cookie/bili-login-panel.tsx @@ -5,6 +5,9 @@ import './edit-cookie.css'; const { TextArea } = Input; +// 与后端 biliLoginCookieNames 保持一致,顺序即拼出的 Cookie 顺序 +const BILI_COOKIE_FIELDS = ['DedeUserID', 'DedeUserID__ckMd5', 'SESSDATA', 'bili_jct', 'sid']; + interface BiliLoginPanelProps { initialCookie: string; onCookieChange: (cookie: string) => void; @@ -54,27 +57,24 @@ const BiliLoginPanel: React.FC = ({ initialCookie, onCookie }); }, [api]); - const processLoginSuccess = useCallback((urlStr: string) => { - try { - const urlObj = new URL(urlStr); - const params = urlObj.searchParams; - const cookies = [ - `DedeUserID=${params.get('DedeUserID')}`, - `DedeUserID__ckMd5=${params.get('DedeUserID__ckMd5')}`, - `SESSDATA=${params.get('SESSDATA')}`, - `bili_jct=${params.get('bili_jct')}`, - `sid=${params.get('sid')}`, - ]; - // Filter out null values and join - const cookieStr = cookies.filter(c => !c.includes('null')).join('; ') + ';'; - setTextView(cookieStr); - onCookieChangeRef.current(cookieStr); - // Auto verify after success - verifyCookie(cookieStr); - } catch (e) { - console.error(e); - notification.error({ message: '解析结果失败' }); + const processLoginSuccess = useCallback((data: any) => { + // 后端已把 B 站 Set-Cookie 下发的登录字段整理进 data.cookies;data.url 只是跳转地址,不含凭证 + const received = (data && data.cookies) || {}; + if (!received.SESSDATA) { + notification.error({ + message: '登录响应未返回 Cookie 凭证', + description: '请输入框内仍是旧 Cookie,未点保存不会覆盖。可关闭后重试,或按下方教程手动粘贴 Cookie。', + }); + return; } + const cookieStr = BILI_COOKIE_FIELDS + .filter(name => received[name]) + .map(name => `${name}=${received[name]}`) + .join('; ') + ';'; + setTextView(cookieStr); + onCookieChangeRef.current(cookieStr); + // Auto verify after success + verifyCookie(cookieStr); }, [verifyCookie]); const startPolling = useCallback((key: string) => { @@ -89,7 +89,7 @@ const BiliLoginPanel: React.FC = ({ initialCookie, onCookie clearInterval(pollTimerRef.current); setLoginStatus('success'); setLoginMsg('登录成功!'); - processLoginSuccess(data.url); + processLoginSuccess(data); } else if (data.code === 86101) { setLoginStatus('active'); setLoginMsg('等待扫描...'); diff --git a/src/webapp/src/component/edit-cookie/douyu-login-panel.tsx b/src/webapp/src/component/edit-cookie/douyu-login-panel.tsx index c0db796..0badab7 100644 --- a/src/webapp/src/component/edit-cookie/douyu-login-panel.tsx +++ b/src/webapp/src/component/edit-cookie/douyu-login-panel.tsx @@ -7,7 +7,8 @@ const { TextArea } = Input; interface DouyuLoginPanelProps { initialCookie: string; - onCookieChange: (cookie: string) => void; + /** 兼容父组件签名,斗鱼面板改为只读展示后不再回写 Cookie */ + onCookieChange?: (cookie: string) => void; /** 扫码登录成功后由父组件重新拉取服务端 Cookie 并同步显示基准 */ onScanSuccess?: () => void; api: API; @@ -20,6 +21,17 @@ interface DouyuIdentity { nickname: string; } +// 后端 /douyu/auth/status 返回的自动续期健康状态(不含凭证原文) +interface DouyuAuthStatus { + logged_in: boolean; + has_ltp0: boolean; + need_rescan: boolean; + next_refresh_at: number; +} + +const formatStamp = (unixSec: number): string => + new Date(unixSec * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); + /** * 斗鱼扫码登录面板。 * 与 B 站面板不同:确认登录后由后端自动完成 cookie 换取与保存, @@ -65,37 +77,81 @@ const parseDouyuCookieExpiry = (cookieStr: string): number | null => { } }; -const DouyuLoginPanel: React.FC = ({ initialCookie, onCookieChange, onScanSuccess, api }) => { +const DouyuLoginPanel: React.FC = ({ initialCookie, onScanSuccess, api }) => { const [qrCodeUrl, setQrCodeUrl] = useState(''); const [loginStatus, setLoginStatus] = useState('loading'); const [loginMsg, setLoginMsg] = useState('正在获取二维码...'); const [loginUser, setLoginUser] = useState(null); const [textView, setTextView] = useState(initialCookie); + const [authStatus, setAuthStatus] = useState(null); const pollTimerRef = useRef(null); const isMounted = useRef(true); // 连续轮询失败计数:后端持续异常(如网络中断)时避免每 2s 无限重试 const pollErrorCountRef = useRef(0); + // 上一次轮询是否仍未返回:上游超时 15s 而间隔只有 2s,不挡住会堆积并发请求 + const pollInFlightRef = useRef(false); + // 二维码有效期截止时间戳:斗鱼侧 error 码长期不回 -1 时,前端也必须自行停止轮询 + const qrDeadlineRef = useRef(0); + // 轮询会话代号:刷新二维码会开启新一轮轮询,而上一轮的请求可能仍在路上(上游超时可达 15s)。 + // 不做隔离的话,迟到的旧响应会 stopPolling() 停掉刚起的新定时器,并把界面打成"已失效/连续失败"。 + const pollSessionRef = useRef(0); const stopPolling = useCallback(() => { if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } + pollInFlightRef.current = false; }, []); + // 二维码到期/连续失败后的统一收尾:停止轮询并提示刷新 + const giveUpPolling = useCallback((msg: string) => { + stopPolling(); + setLoginStatus('expired'); + setLoginMsg(msg); + }, [stopPolling]); + // 父组件在扫码成功后会重新拉取服务端 cookie 并更新 initialCookie,同步到本地显示 useEffect(() => { setTextView(initialCookie); }, [initialCookie]); + // 拉取后端自动续期状态(续期失败提醒、下次续期时间) + const fetchAuthStatus = useCallback(() => { + api.getDouyuAuthStatus() + .then((res: any) => { + if (isMounted.current && res.err_no === 0 && res.data) setAuthStatus(res.data); + }) + .catch(() => { + // 该接口只用于展示提示,失败时静默降级为不显示,不打断扫码主流程 + }); + }, [api]); + + useEffect(() => { + fetchAuthStatus(); + // 低频刷新:续期由后台按需触发(约每 3 天一次;临时失败按 6 小时退避,判定凭证失效后 24 小时才再试), + // 面板开着不动的话"已自动续期/已判定失效"永远看不到。60s 一次足够,且接口只读配置、无外部请求。 + const timer = setInterval(fetchAuthStatus, 60000); + return () => clearInterval(timer); + }, [fetchAuthStatus]); + const startPolling = useCallback((code: string) => { stopPolling(); + const session = ++pollSessionRef.current; pollErrorCountRef.current = 0; pollTimerRef.current = setInterval(() => { + if (pollInFlightRef.current) return; // 上一次还没回来就先跳过,避免请求堆积 + if (qrDeadlineRef.current && Date.now() > qrDeadlineRef.current) { + giveUpPolling('二维码已超时失效,请点击下方按钮刷新'); + return; + } + pollInFlightRef.current = true; api.pollDouyuQRCode(code) .then((res: any) => { - if (!isMounted.current) return; + // 迟到的旧会话响应一律丢弃:它既不能改状态,也不该重置新会话的在途标记 + if (!isMounted.current || session !== pollSessionRef.current) return; + pollInFlightRef.current = false; // 后端统一响应格式为 {err_no, err_msg, data} if (res.err_no !== 0 || !res.data) { pollErrorCountRef.current += 1; @@ -103,9 +159,7 @@ const DouyuLoginPanel: React.FC = ({ initialCookie, onCook pollErrorCountRef.current = 0; } if (pollErrorCountRef.current >= 5) { - stopPolling(); - setLoginStatus('expired'); - setLoginMsg('登录状态查询连续失败,请刷新二维码重试'); + giveUpPolling('登录状态查询连续失败,请刷新二维码重试'); return; } if (res.err_no !== 0 || !res.data) return; @@ -127,6 +181,7 @@ const DouyuLoginPanel: React.FC = ({ initialCookie, onCook // 后端不回传 cookie 原文,重新拉取服务端最新值刷新 TextArea, // 避免用户基于过期的旧文本编辑并保存、覆盖掉新登录态 onScanSuccess?.(); + fetchAuthStatus(); notification.success({ message: '斗鱼登录成功', description: '登录 Cookie 已保存,新的录制将携带登录态(可消除约 5 分钟的匿名断流)', @@ -142,18 +197,41 @@ const DouyuLoginPanel: React.FC = ({ initialCookie, onCook setLoginStatus('expired'); setLoginMsg('登录被取消或拒绝,请刷新二维码重试'); break; + case 'save_failed': + // 手机端已确认、一次性登录票据已消耗:反复重扫没用,必须先让配置文件可写 + stopPolling(); + setLoginStatus('expired'); + setLoginMsg('登录已在手机上确认成功,但写入配置文件失败,请检查磁盘空间与配置文件是否可写后重新扫码' + (data.msg ? `(${data.msg})` : '')); + break; default: + // 未识别的状态(后端/上游接口变更)必须可见,否则用户只看到"请扫码"却永远扫不上 + giveUpPolling('登录状态无法识别' + (data.msg ? `:${data.msg}` : '') + ',请刷新二维码重试'); break; } }) - .catch(console.error); + .catch(() => { + if (!isMounted.current || session !== pollSessionRef.current) return; + pollInFlightRef.current = false; + // 后端把上游失败统一回成 HTTP 5xx,此时 Promise 直接 reject, + // 上面的 err_no 计数走不到;不在此处计数的话熔断逻辑就是死代码,会每 2s 无限重试。 + pollErrorCountRef.current += 1; + if (pollErrorCountRef.current >= 5) { + giveUpPolling('登录状态查询连续失败,请检查网络后刷新二维码重试'); + } + }); }, 2000); - }, [api, stopPolling, onScanSuccess]); + }, [api, stopPolling, giveUpPolling, onScanSuccess, fetchAuthStatus]); const getDouyuQRCode = useCallback(() => { + // 先断掉旧会话:正在路上的上一轮轮询响应不能再回来改状态 + stopPolling(); + pollSessionRef.current += 1; + // 清掉旧二维码图:加载中仍挂着上一张时,用户扫到的是已经作废的码 + setQrCodeUrl(''); setLoginStatus('loading'); setLoginMsg('正在获取二维码...'); setLoginUser(null); + qrDeadlineRef.current = 0; api.getDouyuQRCode() .then((res: any) => { if (!isMounted.current) return; @@ -161,6 +239,9 @@ const DouyuLoginPanel: React.FC = ({ initialCookie, onCook setQrCodeUrl(res.data.qr_url); setLoginStatus('active'); setLoginMsg('请用斗鱼 App 扫码'); + // 二维码有有效期(斗鱼回 300 秒):上游状态异常时前端也要自行到期停止轮询 + const expire = Number(res.data.expire) || 0; + qrDeadlineRef.current = expire > 0 ? Date.now() + expire * 1000 : 0; startPolling(res.data.code); } else { setLoginStatus('expired'); @@ -173,13 +254,7 @@ const DouyuLoginPanel: React.FC = ({ initialCookie, onCook setLoginMsg('获取二维码失败,请检查网络'); console.error(err); }); - }, [api, startPolling]); - - const handleTextChange = (e: any) => { - const val = e.target.value; - setTextView(val); - onCookieChange(val); - }; + }, [api, startPolling, stopPolling]); useEffect(() => { isMounted.current = true; @@ -194,23 +269,64 @@ const DouyuLoginPanel: React.FC = ({ initialCookie, onCook const displayUser = loginUser || parseDouyuIdentity(textView); const scanSucceeded = loginStatus === 'success'; + // 后台续期状态:needRescan 表示 safeAuth 换票已判定凭证失效,必须由用户重新扫码 + const needRescan = !!authStatus?.need_rescan; + const hasLtp0 = !!authStatus?.has_ltp0; + const nextRenewAt = authStatus?.next_refresh_at || 0; + // 服务端才是"当前登录态是否可用"的权威(见 statusLine)。本地 TextArea 里的文本可能已经被 + // 后台续期换掉或被清空,只按它算有效期会和红字状态打架,所以红字时不再展示有效期行。 + const serverSaysUsable = authStatus === null || authStatus.logged_in; + // 扫码成功后,父组件还要异步把新 cookie 拉回来;这中间 TextArea 里仍是扫码前的旧文本, + // 按旧文本算出的"已过期"会和"登录成功"同屏。等文本确实换成这次登录的账号后再展示。 + const cookieTextIsStale = scanSucceeded && !!loginUser && displayUser?.uid !== loginUser.uid; + // 登录态有效期:来自 acf_jwt_token 的 exp(斗鱼签发时固定 7 天) const expiryMs = parseDouyuCookieExpiry(textView); let expiryInfo: { text: string; color: string } | null = null; - if (expiryMs !== null) { + if (expiryMs !== null && serverSaysUsable && !cookieTextIsStale) { const remainDays = (expiryMs - Date.now()) / 86400000; const until = new Date(expiryMs).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); if (remainDays <= 0) { - expiryInfo = { text: `Cookie 已于 ${until} 过期,请重新扫码登录`, color: '#ff4d4f' }; + // needRescan 才是"续期失败"的权威信号(顶部红色 Alert 与状态行都按它显示)。 + // 只要还绑着长期凭证且后端没判失效,本地文本的 exp 过期多半只是这段文本没跟着刷新, + // 再喊"请重新扫码登录"就会和绿色的"检测到有效登录 Cookie"同屏自相矛盾。 + // authStatus 尚未返回时同样不能断言"续期未成功",口径与 ltp0Missing 保持一致。 + expiryInfo = authStatus === null || (hasLtp0 && !needRescan) + ? { text: `本地 Cookie 文本的签名已于 ${until} 到期,登录态以后端自动续期状态为准`, color: '#8c8c8c' } + : { text: `Cookie 已于 ${until} 过期,自动续期未成功,请重新扫码登录`, color: '#ff4d4f' }; } else if (remainDays <= 2) { - expiryInfo = { text: `Cookie 将于 ${until} 过期(剩余不足 2 天),建议尽快重新扫码`, color: '#fa8c16' }; + expiryInfo = { text: `Cookie 将于 ${until} 过期(剩余不足 2 天),系统会自动续期`, color: '#fa8c16' }; } else { - expiryInfo = { text: `Cookie 有效期至 ${until}(剩余 ${Math.floor(remainDays)} 天)`, color: '#8c8c8c' }; + expiryInfo = { text: `Cookie 有效期至 ${until}(剩余 ${Math.floor(remainDays)} 天,将自动续期)`, color: '#8c8c8c' }; } } + // 状态接口尚未返回/返回失败时 authStatus 为 null,此时不能断定"没有 LTP0", + // 否则会对已启用自动续期的用户误报橙色警告。 + const ltp0Missing = authStatus !== null && !hasLtp0; + // 服务端才是登录态的权威来源:本地 Cookie 文本可能是打开面板时的旧值(例如另一处已清空/已被续期替换), + // 只按 scanSucceeded/needRescan 回落会让"其实没登录"显示成绿色"检测到有效登录 Cookie"。 + const statusLine = scanSucceeded + ? { text: '状态:扫码登录成功,Cookie 已自动保存并应用到运行中的房间', color: '#52c41a' } + : needRescan + ? { text: '状态:长期凭证已失效,自动续期未成功,请重新扫码', color: '#ff4d4f' } + : authStatus === null + ? { text: '状态:正在读取登录状态...', color: '#8c8c8c' } + : authStatus.logged_in + ? { text: '状态:检测到有效登录 Cookie,录制将自动携带登录态', color: '#52c41a' } + : { text: '状态:当前没有可用的登录 Cookie,斗鱼房间为匿名录制(约 5 分钟被切断一次),请扫码登录', color: '#ff4d4f' }; + return (
+ {needRescan && ( + 斗鱼自动续期失败,请重新扫码登录} + description="长期凭证(LTP0)已失效,后台无法再自动续期;当前 Cookie 到期后斗鱼房间会退回匿名录制、约 5 分钟被切断一次。请用斗鱼 App 扫描左侧二维码,扫码成功后本提示自动撤销(若这一次斗鱼未下发新的长期凭证,自动续期需再扫一次码才会恢复)。" + /> + )}
{/* QR Section */}
@@ -222,11 +338,15 @@ const DouyuLoginPanel: React.FC = ({ initialCookie, onCook
) : ( <> - QR Code + {/* 没有二维码 URL 时不渲染 img:src 里的 data= 为空会画出一张无意义图, + 用户对着它扫码只会失败 */} + {qrCodeUrl && ( + QR Code + )} {(loginStatus === 'scanned' || loginStatus === 'success' || loginStatus === 'expired') && (
@@ -263,68 +383,79 @@ const DouyuLoginPanel: React.FC = ({ initialCookie, onCook )}
- {/* Manual Section */} + {/* Cookie 状态展示(只读) */}
- 手动管理 Cookie + 当前登录 Cookie(只读)