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:
1 parent
43f3dfadb1
commit
78c6336c93
24 files changed
+3728
-160
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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)。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+59
-14
@@ -48,9 +48,12 @@ type QRLoginState string
|
||||
const (
|
||||
QRStateWaiting QRLoginState = "waiting" // 客户端还未扫码
|
||||
QRStateScanned QRLoginState = "scanned" // 已扫码,等待手机端确认
|
||||
QRStateSuccess QRLoginState = "success" // 确认成功,已换取登录 cookie
|
||||
QRStateSuccess QRLoginState = "success" // 确认成功,已换取并保存登录 cookie
|
||||
QRStateExpired QRLoginState = "expired" // 二维码失效/被取消
|
||||
QRStateRejected QRLoginState = "rejected" // 手机端拒绝授权
|
||||
// QRStateSaveFailed 表示"手机端已确认登录,但后端保存配置失败"(磁盘满/配置只读等)。
|
||||
// 必须与"轮询本身失败"区分:前者反复重扫也没用,要先解决配置可写。
|
||||
QRStateSaveFailed QRLoginState = "save_failed"
|
||||
)
|
||||
|
||||
// QRLoginSession 一次扫码登录会话
|
||||
@@ -60,12 +63,16 @@ type QRLoginSession struct {
|
||||
Expire int `json:"expire"` // 有效期(秒)
|
||||
}
|
||||
|
||||
// QRPollResult 轮询结果;成功时附带登录 cookie 与用户信息
|
||||
// QRPollResult 轮询结果;成功时附带登录 cookie 与用户信息。
|
||||
// Cookie/LTP0/DyDid 一律 json:"-":它们只用于后端内部传递(写配置、续期),
|
||||
// 绝不能出现在任何 JSON 响应里——pollDouyuQRCode 在 writeJSON 前会显式置空作为第二道保险。
|
||||
type QRPollResult struct {
|
||||
State QRLoginState `json:"state"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
UserID string `json:"uid,omitempty"`
|
||||
Cookie string `json:"cookie,omitempty"` // 仅成功时有值
|
||||
LTP0 string `json:"-"` // passport 域长期凭证,用于后续 safeAuth 自动续期
|
||||
DyDid string `json:"-"` // 可选设备 id,随 LTP0 一起持久化
|
||||
Cookie string `json:"-"` // 仅成功时有值,由后端直接落盘
|
||||
Msg string `json:"msg,omitempty"`
|
||||
}
|
||||
|
||||
@@ -113,7 +120,7 @@ func PollLoginQRCode(ctx context.Context, code string) (*QRPollResult, error) {
|
||||
req.Header.Set("User-Agent", douyuWebUAString)
|
||||
req.Header.Set("Referer", qrLoginReferer)
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
body, err := doDouyuRequest(req)
|
||||
body, pollCookies, err := doDouyuRequestWithCookies(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -145,11 +152,22 @@ func PollLoginQRCode(ctx context.Context, code string) (*QRPollResult, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// LTP0 由轮询 error=0 响应的 Set-Cookie 下发(passport 域),是后续续期的关键长期凭证
|
||||
ltp0 := cookieFromList(pollCookies, "LTP0")
|
||||
if ltp0 == "" {
|
||||
ltp0 = cookieValue(cookie, "LTP0")
|
||||
}
|
||||
dyDid := cookieFromList(pollCookies, "dy_did")
|
||||
if dyDid == "" {
|
||||
dyDid = cookieValue(cookie, "dy_did")
|
||||
}
|
||||
return &QRPollResult{
|
||||
State: QRStateSuccess,
|
||||
Nickname: nickname,
|
||||
UserID: userID,
|
||||
Cookie: cookie,
|
||||
LTP0: ltp0,
|
||||
DyDid: dyDid,
|
||||
}, nil
|
||||
case 1:
|
||||
return &QRPollResult{State: QRStateScanned, Msg: msg}, nil
|
||||
@@ -164,12 +182,33 @@ func PollLoginQRCode(ctx context.Context, code string) (*QRPollResult, error) {
|
||||
|
||||
// doDouyuRequest 执行一次斗鱼上游请求并返回响应体(限制 1MB)
|
||||
func doDouyuRequest(req *http.Request) ([]byte, error) {
|
||||
body, _, err := doDouyuRequestWithCookies(req)
|
||||
return body, err
|
||||
}
|
||||
|
||||
// doDouyuRequestWithCookies 与 doDouyuRequest 相同,但额外返回响应的 Set-Cookie,
|
||||
// 供扫码成功时提取 passport 域 LTP0 等长期凭证。
|
||||
func doDouyuRequestWithCookies(req *http.Request) ([]byte, []*http.Cookie, error) {
|
||||
resp, err := douyuHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return body, resp.Cookies(), nil
|
||||
}
|
||||
|
||||
// cookieFromList 从 http.Cookie 列表中取指定字段的值
|
||||
func cookieFromList(cookies []*http.Cookie, name string) string {
|
||||
for _, c := range cookies {
|
||||
if c.Name == name {
|
||||
return c.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// exchangeLoginCookie 请求登录成功回调 URL(JSONP),从 Set-Cookie 收集登录态。
|
||||
@@ -180,8 +219,8 @@ func exchangeLoginCookie(ctx context.Context, rawUrl string) (cookie string, jso
|
||||
if err = checkDouyuURL(rawUrl); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
seen := make(map[string]bool) // 已收集的 k=v,兼做去重
|
||||
var hops []string // 跳转链诊断信息(仅记录 cookie 名,不记录值)
|
||||
seen := make(map[string]string) // 字段名 -> 值,后一跳覆盖前一跳(终点才是权威登录态)
|
||||
var hops []string // 跳转链诊断信息(仅记录 cookie 名,不记录值)
|
||||
cur := rawUrl
|
||||
var finalBody string
|
||||
for hop := 0; hop < 8; hop++ {
|
||||
@@ -197,10 +236,19 @@ func exchangeLoginCookie(ctx context.Context, rawUrl string) (cookie string, jso
|
||||
}
|
||||
names := make([]string, 0, len(resp.Cookies()))
|
||||
for _, c := range resp.Cookies() {
|
||||
seen[c.Name+"="+c.Value] = true
|
||||
names = append(names, c.Name)
|
||||
// 空值 Set-Cookie 是"清除该 cookie"的写法,不值得写入登录态;缺失会让下游字段保持更早的有效值
|
||||
if c.Value == "" {
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = c.Value
|
||||
}
|
||||
hops = append(hops, fmt.Sprintf("%s -> %d (Set-Cookie: %v)", cur, resp.StatusCode, names))
|
||||
// 一次性登录 token 就在 query 里,这条诊断串会随错误冒泡到前端与日志,只保留 scheme://host/path
|
||||
hopURL := cur
|
||||
if u, e := url.Parse(cur); e == nil {
|
||||
hopURL = u.Scheme + "://" + u.Host + u.Path
|
||||
}
|
||||
hops = append(hops, fmt.Sprintf("%s -> %d (Set-Cookie: %v)", hopURL, resp.StatusCode, names))
|
||||
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||
loc, _ := resp.Location()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
@@ -223,10 +271,7 @@ func exchangeLoginCookie(ctx context.Context, rawUrl string) (cookie string, jso
|
||||
finalBody = string(body)
|
||||
break
|
||||
}
|
||||
parts := make([]string, 0, len(seen))
|
||||
for kv := range seen {
|
||||
parts = append(parts, kv)
|
||||
}
|
||||
parts := sortedCookiePairs(seen)
|
||||
trace = strings.Join(hops, "; ") + "; 终点响应体: " + truncateForLog([]byte(finalBody))
|
||||
return strings.Join(parts, "; "), stripJSONP(finalBody), trace, nil
|
||||
}
|
||||
|
||||
+91
-7
@@ -2,17 +2,20 @@ package douyu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/hr3lxphr6j/requests"
|
||||
|
||||
"github.com/bililive-go/bililive-go/src/configs"
|
||||
"github.com/bililive-go/bililive-go/src/live"
|
||||
"github.com/bililive-go/bililive-go/src/live/internal"
|
||||
"github.com/bililive-go/bililive-go/src/pkg/utils"
|
||||
@@ -147,8 +150,10 @@ func (l *Live) getEngineWithCryptoJS() (*otto.Otto, error) {
|
||||
type Live struct {
|
||||
internal.BaseLive
|
||||
roomID string
|
||||
// did 在 Live 生命周期内固定,避免每次签名都换新设备号触发风控
|
||||
did string
|
||||
// did 在 Live 生命周期内固定,避免每次签名都换新设备号触发风控;
|
||||
// 读取自签名路径、重置自配置热更新,故需加锁。
|
||||
didMu sync.Mutex
|
||||
did string
|
||||
}
|
||||
|
||||
// GetRoomID 返回已解析的数字房间号(由 fetchRoomID 解析)。
|
||||
@@ -157,10 +162,21 @@ func (l *Live) GetRoomID() string {
|
||||
return l.roomID
|
||||
}
|
||||
|
||||
// loginCookieUsable 报告当前这份斗鱼登录 cookie 是否还值得作为"登录态"发出去。
|
||||
// 自动续期一旦已不可能把这份 cookie 救回来(见 LoginCookieKnownExpired),就主动停用,
|
||||
// 让取流请求回到"从未登录过"那条行为已验证的路径上,而不是继续赌服务端会忽略非法登录字段。
|
||||
func loginCookieUsable() bool {
|
||||
cfg := configs.GetCurrentConfig()
|
||||
if cfg == nil {
|
||||
return true
|
||||
}
|
||||
return !LoginCookieKnownExpired(cfg.DouyuAuth.LTP0, cfg.DouyuAuth.LastRenewSuccessAt, time.Now())
|
||||
}
|
||||
|
||||
// getCookieString 从 Options cookie jar 中读取斗鱼域名的 cookie 字符串("k1=v1; k2=v2")。
|
||||
// 未配置 cookie 时返回空串,调用方据此决定是否附加 Cookie 头。
|
||||
// 未配置 cookie、或这份登录态已确凿失效时返回空串,调用方据此决定是否附加 Cookie 头。
|
||||
func (l *Live) getCookieString() string {
|
||||
if l.Options == nil || l.Options.Cookies == nil {
|
||||
if l.Options == nil || l.Options.Cookies == nil || !loginCookieUsable() {
|
||||
return ""
|
||||
}
|
||||
cookies := l.Options.Cookies.Cookies(l.Url)
|
||||
@@ -171,17 +187,72 @@ func (l *Live) getCookieString() string {
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// deviceIDCookieKeys 是斗鱼侧设备标识字段,按优先级排列(cookiejar 返回顺序不稳定,不能"取先命中的")。
|
||||
// 这张表同时供续期写回侧判定设备号该丢还是该补种(见 ApplyDeviceIDPolicy):
|
||||
// "取来签名"与"续期时取舍"必须共用同一处定义,否则加一个字段就要漏改一处。
|
||||
var deviceIDCookieKeys = []string{"dy_did", "acf_did", "acf_devid"}
|
||||
|
||||
// isDeviceIDCookieKey 报告字段名是否为设备标识字段。
|
||||
func isDeviceIDCookieKey(name string) bool {
|
||||
for _, k := range deviceIDCookieKeys {
|
||||
if k == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasStoredDeviceID 报告这份 cookie 串里是否已有非空的设备标识字段。
|
||||
func HasStoredDeviceID(cookie string) bool {
|
||||
fields := configs.ParseCookieFields(cookie)
|
||||
for _, k := range deviceIDCookieKeys {
|
||||
if fields[k] != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ApplyDeviceIDPolicy 决定一次续期换到的字段里,设备号该丢掉还是该落盘(唯一决策点,返回副本)。
|
||||
// - 现有登录 cookie 里已经有设备号:挡掉换票链这次签发的新值。设备号在扫码那次就定下并长期沿用,
|
||||
// 跟着续期轮换等于每 3 天换一台"新设备",是签名侧最要避免的风控诱因。
|
||||
// - 现有 cookie 里一个设备号都没有(手改配置删掉了整行、或那次扫码未下发):把新值补种进去。
|
||||
// 否则签名侧只能自行生成随机 did,而续期热应用会作废该缓存,实测表现为每次续期换一个设备号——
|
||||
// 带着登录 cookie 却每次换设备,比"设备号来自续期"更可疑。补种一次之后即落入上一条分支,自动稳定。
|
||||
func ApplyDeviceIDPolicy(storedCookie string, fields map[string]string) map[string]string {
|
||||
hasStored := HasStoredDeviceID(storedCookie)
|
||||
out := make(map[string]string, len(fields))
|
||||
for k, v := range fields {
|
||||
if hasStored && isDeviceIDCookieKey(k) {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getDID 返回固定的设备号:优先复用用户 cookie 中的斗鱼设备号
|
||||
// (浏览器实际使用 dy_did,登录态下 acf_did 与其一致),使签名 did
|
||||
// (扫码链是纯 HTTP、不执行页面 JS,拿不到浏览器用的 dy_did,实际下发的设备号是 acf_devid;
|
||||
// 浏览器自己登录时则通常有 dy_did),使签名 did
|
||||
// 与登录 cookie 保持一致、更接近浏览器行为;否则生成一次并在该 Live 生命周期内复用。
|
||||
// 注意:设备号与"登录态是否还可用"无关(斗鱼侧设备号本身不过期),所以即使登录 cookie 已被
|
||||
// 停用(getCookieString 返回空),这里仍延用它——换成一个随机 did 反而更像一台新设备。
|
||||
func (l *Live) getDID() string {
|
||||
l.didMu.Lock()
|
||||
defer l.didMu.Unlock()
|
||||
if l.did != "" {
|
||||
return l.did
|
||||
}
|
||||
if l.Options != nil && l.Options.Cookies != nil {
|
||||
found := make(map[string]string, len(deviceIDCookieKeys))
|
||||
for _, c := range l.Options.Cookies.Cookies(l.Url) {
|
||||
if (c.Name == "dy_did" || c.Name == "acf_did") && c.Value != "" {
|
||||
l.did = c.Value
|
||||
if c.Value != "" {
|
||||
found[c.Name] = c.Value
|
||||
}
|
||||
}
|
||||
for _, name := range deviceIDCookieKeys {
|
||||
if v := found[name]; v != "" {
|
||||
l.did = v
|
||||
return l.did
|
||||
}
|
||||
}
|
||||
@@ -190,6 +261,19 @@ func (l *Live) getDID() string {
|
||||
return l.did
|
||||
}
|
||||
|
||||
// UpdateLiveOptionsbyConfig 热更新房间选项(重新扫码、自动续期换了 cookie)后作废已缓存的设备号。
|
||||
// did 优先复用登录 cookie 里的斗鱼设备号,若一直沿用首次的值,换号之后签名里的 did
|
||||
// 仍是上一份登录态的设备号,与新 cookie 不匹配(表现为偶发签名被拒、退回匿名录制)。
|
||||
func (l *Live) UpdateLiveOptionsbyConfig(ctx context.Context, room *configs.LiveRoom) error {
|
||||
if err := l.BaseLive.UpdateLiveOptionsbyConfig(ctx, room); err != nil {
|
||||
return err
|
||||
}
|
||||
l.didMu.Lock()
|
||||
defer l.didMu.Unlock()
|
||||
l.did = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// cookieRequestOption 返回带 Cookie 头的请求选项;无 cookie 时返回 nil,调用方需过滤。
|
||||
func (l *Live) cookieRequestOption() requests.RequestOption {
|
||||
if ck := l.getCookieString(); ck != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`, "大整数必须逐字节保留")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+294
-61
@@ -998,7 +998,24 @@ func putConfig(writer http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func getRawConfig(writer http.ResponseWriter, r *http.Request) {
|
||||
b, err := yaml.Marshal(configs.GetCurrentConfig())
|
||||
// 掩码斗鱼长期凭证后再输出:/api/config 已经用 json:"-" 把 LTP0 挡在 JSON 之外,
|
||||
// 若这里原样吐出 YAML,"设置明文"页就等于把数月有效的票据连同一堆 cookie 一起交给浏览器
|
||||
// (默认 rpc.bind 是 :8080 且接口无鉴权)。用户显式重新扫码才会更换该票据。
|
||||
cfg := configs.CloneConfigShallow(configs.GetCurrentConfig())
|
||||
if cfg == nil {
|
||||
writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{
|
||||
ErrNo: http.StatusInternalServerError,
|
||||
ErrMsg: "配置尚未加载",
|
||||
})
|
||||
return
|
||||
}
|
||||
if cfg.DouyuAuth.LTP0 != "" {
|
||||
cfg.DouyuAuth.LTP0 = douyuSecretMask
|
||||
}
|
||||
if cfg.DouyuAuth.DyDid != "" {
|
||||
cfg.DouyuAuth.DyDid = douyuSecretMask
|
||||
}
|
||||
b, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{
|
||||
ErrNo: http.StatusBadRequest,
|
||||
@@ -1011,6 +1028,16 @@ func getRawConfig(writer http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// rawConfigTopKeys 取"设置明文"YAML 的顶层键集合,仅用于区分"这一节没提交"与"提交了但为空"。
|
||||
// 解析失败返回 nil,此时按"未提交"处理(宁可沿用原凭证,也不静默清掉不可再生的长期票据)。
|
||||
func rawConfigTopKeys(yamlText string) map[string]any {
|
||||
var top map[string]any
|
||||
if err := yaml.Unmarshal([]byte(yamlText), &top); err != nil {
|
||||
return nil
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
func putRawConfig(writer http.ResponseWriter, r *http.Request) {
|
||||
inst := instance.GetInstance(r.Context())
|
||||
b, err := io.ReadAll(r.Body)
|
||||
@@ -1023,8 +1050,22 @@ func putRawConfig(writer http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ctx := inst.Ctx
|
||||
var jsonBody map[string]any
|
||||
json.Unmarshal(b, &jsonBody)
|
||||
newConfig, err := configs.NewConfigWithBytes([]byte(jsonBody["config"].(string)))
|
||||
if err := json.Unmarshal(b, &jsonBody); err != nil {
|
||||
writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{
|
||||
ErrNo: http.StatusBadRequest,
|
||||
ErrMsg: "请求体不是合法 JSON: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
rawYaml, ok := jsonBody["config"].(string)
|
||||
if !ok {
|
||||
writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{
|
||||
ErrNo: http.StatusBadRequest,
|
||||
ErrMsg: `缺少字符串字段 "config"`,
|
||||
})
|
||||
return
|
||||
}
|
||||
newConfig, err := configs.NewConfigWithBytes([]byte(rawYaml))
|
||||
if err != nil {
|
||||
writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{
|
||||
ErrNo: http.StatusInternalServerError,
|
||||
@@ -1032,8 +1073,37 @@ func putRawConfig(writer http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
oldConfig := configs.GetCurrentConfig()
|
||||
curConfig := configs.GetCurrentConfig()
|
||||
if curConfig == nil {
|
||||
writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{
|
||||
ErrNo: http.StatusInternalServerError,
|
||||
ErrMsg: "配置尚未加载",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 在副本上刷新索引:直接用全局配置指针的话,RefreshLiveRoomIndexCache 会就地改写一份
|
||||
// 正被所有并发请求读取的配置(data race)。
|
||||
oldConfig := configs.CloneConfigShallow(curConfig)
|
||||
oldConfig.RefreshLiveRoomIndexCache()
|
||||
// 明文页提交的是"整份配置",但"文档里根本没写这一节"与"写了但留空"含义不同:前者极可能是旧前端
|
||||
// 缓存或局部提交的客户端(它不认识斗鱼登录字段),后者才是用户显式清空。不区分的话,一次普通保存
|
||||
// 就把不可再生的长期凭证连同续期日程静默抹掉,表现为自动续期永久失效且没有任何提示(只能重新扫码)。
|
||||
// 实测:把 GET /raw-config 的响应体原样提交回去(YAML 里两节都缺)即可复现。
|
||||
topKeys := rawConfigTopKeys(rawYaml)
|
||||
_, authSectionEdited := topKeys["douyu_auth"]
|
||||
_, cookiesSectionEdited := topKeys["cookies"]
|
||||
if !authSectionEdited {
|
||||
newConfig.DouyuAuth = oldConfig.DouyuAuth
|
||||
}
|
||||
// "设置明文"页展示的是掩码,保存回来的仍是掩码:此时沿用原票据,
|
||||
// 否则"打开明文页点一次保存"就会抹掉长期凭证,表现为自动续期悄悄失效。
|
||||
// 需要真正清除时,保留 douyu_auth 这一节并把 ltp0 留空即可(整节删掉视为"未修改")。
|
||||
if newConfig.DouyuAuth.LTP0 == douyuSecretMask {
|
||||
newConfig.DouyuAuth.LTP0 = oldConfig.DouyuAuth.LTP0
|
||||
}
|
||||
if newConfig.DouyuAuth.DyDid == douyuSecretMask {
|
||||
newConfig.DouyuAuth.DyDid = oldConfig.DouyuAuth.DyDid
|
||||
}
|
||||
// 继承原配置的文件路径
|
||||
newConfig.File = oldConfig.File
|
||||
// 预先将旧配置中的 LiveId 迁移到新配置(相同 URL)
|
||||
@@ -1046,17 +1116,48 @@ func putRawConfig(writer http.ResponseWriter, r *http.Request) {
|
||||
newConfig.LiveRooms[i].LiveId = rOld.LiveId
|
||||
}
|
||||
}
|
||||
// 先设置为当前全局配置,再驱动运行态差异变更
|
||||
configs.SetCurrentConfig(newConfig)
|
||||
if err := applyLiveRoomsByConfig(ctx, oldConfig, newConfig); err != nil {
|
||||
// 手工编辑"设置明文"里的斗鱼 cookie,只有换了登录账号才算换掉了一份登录态:按 acf_uid 判断。
|
||||
// 不能整串比较后一律清票据——补一个字段、删一个过期字段这类同账号微调非常常见,
|
||||
// 顺手清掉长期凭证的结果是自动续期静默失效,用户只看到"过几天又断流"却毫无线索。
|
||||
// 反之若真的换了账号却保留盘上的 LTP0,后台下次排期会用原账号换票并把它的 acf_* 合并回来,
|
||||
// 表现为"我手改的 cookie 过几天自己变了回去"(静默混号)。宁可放弃自动续期,也不能悄悄换人。
|
||||
if oldUid, newUid := douyuCookieUid(oldConfig.Cookies[dy.CookieHost]), douyuCookieUid(newConfig.Cookies[dy.CookieHost]); oldUid != newUid {
|
||||
// cookies 整节缺失时 newUid 必然为空,那不是"换了账号"而是"没提交这一节",不能据此作废票据。
|
||||
if cookiesSectionEdited {
|
||||
if newConfig.DouyuAuth.LTP0 != "" {
|
||||
applog.GetLogger().Warnf("检测到斗鱼登录 Cookie 换了账号(acf_uid %s -> %s),已同时清除斗鱼长期凭证(需重新扫码才能启用自动续期)", oldUid, newUid)
|
||||
}
|
||||
newConfig.DouyuAuth = configs.DouyuAuth{}
|
||||
}
|
||||
}
|
||||
// 票据因"未提交该节"而被沿用、登录 cookie 却已不在(cookies 整节被局部提交带走)时,
|
||||
// 把排期改为"立即":否则要空转到原排期(最长 3 天)才换回登录态,中间一直是匿名录制。
|
||||
if newConfig.DouyuAuth.LTP0 != "" && douyuCookieUid(newConfig.Cookies[dy.CookieHost]) == "" {
|
||||
newConfig.DouyuAuth.NextRefreshAt = 0
|
||||
}
|
||||
// 整体替换必须走带锁的提交路径:SetCurrentConfig 是裸写全局指针,绕开 updateMu 会把与本次
|
||||
// 编辑同期完成的其它更新(保存 cookie、斗鱼自动续期)从内存里覆盖掉;锁外再 Marshal 更是
|
||||
// 直接用这份基于旧快照的配置盖掉别人刚落盘的内容。UpdateWithRetry 在锁内完成替换+持久化,
|
||||
// 并在版本冲突时重试,读到新配置的其它写者不会丢失更新。
|
||||
committed, err := configs.UpdateWithRetry(func(c *configs.Config) error {
|
||||
*c = *newConfig
|
||||
return nil
|
||||
}, 3, 10*time.Millisecond)
|
||||
if err != nil {
|
||||
writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{
|
||||
ErrNo: http.StatusInternalServerError,
|
||||
ErrMsg: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// 先落配置再驱动运行态差异:新增房间在初始化时会按全局配置取登录 cookie,
|
||||
// 若此时配置还是旧的,新加的房间就以匿名态建连。
|
||||
if err := applyLiveRoomsByConfig(ctx, oldConfig, committed); err != nil {
|
||||
writeJSON(writer, map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := newConfig.Marshal(); err != nil {
|
||||
applog.GetLogger().Error("failed to save config: " + err.Error())
|
||||
}
|
||||
writeJSON(writer, commonResp{
|
||||
Data: "OK",
|
||||
})
|
||||
@@ -2894,6 +2995,11 @@ func getLiveHostCookie(writer http.ResponseWriter, r *http.Request) {
|
||||
inst := instance.GetInstance(r.Context())
|
||||
hostCookieMap := make(map[string]*live.InfoCookie)
|
||||
keys := make([]string, 0)
|
||||
// 配置在启动早期可能尚未就绪,取一次快照统一使用,避免逐房间重复解引用
|
||||
allCookies := map[string]string{}
|
||||
if cfg := configs.GetCurrentConfig(); cfg != nil {
|
||||
allCookies = cfg.Cookies
|
||||
}
|
||||
inst.Lives.Range(func(_ types.LiveID, v live.Live) bool {
|
||||
urltmp, _ := url.Parse(v.GetRawUrl())
|
||||
if _, ok := hostCookieMap[urltmp.Host]; ok {
|
||||
@@ -2901,7 +3007,7 @@ func getLiveHostCookie(writer http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
host := urltmp.Host
|
||||
platformName := v.GetPlatformCNName()
|
||||
if cookie, ok := configs.GetCurrentConfig().Cookies[host]; ok {
|
||||
if cookie, ok := allCookies[host]; ok {
|
||||
tmp := &live.InfoCookie{Platform_cn_name: platformName, Host: host, Cookie: cookie}
|
||||
hostCookieMap[host] = tmp
|
||||
} else {
|
||||
@@ -2911,6 +3017,18 @@ func getLiveHostCookie(writer http.ResponseWriter, r *http.Request) {
|
||||
keys = append(keys, host)
|
||||
return true
|
||||
})
|
||||
// 斗鱼扫码入口兜底:列表行只由"运行中的房间 host"推导,用户删掉全部斗鱼房间后
|
||||
// 既看不到自动续期失败提醒、也无法重新扫码。只要后端仍持有斗鱼凭证就固定补一行。
|
||||
if cfg := configs.GetCurrentConfig(); cfg != nil {
|
||||
if _, ok := hostCookieMap[dy.CookieHost]; !ok && (cfg.DouyuAuth.LTP0 != "" || cfg.Cookies[dy.CookieHost] != "") {
|
||||
hostCookieMap[dy.CookieHost] = &live.InfoCookie{
|
||||
Platform_cn_name: "斗鱼",
|
||||
Host: dy.CookieHost,
|
||||
Cookie: cfg.Cookies[dy.CookieHost],
|
||||
}
|
||||
keys = append(keys, dy.CookieHost)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
result := make([]*live.InfoCookie, 0)
|
||||
for _, v := range keys {
|
||||
@@ -2919,6 +3037,10 @@ func getLiveHostCookie(writer http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(writer, result)
|
||||
}
|
||||
|
||||
// applyCookiesToLives 把 newCfg 中属于 hosts 的房间重新套用 cookie。
|
||||
// 注意:newCfg 只用于"枚举哪些房间要刷新",实际生效的 cookie 由
|
||||
// UpdateLiveOptionsbyConfig 从全局当前配置现读;因此调用方必须在
|
||||
// 配置更新(CAS 提交)成功之后调用,否则刷到的可能是并发写入的更新值。
|
||||
func applyCookiesToLives(ctx context.Context, newCfg *configs.Config, hosts ...string) {
|
||||
inst := instance.GetInstance(ctx)
|
||||
hostSet := make(map[string]struct{}, len(hosts))
|
||||
@@ -2965,7 +3087,9 @@ func putLiveHostCookie(writer http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
data := gjson.ParseBytes(b)
|
||||
|
||||
host := data.Get("Host").Str
|
||||
// host 先归一为标准域名:房间 URL 与 Cookies 键都用标准 host,别名提交若不归一,
|
||||
// 斗鱼分支(清长期凭证)和热应用(按 host 匹配房间)都会静默不命中。
|
||||
host := configs.NormalizeCookieHost(data.Get("Host").Str)
|
||||
cookie := data.Get("Cookie").Str
|
||||
if cookie == "" {
|
||||
|
||||
@@ -2980,7 +3104,40 @@ func putLiveHostCookie(writer http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
// 使用统一 Update 接口更新 Cookies
|
||||
newCfg, err := configs.SetCookie(host, cookie)
|
||||
// 斗鱼特殊处理:手动改写/清空 cookie 视为用户显式接管登录态,必须同时清除长期凭证 LTP0 与续期日程,
|
||||
// 否则后台 keeper 会在下次续期时用旧 LTP0 换票,把用户刚写入(或删除)的 cookie 覆盖回去(幽灵恢复/静默换账号)。
|
||||
var newCfg *configs.Config
|
||||
if host == dy.CookieHost {
|
||||
newCfg, err = configs.UpdateWithRetry(func(c *configs.Config) error {
|
||||
// 浏览器导出的 www.douyu.com cookie 常连带 passport 域的 LTP0(domain=.douyu.com)。
|
||||
// 长期凭证只应留在 DouyuAuth.LTP0 一处:混进 Cookies 后 /api/cookies 会把它明文回传浏览器,
|
||||
// 而且请求主站本来也不需要这张票。
|
||||
stored := configs.DropCookieFields(strings.TrimSpace(cookie), "LTP0")
|
||||
oldUid := douyuCookieUid(c.Cookies[host])
|
||||
if stored == "" {
|
||||
delete(c.Cookies, host)
|
||||
} else {
|
||||
if c.Cookies == nil {
|
||||
c.Cookies = make(map[string]string)
|
||||
}
|
||||
c.Cookies[host] = stored
|
||||
}
|
||||
// 只在"换账号"或"显式存一份未登录 cookie"时作废票据,而不是只要动过 cookie 就清:
|
||||
// 同账号补/删非登录字段很常见,顺手清掉票据会让自动续期从此静默失效,
|
||||
// 用户只看到"过几天又断流"。反之新的 cookie 里没有 acf_uid,说明用户在主动退出登录,
|
||||
// 留着票据就会被后台换票恢复成登录态。
|
||||
newUid := douyuCookieUid(stored)
|
||||
if newUid != oldUid || newUid == "" {
|
||||
if c.DouyuAuth.LTP0 != "" {
|
||||
applog.GetLogger().Warnf("斗鱼登录 Cookie 被手工替换为另一份登录态(acf_uid %s -> %s),已清除长期凭证(需重新扫码才能启用自动续期)", oldUid, newUid)
|
||||
}
|
||||
c.DouyuAuth = configs.DouyuAuth{}
|
||||
}
|
||||
return nil
|
||||
}, 3, 10*time.Millisecond)
|
||||
} else {
|
||||
newCfg, err = configs.SetCookie(host, cookie)
|
||||
}
|
||||
if err != nil {
|
||||
writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{
|
||||
ErrNo: http.StatusInternalServerError,
|
||||
@@ -2989,9 +3146,8 @@ func putLiveHostCookie(writer http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
applyCookiesToLives(ctx, newCfg, host)
|
||||
if err := newCfg.Marshal(); err != nil {
|
||||
applog.GetLogger().Error("failed to persistence config: " + err.Error())
|
||||
}
|
||||
// 不再额外 Marshal:上面的 UpdateWithRetry/SetCookie 已在 updateMu 锁内落过盘。
|
||||
// 在锁外重写同一个快照,会把期间后台续期刚写好的 DouyuAuth/新 cookie 盖回旧值(盘上比内存旧,重启才显现)。
|
||||
writeJSON(writer, commonResp{
|
||||
Data: "OK",
|
||||
})
|
||||
@@ -3608,48 +3764,81 @@ func pollBilibiliQRCode(writer http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试解析响应,如果是成功状态,尝试从 Cookie 中提取 sid 并附加到 URL
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Code int `json:"code"`
|
||||
Url string `json:"url"`
|
||||
} `json:"data"`
|
||||
// 登录成功时 B 站把凭证放在响应头 Set-Cookie 里下发,body 的 data.url 只有一个跳转地址,
|
||||
// 前端光解析 data.url 拼不出 SESSDATA,所以这里把登录字段补进 data.cookies 再回传。
|
||||
body = attachBilibiliLoginCookies(body, resp.Cookies())
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Write(body)
|
||||
}
|
||||
|
||||
// biliLoginCookieNames 是录制与弹幕真正需要的 B 站 Cookie 字段。
|
||||
// buvid、_uuid 这类埋点字段不回传,避免被写进配置文件。
|
||||
var biliLoginCookieNames = map[string]bool{
|
||||
"SESSDATA": true,
|
||||
"bili_jct": true,
|
||||
"DedeUserID": true,
|
||||
"DedeUserID__ckMd5": true,
|
||||
"sid": true,
|
||||
}
|
||||
|
||||
// attachBilibiliLoginCookies 在轮询返回登录成功时,把登录字段汇总到 data.cookies,
|
||||
// 其余上游字段原样保留;非成功状态或解析失败都返回原始 body。
|
||||
func attachBilibiliLoginCookies(body []byte, cookies []*http.Cookie) []byte {
|
||||
var result map[string]interface{}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
// 必须用 UseNumber:默认会把所有数字解析成 float64,重新 Marshal 时
|
||||
// 超过 2^53 的整数(B 站以后可能回 mid/room_id 这类大整数)会被静默截断成近似值。
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
applog.GetLogger().Error("轮询登录状态解析 JSON 失败: " + err.Error())
|
||||
return body
|
||||
}
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
if !ok || !isJSONZero(result["code"]) || !isJSONZero(data["code"]) {
|
||||
return body
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
applog.GetLogger().Error("轮询登录状态解析 JSON 失败: " + err.Error())
|
||||
} else if result.Code == 0 && result.Data.Code == 0 && result.Data.Url != "" {
|
||||
// 登录成功,检查响应头中的 Cookie
|
||||
u, err := url.Parse(result.Data.Url)
|
||||
if err != nil {
|
||||
applog.GetLogger().Error("解析登录回调 URL 失败: " + err.Error() + ", URL: " + result.Data.Url)
|
||||
} else {
|
||||
q := u.Query()
|
||||
foundExtra := false
|
||||
if resp.Response != nil {
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == "sid" && q.Get("sid") == "" {
|
||||
q.Set("sid", cookie.Value)
|
||||
foundExtra = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if foundExtra {
|
||||
u.RawQuery = q.Encode()
|
||||
result.Data.Url = u.String()
|
||||
newBody, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
applog.GetLogger().Error("序列化增强后的登录结果失败: " + err.Error())
|
||||
} else {
|
||||
body = newBody
|
||||
login := make(map[string]string)
|
||||
// 上游也可能把字段挂在 data.url 的 query 上(原实现只从 URL 里取 sid),这里一并收进来,
|
||||
// 同名时 Set-Cookie 的值优先。
|
||||
if rawURL, _ := data["url"].(string); rawURL != "" {
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
for name, values := range u.Query() {
|
||||
if biliLoginCookieNames[name] && len(values) > 0 {
|
||||
login[name] = values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range cookies {
|
||||
if biliLoginCookieNames[c.Name] && c.Value != "" {
|
||||
login[c.Name] = c.Value
|
||||
}
|
||||
}
|
||||
if len(login) == 0 {
|
||||
return body
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Write(body)
|
||||
data["cookies"] = login
|
||||
newBody, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
applog.GetLogger().Error("序列化增强后的登录结果失败: " + err.Error())
|
||||
return body
|
||||
}
|
||||
return newBody
|
||||
}
|
||||
|
||||
// isJSONZero 判断 json 数值字段是否为 0(配合 UseNumber,数值是 json.Number)。
|
||||
func isJSONZero(v interface{}) bool {
|
||||
switch number := v.(type) {
|
||||
case json.Number:
|
||||
return number.String() == "0"
|
||||
case float64:
|
||||
return number == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// verifyBilibiliCookie 验证哔哩哔哩 Cookie 有效性
|
||||
@@ -3714,28 +3903,72 @@ func pollDouyuQRCode(writer http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if result.State == dy.QRStateSuccess {
|
||||
newCfg, err := configs.UpdateWithRetry(func(c *configs.Config) error {
|
||||
if c.Cookies == nil {
|
||||
c.Cookies = make(map[string]string)
|
||||
}
|
||||
c.Cookies[dy.CookieHost] = result.Cookie
|
||||
applyDouyuScanResult(c, result, time.Now())
|
||||
return nil
|
||||
}, 3, 10*time.Millisecond)
|
||||
if err != nil {
|
||||
writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{
|
||||
ErrNo: http.StatusInternalServerError,
|
||||
ErrMsg: "斗鱼登录成功,但写入配置失败: " + err.Error(),
|
||||
})
|
||||
// 手机端已经确认登录、票据已消耗,回 HTTP 500 会让前端只看到"查询连续失败",
|
||||
// 用户以为是网络问题而反复重扫。用独立状态把"保存失败"这个真相送到界面上。
|
||||
applog.GetLogger().WithError(err).Error("斗鱼扫码登录成功但写入配置失败")
|
||||
result.State = dy.QRStateSaveFailed
|
||||
result.Msg = err.Error()
|
||||
writeJSON(writer, commonResp{Data: result})
|
||||
return
|
||||
}
|
||||
// 扫码已经拿到全新登录态,为"上一轮写盘失败"记的内存退避到此失去意义:
|
||||
// 不退避就会让 keeper 在几小时后拿刚扫到的新鲜 cookie 再打一次 safeAuth。
|
||||
clearRenewScheduleOverride()
|
||||
// 同理复位进程内的提醒去重标记:用户按提示重扫成功后,若还是这张票据又出了同样的问题,
|
||||
// 必须还能再提醒一次,否则"我扫过了却又断了"将彻底无声。
|
||||
noteRenewHealthy()
|
||||
applyCookiesToLives(r.Context(), newCfg, dy.CookieHost)
|
||||
applog.GetLogger().Infof("斗鱼扫码登录成功: uid=%s nickname=%s cookieLength=%d", result.UserID, result.Nickname, len(result.Cookie))
|
||||
applog.GetLogger().Infof("斗鱼扫码登录成功: uid=%s nickname=%s cookieLength=%d gotLTP0=%t", result.UserID, result.Nickname, len(result.Cookie), result.LTP0 != "")
|
||||
}
|
||||
|
||||
// 不回传原始 cookie 内容,前端需要时可经 /api/cookies 查看
|
||||
// 不回传原始 cookie 内容,前端需要时可经 /api/cookies 查看;
|
||||
// LTP0/dy_did 是 passport 域长期凭证,绝不能下发给前端,仅留在后端配置中用于续期。
|
||||
result.Cookie = ""
|
||||
result.LTP0 = ""
|
||||
result.DyDid = ""
|
||||
writeJSON(writer, commonResp{Data: result})
|
||||
}
|
||||
|
||||
// douyuAuthStatus 斗鱼登录态自动续期的前端可见状态。
|
||||
// 只暴露状态与时间点,绝不回传 LTP0/dy_did/cookie 原文。
|
||||
type douyuAuthStatus struct {
|
||||
LoggedIn bool `json:"logged_in"` // 登录态是否可用(有 acf_uid,且续期链路没有长期停摆)
|
||||
HasLTP0 bool `json:"has_ltp0"` // 是否已绑定扫码引导的长期凭证
|
||||
NeedRescan bool `json:"need_rescan"` // 自动续期是否已判定凭证失效、需重新扫码
|
||||
NextRefreshAt int64 `json:"next_refresh_at"` // 下次自动续期的 Unix 秒;0 表示尚未排期
|
||||
}
|
||||
|
||||
// getDouyuAuthStatus 供 Web 界面展示斗鱼 cookie 自动续期健康状况(续期失败提醒等)
|
||||
func getDouyuAuthStatus(writer http.ResponseWriter, r *http.Request) {
|
||||
cfg := configs.GetCurrentConfig()
|
||||
if cfg == nil {
|
||||
writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{
|
||||
ErrNo: http.StatusInternalServerError,
|
||||
ErrMsg: "配置尚未加载",
|
||||
})
|
||||
return
|
||||
}
|
||||
auth := cfg.DouyuAuth
|
||||
loggedIn := douyuCookieUid(cfg.Cookies[dy.CookieHost]) != ""
|
||||
// cookie 里有 acf_uid 只代表"曾经登录过",不代表现在还在有效期内(主站 cookie 约 6 天)。
|
||||
// 判据与录制侧共用 dy.LoginCookieKnownExpired:界面说"未登录"和录制端"不再发这份 cookie"
|
||||
// 必须同口径,两处各留一份阈值迟早会漂移(表现为界面绿着却一直在匿名录制,或反过来
|
||||
// 界面报未登录而实际还能用)。没有 LTP0(手填 cookie、历史配置)时没有任何过期证据,不据此判未登录。
|
||||
if loggedIn && dy.LoginCookieKnownExpired(auth.LTP0, auth.LastRenewSuccessAt, time.Now()) {
|
||||
loggedIn = false
|
||||
}
|
||||
writeJSON(writer, commonResp{Data: douyuAuthStatus{
|
||||
LoggedIn: loggedIn,
|
||||
HasLTP0: auth.LTP0 != "",
|
||||
NeedRescan: auth.NeedRescan,
|
||||
NextRefreshAt: effectiveNextRefreshAt(cfg), // 内存退避也要计入,否则界面显示的续期时间比实际早
|
||||
}})
|
||||
}
|
||||
|
||||
// startRecordDirect 直接启动录制(绕过 Listener,适用于 NotifyOnly 房间)
|
||||
func startRecordDirect(writer http.ResponseWriter, r *http.Request) {
|
||||
inst := instance.GetInstance(r.Context())
|
||||
|
||||
@@ -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('; ') + ';';
|
||||
setTextView(cookieStr);
|
||||
onCookieChangeRef.current(cookieStr);
|
||||
// Auto verify after success
|
||||
verifyCookie(cookieStr);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
notification.error({ message: '解析结果失败' });
|
||||
const processLoginSuccess = useCallback((data: any) => {
|
||||
// 后端已把 B 站 Set-Cookie 下发的登录字段整理进 data.cookies;data.url 只是跳转地址,不含凭证
|
||||
const received = (data && data.cookies) || {};
|
||||
if (!received.SESSDATA) {
|
||||
notification.error({
|
||||
message: '登录响应未返回 Cookie 凭证',
|
||||
description: '请输入框内仍是旧 Cookie,未点保存不会覆盖。可关闭后重试,或按下方教程手动粘贴 Cookie。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cookieStr = BILI_COOKIE_FIELDS
|
||||
.filter(name => received[name])
|
||||
.map(name => `${name}=${received[name]}`)
|
||||
.join('; ') + ';';
|
||||
setTextView(cookieStr);
|
||||
onCookieChangeRef.current(cookieStr);
|
||||
// Auto verify after success
|
||||
verifyCookie(cookieStr);
|
||||
}, [verifyCookie]);
|
||||
|
||||
const startPolling = useCallback((key: string) => {
|
||||
@@ -89,7 +89,7 @@ const BiliLoginPanel: React.FC<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>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
className="qr-image"
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(qrCodeUrl)}`}
|
||||
alt="QR Code"
|
||||
/>
|
||||
{/* 没有二维码 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 ? (
|
||||
|
||||
@@ -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 列表本身
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 已保存的账号密码配置
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user