feat(live/douyu,servers,configs,webapp): 斗鱼 cookie 后台自动续期(LTP0 + safeAuth 换票)

主站 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 与票据均未被破坏。
This commit is contained in:
Joftal committed 2026-09-25 20:45:43 +08:00
1 parent 43f3dfadb1
commit 78c6336c93
24 files changed
+3723 -155

No files matched your search

+1 -1
View File
@@ -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
+5 -1
View File
@@ -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)在工具可用之前
// 不去请求直播间信息,从而也不会启动录制任务。
+301 -3
View File
@@ -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)。
+226
View File
@@ -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)
}
@@ -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")
}
+58 -13
View File
@@ -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,7 +219,7 @@ 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,兼做去重
seen := make(map[string]string) // 字段名 -> 值,后一跳覆盖前一跳(终点才是权威登录态)
var hops []string // 跳转链诊断信息(仅记录 cookie 名,不记录值)
cur := rawUrl
var finalBody string
@@ -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
}
hops = append(hops, fmt.Sprintf("%s -> %d (Set-Cookie: %v)", cur, resp.StatusCode, names))
seen[c.Name] = c.Value
}
// 一次性登录 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
}
+90 -6
View File
@@ -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,7 +150,9 @@ func (l *Live) getEngineWithCryptoJS() (*otto.Otto, error) {
type Live struct {
internal.BaseLive
roomID string
// did 在 Live 生命周期内固定,避免每次签名都换新设备号触发风控
// did 在 Live 生命周期内固定,避免每次签名都换新设备号触发风控;
// 读取自签名路径、重置自配置热更新,故需加锁。
didMu sync.Mutex
did string
}
@@ -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 != "" {
+57
View File
@@ -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)
}
}
+201
View File
@@ -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)
}
}
}
+299
View File
@@ -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
}
+373
View File
@@ -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: "<!DOCTYPE html><html>verify</html>", 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())
}
})
}
}
+11
View File
@@ -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
+72
View File
@@ -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`, "大整数必须逐字节保留")
}
+463
View File
@@ -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
}
+319
View File
@@ -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"]
}
+115
View File
@@ -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)
}
}
+564
View File
@@ -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)
}
}
+299 -66
View File
@@ -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,50 +3764,83 @@ 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"`
}
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
}
}
}
}
// 登录成功时 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
}
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
}
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 有效性
func verifyBilibiliCookie(writer http.ResponseWriter, r *http.Request) {
var req struct {
@@ -3714,26 +3903,70 @@ 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 {
// 手机端已经确认登录、票据已消耗,回 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 gotLTP0=%t", result.UserID, result.Nickname, len(result.Cookie), result.LTP0 != "")
}
// 不回传原始 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: "斗鱼登录成功,但写入配置失败: " + err.Error(),
ErrMsg: "配置尚未加载",
})
return
}
applyCookiesToLives(r.Context(), newCfg, dy.CookieHost)
applog.GetLogger().Infof("斗鱼扫码登录成功: uid=%s nickname=%s cookieLength=%d", result.UserID, result.Nickname, len(result.Cookie))
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
}
// 不回传原始 cookie 内容,前端需要时可经 /api/cookies 查看
result.Cookie = ""
writeJSON(writer, commonResp{Data: result})
writeJSON(writer, commonResp{Data: douyuAuthStatus{
LoggedIn: loggedIn,
HasLTP0: auth.LTP0 != "",
NeedRescan: auth.NeedRescan,
NextRefreshAt: effectiveNextRefreshAt(cfg), // 内存退避也要计入,否则界面显示的续期时间比实际早
}})
}
// startRecordDirect 直接启动录制(绕过 Listener,适用于 NotifyOnly 房间)
+1
View File
@@ -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")
@@ -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<BiliLoginPanelProps> = ({ 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('; ') + ';';
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);
} catch (e) {
console.error(e);
notification.error({ message: '解析结果失败' });
}
}, [verifyCookie]);
const startPolling = useCallback((key: string) => {
@@ -89,7 +89,7 @@ const BiliLoginPanel: React.FC<BiliLoginPanelProps> = ({ initialCookie, onCookie
clearInterval(pollTimerRef.current);
setLoginStatus('success');
setLoginMsg('登录成功!');
processLoginSuccess(data.url);
processLoginSuccess(data);
} else if (data.code === 86101) {
setLoginStatus('active');
setLoginMsg('等待扫描...');
@@ -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<DouyuLoginPanelProps> = ({ initialCookie, onCookieChange, onScanSuccess, api }) => {
const DouyuLoginPanel: React.FC<DouyuLoginPanelProps> = ({ initialCookie, onScanSuccess, api }) => {
const [qrCodeUrl, setQrCodeUrl] = useState('');
const [loginStatus, setLoginStatus] = useState<QRStatus>('loading');
const [loginMsg, setLoginMsg] = useState('正在获取二维码...');
const [loginUser, setLoginUser] = useState<DouyuIdentity | null>(null);
const [textView, setTextView] = useState(initialCookie);
const [authStatus, setAuthStatus] = useState<DouyuAuthStatus | null>(null);
const pollTimerRef = useRef<any>(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<DouyuLoginPanelProps> = ({ 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<DouyuLoginPanelProps> = ({ initialCookie, onCook
// 后端不回传 cookie 原文,重新拉取服务端最新值刷新 TextArea,
// 避免用户基于过期的旧文本编辑并保存、覆盖掉新登录态
onScanSuccess?.();
fetchAuthStatus();
notification.success({
message: '斗鱼登录成功',
description: '登录 Cookie 已保存,新的录制将携带登录态(可消除约 5 分钟的匿名断流)',
@@ -142,18 +197,41 @@ const DouyuLoginPanel: React.FC<DouyuLoginPanelProps> = ({ 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<DouyuLoginPanelProps> = ({ 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<DouyuLoginPanelProps> = ({ 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<DouyuLoginPanelProps> = ({ 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 (
<div className="bili-login-container">
{needRescan && (
<Alert
type="error"
showIcon
style={{ marginBottom: 12 }}
message={<span style={{ fontWeight: 700, fontSize: '15px' }}>斗鱼自动续期失败,请重新扫码登录</span>}
description="长期凭证(LTP0)已失效,后台无法再自动续期;当前 Cookie 到期后斗鱼房间会退回匿名录制、约 5 分钟被切断一次。请用斗鱼 App 扫描左侧二维码,扫码成功后本提示自动撤销(若这一次斗鱼未下发新的长期凭证,自动续期需再扫一次码才会恢复)。"
/>
)}
<div className="bili-login-layout">
{/* QR Section */}
<div className="bili-qr-section">
@@ -222,11 +338,15 @@ const DouyuLoginPanel: React.FC<DouyuLoginPanelProps> = ({ initialCookie, onCook
<div className="qr-overlay"><Spin tip="获取中..." /></div>
) : (
<>
{/* 没有二维码 URL 时不渲染 img:src 里的 data= 为空会画出一张无意义图,
用户对着它扫码只会失败 */}
{qrCodeUrl && (
<img
className="qr-image"
src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(qrCodeUrl)}`}
alt="QR Code"
/>
)}
{(loginStatus === 'scanned' || loginStatus === 'success' || loginStatus === 'expired') && (
<div className="qr-overlay">
<div className="qr-status-icon">
@@ -263,68 +383,79 @@ const DouyuLoginPanel: React.FC<DouyuLoginPanelProps> = ({ initialCookie, onCook
)}
</div>
{/* Manual Section */}
{/* Cookie 状态展示(只读) */}
<div className="bili-manual-section">
<div className="section-label">
<span>手动管理 Cookie</span>
<span>当前登录 Cookie(只读)</span>
</div>
<TextArea
className="cookie-textarea"
placeholder="在此粘贴或修改 Cookie 字符串... (需包含 acf_uid、acf_auth 等登录字段)"
placeholder="尚无 Cookie,请在左侧扫码登录"
value={textView}
autoSize={{ minRows: 6, maxRows: 6 }}
onChange={handleTextChange}
readOnly
/>
<div className="manual-tip">提示:手动粘贴的 Cookie 需点击"保存并生效"落盘;扫码登录由后端自动保存,无需再点击</div>
<div className="manual-tip">
斗鱼登录 Cookie 只能通过扫码获取,且会随长期凭证自动续期,无需也不能手动编辑。若下方提示已过期且持续无法自动续期,请重新扫码。
</div>
{displayUser ? (
<div className="verification-card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Badge status="processing" color="#52c41a" />
<Badge status={needRescan ? 'error' : 'processing'} color={statusLine.color} />
<span className="user-badge">{displayUser.nickname || '未知昵称'}</span>
<span className="uid-text">UID: {displayUser.uid}</span>
</div>
</div>
<div style={{ fontSize: '13px', color: '#52c41a', marginTop: '8px', fontWeight: 500 }}>
{scanSucceeded
? '状态:扫码登录成功,Cookie 已自动保存并应用到运行中的房间'
: '状态:检测到登录 Cookie,保存后可正常携带登录态拉流'}
<div style={{ fontSize: '13px', color: statusLine.color, marginTop: '8px', fontWeight: 500 }}>
{statusLine.text}
</div>
{expiryInfo && (
<div style={{ fontSize: '13px', color: expiryInfo.color, marginTop: '4px', fontWeight: 500 }}>
{expiryInfo.text}
</div>
)}
{hasLtp0 && nextRenewAt > 0 && (
<div style={{ fontSize: '13px', color: needRescan ? '#ff4d4f' : '#8c8c8c', marginTop: '4px' }}>
{needRescan
? `将在 ${formatStamp(nextRenewAt)} 自动重试续期,若持续失败请重新扫码`
: `下次自动续期:${formatStamp(nextRenewAt)}(系统自动完成,无需操作)`}
</div>
)}
{ltp0Missing && (
<div style={{ fontSize: '13px', color: '#fa8c16', marginTop: '4px' }}>
尚未记录长期凭证(LTP0),Cookie 过期后无法自动续期,建议重新扫码一次以启用自动续期
</div>
)}
</div>
) : (
<div className="verification-card pending">
<span>
{textView
? '当前 Cookie 未包含登录字段(acf_uid),可能仍处于未登录状态'
: '请扫码或输入 Cookie 以开始'}
? '当前 Cookie 未包含登录字段(acf_uid),请重新扫码登录'
: '请在左侧扫码登录'}
</span>
</div>
)}
</div>
</div>
<Divider className="divider-text">如果您选择手动获取 Cookie</Divider>
<Divider className="divider-text">关于斗鱼登录 Cookie</Divider>
<Alert
className="info-alert"
showIcon
message={<span style={{ fontWeight: 700, fontSize: '15px' }}>手动获取教程</span>}
message={<span style={{ fontWeight: 700, fontSize: '15px' }}>扫码登录 · 自动续期</span>}
type="info"
description={
<div style={{ fontSize: '14px' }}>
推荐使用扫码登录。匿名(无 Cookie)录制会被斗鱼 CDN 约 5 分钟切断一次流并不断重开分段,请确保 Cookie 处于登录状态,步骤如下:
匿名(无 Cookie)录制会被斗鱼 CDN 约 5 分钟切断一次流并不断重开分段,需要保持登录态。斗鱼登录 Cookie 只能通过扫码获取,其使用方式如下:
<ul className="instruction-list">
<li>在浏览器打开 <b>斗鱼直播</b>(www.douyu.com)并保持登录状态。</li>
<li>按键盘上的 <b>F12</b> 或右键选择 <b>检查</b>,切换到 <b>网络 (Network)</b> 面板。</li>
<li>刷新页面,点开列表中的 <b>www.douyu.com</b> 第一个请求,在 <b>标头 (Headers)</b> 中找到 <b>Cookie</b> 一栏。</li>
<li><b>右键选中复制值</b>,并粘贴到上方输入框内(需包含 <b>acf_uid</b>、<b>acf_auth</b> 字段)。</li>
<li>点击 <b>保存并生效</b> 按钮完成落盘。</li>
<li>用手机 <b>斗鱼 App</b> 扫描左侧二维码并确认登录,Cookie 会由后端自动保存并立即生效。</li>
<li>主站登录 Cookie(<b>acf_*</b>)约 6 天有效;扫码时后端同时记录了长期凭证 <b>LTP0</b>,之后系统会在到期前<b>自动按需换票续期</b>(约每 3 天一次),无需你再次操作。</li>
<li>只有当长期凭证也失效(页面顶部出现红色"自动续期失败"提示)时,才需要重新扫码一次。</li>
<li>因 Cookie 会持续轮换、且长期凭证仅能由扫码引导,<b>不再支持手动粘贴 Cookie</b>(手填的 Cookie 约 6 天后过期且无法自动续期)。</li>
</ul>
</div>
}
@@ -49,8 +49,11 @@ class EditCookieDialog extends React.Component<IProps> {
* 斗鱼扫码登录成功后,后端已自动落盘新 cookie(轮询接口不回传原文)。
* 这里重新拉取 /api/cookies,把显示文本与"未修改"基准一起刷新为新值,
* 避免用户基于旧文本编辑保存、覆盖新登录态。
* 同时通知父组件刷新 Cookie 列表:斗鱼面板没有"保存"按钮,用户扫完通常直接关闭弹窗,
* 若不刷新,列表上那枚"需重新扫码"红标会一直挂着(续期失败的可视化提示就无法自动撤销)。
*/
handleDouyuScanSuccess = async () => {
this.props.refresh();
try {
const list: any = await this.api.getCookieList();
const entries = Array.isArray(list) ? list : [];
@@ -148,7 +151,8 @@ class EditCookieDialog extends React.Component<IProps> {
onCancel={this.handleCancel}
width={(isBili || isSoop || isDouyu) ? 720 : 520}
okText="保存并生效"
cancelText="取消"
cancelText={isDouyu ? '关闭' : '取消'}
okButtonProps={isDouyu ? { style: { display: 'none' } } : undefined}
destroyOnClose
>
{isBili ? (
+29 -7
View File
@@ -317,6 +317,7 @@ interface IState {
sortedInfo: { columnKey: string | null; order: 'ascend' | 'descend' | null }, // 表格排序状态
danmakuMessages: { [key: string]: DanmakuMessage[] }, // roomId -> 弹幕消息列表
expandedActiveTabs: { [key: string]: string }, // roomId -> 当前激活的 tab key
douyuNeedRescan: boolean, // 斗鱼 cookie 自动续期已失败,需重新扫码
}
interface ItemData {
@@ -571,7 +572,19 @@ class LiveList extends React.Component<Props, IState> {
title: '直播平台',
dataIndex: 'livename',
key: 'livename',
render: (name: string, data: CookieItemData) => data.Platform_cn_name + '(' + data.Host + ')'
render: (name: string, data: CookieItemData) => {
const isDouyu = data.Host === 'www.douyu.com' || data.Host === 'douyu.com';
return (
<span>
{data.Platform_cn_name + '(' + data.Host + ')'}
{isDouyu && this.state.douyuNeedRescan && (
<Tooltip title="斗鱼登录 Cookie 自动续期已失败(长期凭证失效),录制将退回匿名录制,请点击右侧按钮重新扫码登录">
<Tag color="red" style={{ marginLeft: 8 }}>需重新扫码</Tag>
</Tooltip>
)}
</span>
);
}
}, {
title: 'Cookie',
dataIndex: 'Cookie',
@@ -626,6 +639,7 @@ class LiveList extends React.Component<Props, IState> {
sortedInfo: savedSortedInfo,
danmakuMessages: {},
expandedActiveTabs: {},
douyuNeedRescan: false,
}
}
@@ -1012,12 +1026,20 @@ class LiveList extends React.Component<Props, IState> {
requestCookieData() {
api.getCookieList()
.then(function (rsp: any) {
return rsp
}).then((data: CookieItemData[]) => {
this.setState({
cookieList: data
});
.then((data: any) => {
this.setState({ cookieList: Array.isArray(data) ? data : [] });
})
.catch(err => {
// 与 requestListData 不同,这里原先没有 catch:网络异常时是未处理的 Promise rejection
console.error(`加载 Cookie 列表失败:\n${err}`);
})
// 顺带拉取斗鱼自动续期状态,续期失败时在列表上给出可见提醒
api.getDouyuAuthStatus()
.then((res: any) => {
this.setState({ douyuNeedRescan: !!(res && res.err_no === 0 && res.data && res.data.need_rescan) });
})
.catch(() => {
// 旧后端无此接口时忽略,不影响 cookie 列表本身
})
}
+7
View File
@@ -375,6 +375,13 @@ class API {
return utils.requestGet(`${BASE_URL}/douyu/qrcode/poll?code=${encodeURIComponent(code)}`);
}
/**
* 获取斗鱼登录 Cookie 的自动续期健康状态(不含任何凭证原文)
*/
getDouyuAuthStatus() {
return utils.requestGet(`${BASE_URL}/douyu/auth/status`);
}
/**
* 获取 SoopLive 已保存的账号密码配置
*/