From fc71b311ed8b8ad274991f4cb06a26af33d2c01f Mon Sep 17 00:00:00 2001 From: Joftal <42807405+Joftal@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:23:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(servers,configs,live/douyu):=20=E6=98=8E?= =?UTF-8?q?=E6=96=87=E9=85=8D=E7=BD=AE=E5=B9=B6=E5=8F=91=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E4=B8=8E=E4=B8=89=E5=A4=84=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - putRawConfig 改为锁内三方合并:继承与作废判定以提交时刻的最新配置为准, 解析整份 YAML 那几十毫秒里后台续期写入的票据、登录 cookie 与 OpenList 令牌 不再被入口快照倒回旧值(内存与磁盘同时倒带,重启也回不来)。 - 房间 URL 规范化后的去重改为逐字段补齐:整条丢弃会把重复条目上单独配的原画、 输出目录、弹幕开关、转 mp4 无声清零,并随本次加载写回磁盘。 - 换票链未真正到达终点时不再报登录成功:3xx 无 Location、8 跳耗尽、终点 4xx/5xx 一律判失败,否则残缺 cookie 落盘后面板显示已登录而实际录不上,下次续期还排到三天后。 - 扫码接口非 2xx 与缺少 error 字段的响应直接判失败,错误串不再夹带上游正文, 避免一次性 token 与风控页原文进入 HTTP 响应和应用日志。 行为变更:明文页整块删除 cookies: 一节视为"未提交",不再清空登录态; 确要清除请写 cookies: {} 或把对应主机留空。 --- src/configs/config.go | 79 +++++++++-- src/live/douyu/auth.go | 59 ++++++-- src/servers/douyu_cookie_api_test.go | 26 +++- src/servers/handler.go | 193 +++++++++++++++++++-------- 4 files changed, 275 insertions(+), 82 deletions(-) diff --git a/src/configs/config.go b/src/configs/config.go index bd4a5db..378a25d 100644 --- a/src/configs/config.go +++ b/src/configs/config.go @@ -1131,19 +1131,17 @@ func newConfigPostProcess(c *Config) { // 规范化后同一房间的不同写法(m.douyu.com/1 与 www.douyu.com/1;live_web_rid query 形态与长链) // 会变成完全相同的 URL。老版本存量条目 + 升级后经 UI 正常添加的条目就会各占一行, // 加载不去重则同一房间建出两个 Live、开播双录。合并策略:保留第一条, - // is_listening 任一为真即为真,notify_only 全部为真才为真(宁可多录,不漏录)。 + // is_listening 任一为真即为真,notify_only 全部为真才为真(宁可多录,不漏录), + // 其余房间级设置按 mergeDuplicatedRoom 补齐——整条丢弃会把用户在重复条目上单独配的 + // 原画/输出目录/转 mp4 无声抹掉,并随本次加载写回磁盘(重启也回不来)。 if len(c.LiveRooms) > 1 { seen := make(map[string]int, len(c.LiveRooms)) deduped := c.LiveRooms[:0] for _, room := range c.LiveRooms { if idx, ok := seen[room.Url]; ok { - if room.IsListening { - deduped[idx].IsListening = true - } - if !room.NotifyOnly { - deduped[idx].NotifyOnly = false - } - fmt.Fprintf(os.Stderr, "[Config] 检测到规范化后重复的房间 %s,已合并到先线条目\n", room.Url) + adopted, conflicted := mergeDuplicatedRoom(&deduped[idx], room) + fmt.Fprintf(os.Stderr, "[Config] 检测到规范化后重复的房间 %s,已合并到先线条目(补齐 %s;冲突保留先条 %s)\n", + room.Url, joinOrNone(adopted), joinOrNone(conflicted)) continue } seen[room.Url] = len(deduped) @@ -1161,6 +1159,71 @@ func newConfigPostProcess(c *Config) { stripCookieFields(c.Cookies, "LTP0") } +// mergeDuplicatedRoom 把去重时被丢弃的条目 dropped 合并进保留条目 keep, +// 返回"从丢弃条目补齐到保留条目的字段"与"两边都设置、只能保留先条的字段"。 +// +// 为什么必须逐字段合而不是整条丢弃:房间 URL 规范化后,同一个房间的历史写法(m.douyu.com/1) +// 和新写法(www.douyu.com/1)会并成一条,而用户很可能把画质、输出目录、转 mp4 这类设置配在 +// 后一条上。整条丢弃会把这些值无声清零,并随本次加载一起写回磁盘,重启也回不来。 +// 口径:先线条目为准,只在它"没设置"(零值 / 指针为 nil)时用后线条目的值补齐。 +func mergeDuplicatedRoom(keep *LiveRoom, dropped LiveRoom) (adopted, conflicted []string) { + if dropped.IsListening { + keep.IsListening = true + } + if !dropped.NotifyOnly { + keep.NotifyOnly = false + } + // 值类型字段:零值即"这条写法没设置" + mergeRoomValue("quality", &keep.Quality, dropped.Quality, &adopted, &conflicted) + mergeRoomValue("audio_only", &keep.AudioOnly, dropped.AudioOnly, &adopted, &conflicted) + mergeRoomValue("nick_name", &keep.NickName, dropped.NickName, &adopted, &conflicted) + mergeRoomValue("scheme", &keep.SchemeUrl, dropped.SchemeUrl, &adopted, &conflicted) + // 房间级覆盖项全部是指针,nil 即未设置;两边都设置时只能保住先条(比指针不比内容) + mergeRoomPtr("interval", &keep.Interval, dropped.Interval, &adopted, &conflicted) + mergeRoomPtr("out_put_path", &keep.OutPutPath, dropped.OutPutPath, &adopted, &conflicted) + mergeRoomPtr("ffmpeg_path", &keep.FfmpegPath, dropped.FfmpegPath, &adopted, &conflicted) + mergeRoomPtr("log", &keep.Log, dropped.Log, &adopted, &conflicted) + mergeRoomPtr("feature", &keep.Feature, dropped.Feature, &adopted, &conflicted) + mergeRoomPtr("out_put_tmpl", &keep.OutputTmpl, dropped.OutputTmpl, &adopted, &conflicted) + mergeRoomPtr("video_split_strategies", &keep.VideoSplitStrategies, dropped.VideoSplitStrategies, &adopted, &conflicted) + mergeRoomPtr("on_record_finished", &keep.OnRecordFinished, dropped.OnRecordFinished, &adopted, &conflicted) + mergeRoomPtr("timeout_in_us", &keep.TimeoutInUs, dropped.TimeoutInUs, &adopted, &conflicted) + mergeRoomPtr("stream_preference", &keep.StreamPreference, dropped.StreamPreference, &adopted, &conflicted) + mergeRoomPtr("danmaku_enable", &keep.DanmakuEnable, dropped.DanmakuEnable, &adopted, &conflicted) + mergeRoomPtr("danmaku", &keep.Danmaku, dropped.Danmaku, &adopted, &conflicted) + return adopted, conflicted +} + +// mergeRoomValue 保留条目该字段为未设置(零值)时采用丢弃条目的值;两边都设置且不同记为冲突。 +func mergeRoomValue[T comparable](name string, dst *T, src T, adopted, conflicted *[]string) { + var zero T + switch { + case *dst == zero && src != zero: + *dst = src + *adopted = append(*adopted, name) + case *dst != src && src != zero: + *conflicted = append(*conflicted, name) + } +} + +// mergeRoomPtr 同 mergeRoomValue,用于房间级覆盖里的指针字段。 +func mergeRoomPtr[T any](name string, dst **T, src *T, adopted, conflicted *[]string) { + switch { + case *dst == nil && src != nil: + *dst = src + *adopted = append(*adopted, name) + case *dst != nil && src != nil && *dst != src: + *conflicted = append(*conflicted, name) + } +} + +func joinOrNone(names []string) string { + if len(names) == 0 { + return "无" + } + return strings.Join(names, ",") +} + // configMinimal 是配置文件的最小子集,仅包含 launcher 决策所需的字段。 // 使用独立的小结构体而非完整 Config,确保即使完整配置格式在版本间发生不兼容变更, // launcher 检查阶段仍能正常工作(Docker 不停机升级的关键保障)。 diff --git a/src/live/douyu/auth.go b/src/live/douyu/auth.go index 12be50a..20898ec 100644 --- a/src/live/douyu/auth.go +++ b/src/live/douyu/auth.go @@ -93,13 +93,19 @@ func GenerateLoginQRCode(ctx context.Context) (*QRLoginSession, error) { if err != nil { return nil, err } - if gjson.GetBytes(body, "error").Int() != 0 { + // 斗鱼业务错误一律 HTTP 200 + error 字段;字段不存在说明这不是一个 API 响应 + // (风控拦截页也会以 200 返回 HTML)。此处不带上正文,避免上游页面进前端错误与日志。 + errField := gjson.GetBytes(body, "error") + if !errField.Exists() { + return nil, fmt.Errorf("斗鱼二维码接口响应不是预期 JSON(疑似风控页或接口已变更),响应长度 %d 字节", len(body)) + } + if errField.Int() != 0 { return nil, fmt.Errorf("斗鱼返回错误: %s", strings.TrimSpace(gjson.GetBytes(body, "data").String())) } code := gjson.GetBytes(body, "data.code").String() qrUrl := gjson.GetBytes(body, "data.url").String() if !qrCodeReg.MatchString(code) || qrUrl == "" { - return nil, fmt.Errorf("斗鱼二维码响应异常, body: %s", truncateForLog(body)) + return nil, fmt.Errorf("斗鱼二维码响应异常(code/url 不完整),响应长度 %d 字节", len(body)) } return &QRLoginSession{ Code: code, @@ -124,13 +130,18 @@ func PollLoginQRCode(ctx context.Context, code string) (*QRPollResult, error) { if err != nil { return nil, err } - errVal := gjson.GetBytes(body, "error").Int() + // 同 GenerateLoginQRCode:error 字段不存在说明拿到的不是 API 响应(风控页会以 200 返回 HTML), + // 若按"缺省即 0"继续走,就会被当成"已确认成功"去换票,最终吐出一句"斗鱼登录成功响应缺少回调 URL"。 + errField := gjson.GetBytes(body, "error") + if !errField.Exists() { + return nil, fmt.Errorf("斗鱼扫码状态轮询响应不是预期 JSON(疑似风控页或接口已变更),响应长度 %d 字节", len(body)) + } msg := gjson.GetBytes(body, "msg").String() - switch errVal { + switch errField.Int() { case 0: exchangeUrl := gjson.GetBytes(body, "data.url").String() if exchangeUrl == "" { - return nil, fmt.Errorf("斗鱼登录成功响应缺少回调 URL, body: %s", truncateForLog(body)) + return nil, fmt.Errorf("斗鱼登录确认成功但响应缺少回调 URL(接口可能已变更),响应长度 %d 字节", len(body)) } cookie, infoBody, trace, err := exchangeLoginCookie(ctx, exchangeUrl) if err != nil { @@ -139,6 +150,13 @@ func PollLoginQRCode(ctx context.Context, code string) (*QRPollResult, error) { if cookie == "" { return nil, fmt.Errorf("斗鱼登录确认成功但未获得 Set-Cookie,接口可能已变更; %s", trace) } + // 探针确认这份 cookie 真的算登录态:斗鱼改版后跳转链也可能"收敛"到一个不下发 acf_* 的页面, + // 只看 cookie 非空就把残缺登录态写盘,还会顺手把下次续期排到 3 天后——面板显示已登录、实际静默不录。 + // 只有探针给出明确的"未登录"业务码才判失败;风控页、网络抖动这类 ambiguous 异常按原样放行, + // 不能因为探针这一次没打通就把用户刚完成的一次真实登录作废。 + if perr := verifyLoginCookie(ctx, cookie); perr != nil && errors.Is(perr, ErrLoginInvalid) { + return nil, fmt.Errorf("斗鱼登录 Cookie 未通过登录态校验,请重新扫码: %w; %s", perr, trace) + } nickname := pickFirstString(infoBody, "nickname", "user_nickname", "uname", "data.nickname") userID := pickFirstString(infoBody, "uid", "user_id", "id", "data.uid") // JSONP 回调结构可能变化,cookie 中的 acf_uid/acf_nickname 是更稳定的来源 @@ -188,6 +206,9 @@ func doDouyuRequest(req *http.Request) ([]byte, error) { // doDouyuRequestWithCookies 与 doDouyuRequest 相同,但额外返回响应的 Set-Cookie, // 供扫码成功时提取 passport 域 LTP0 等长期凭证。 +// 4xx/5xx 直接判为失败:斗鱼业务错误一律 HTTP 200 + error 字段,非 2xx 是风控页/网关页, +// 其 HTML 正文若原样返回,会被上层当成"JSON 里 error 字段缺省"继续解析, +// 最终拼出"斗鱼登录成功响应缺少回调 URL, body: …"这种既误导又外泄上游原文的错误。 func doDouyuRequestWithCookies(req *http.Request) ([]byte, []*http.Cookie, error) { resp, err := douyuHTTPClient.Do(req) if err != nil { @@ -198,6 +219,9 @@ func doDouyuRequestWithCookies(req *http.Request) ([]byte, []*http.Cookie, error if err != nil { return nil, nil, err } + if resp.StatusCode >= 400 { + return nil, nil, fmt.Errorf("斗鱼登录接口返回 HTTP %d(可能被风控或接口已变更),请求 %s", resp.StatusCode, req.URL.Path) + } return body, resp.Cookies(), nil } @@ -223,6 +247,8 @@ func exchangeLoginCookie(ctx context.Context, rawUrl string) (cookie string, jso var hops []string // 跳转链诊断信息(仅记录 cookie 名,不记录值) cur := rawUrl var finalBody string + finalStatus := 0 + converged := false // 是否走到了非跳转的终点响应;false 表示 8 跳预算用尽 for hop := 0; hop < 8; hop++ { req, err := http.NewRequestWithContext(ctx, "GET", cur, nil) if err != nil { @@ -251,11 +277,13 @@ func exchangeLoginCookie(ctx context.Context, rawUrl string) (cookie string, jso 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)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) resp.Body.Close() if loc == nil { - finalBody = string(body) - break + // 3xx 却不给 Location:跳转链在此断裂,后面再也拿不到任何 Set-Cookie, + // 把已经攒到的半截 cookie 当登录成功落盘,表现为面板显示已登录而实际录不上。 + trace = strings.Join(hops, "; ") + return "", "", trace, fmt.Errorf("斗鱼登录回调返回 HTTP %d 但未给出跳转地址,登录链中断; %s", resp.StatusCode, trace) } if err = checkDouyuURL(loc.String()); err != nil { return "", "", strings.Join(hops, "; "), err @@ -269,11 +297,20 @@ func exchangeLoginCookie(ctx context.Context, rawUrl string) (cookie string, jso return "", "", strings.Join(hops, "; "), err } finalBody = string(body) + finalStatus = resp.StatusCode + converged = true break } - parts := sortedCookiePairs(seen) - trace = strings.Join(hops, "; ") + "; 终点响应体: " + truncateForLog([]byte(finalBody)) - return strings.Join(parts, "; "), stripJSONP(finalBody), trace, nil + trace = strings.Join(hops, "; ") + if !converged { + // 8 跳仍未落到终点响应:斗鱼加长或循环了跳转链。此前这里带着"半截 cookie + nil error"直接返回, + // 调用方只看 cookie 非空就判定登录成功,残缺登录态会连同 3 天后的续期排期一起落盘。 + return "", "", trace, fmt.Errorf("斗鱼登录回调连续 8 跳仍未到达终点,接口可能已变更; %s", trace) + } + if finalStatus >= 400 { + return "", "", trace, fmt.Errorf("斗鱼登录回调终点返回 HTTP %d(风控或接口变更),未获得登录态; %s", finalStatus, trace) + } + return strings.Join(sortedCookiePairs(seen), "; "), stripJSONP(finalBody), trace, nil } // checkDouyuURL 只允许斗鱼官方域名,防止被诱导请求任意地址(SSRF) diff --git a/src/servers/douyu_cookie_api_test.go b/src/servers/douyu_cookie_api_test.go index 96e6156..52912bb 100644 --- a/src/servers/douyu_cookie_api_test.go +++ b/src/servers/douyu_cookie_api_test.go @@ -395,13 +395,31 @@ func TestPutRawConfigDouyuAuthSectionPresence(t *testing.T) { } }) - t.Run("两节均未提交:票据保留,登录态缺失时排期改为立即", func(t *testing.T) { + t.Run("两节均未提交:票据与登录 cookie 一律照抄最新值", func(t *testing.T) { mem, disk := submit(t, seed(t), "out_put_path: ./\n") checkTicket(t, mem, disk, ticket) - // cookies 节没提交 ⇒ 登录 cookie 被默认值带走(它可由票据换回),此时必须立刻续期, - // 否则要空转到原排期(最长 3 天)才恢复登录录制,中间一直是匿名态。 + // cookies 整节没提交 = 这一节没改(旧前端缓存、不认识斗鱼字段的局部客户端), + // 不能当成"把所有登录 cookie 清空":票据一旦同时失效,抹掉 cookie 就只能重新扫码。 + if got := mem.Cookies[dy.CookieHost]; got != "acf_uid=111; acf_auth=old" { + t.Errorf("未提交 cookies 节却改动了登录 cookie: %q", got) + } + if mem.DouyuAuth.NextRefreshAt != 1893456000 { + t.Errorf("未提交 cookies 节却改动了续期日程: %d", mem.DouyuAuth.NextRefreshAt) + } + if disk.DouyuAuth.NextRefreshAt != 1893456000 { + t.Errorf("盘上续期日程被改动: %d", disk.DouyuAuth.NextRefreshAt) + } + }) + + t.Run("只剩票据没有登录 cookie:排期改为立即", func(t *testing.T) { + // 升级后的存量状态:盘上有 LTP0 但没有登录 cookie(或只有无 acf_uid 的匿名 cookie)。 + // 此时一次普通保存若仍按原日程排队,要空转到最长 3 天后才换回登录态,中间一直匿名录制。 + cfg := seed(t) + delete(cfg.Cookies, dy.CookieHost) + mem, disk := submit(t, cfg, "out_put_path: ./\n") + checkTicket(t, mem, disk, ticket) if douyuCookieUid(mem.Cookies[dy.CookieHost]) != "" { - t.Fatalf("用例前提不成立:cookie 并未丢失") + t.Fatalf("用例前提不成立:登录 cookie 并未丢失") } if mem.DouyuAuth.NextRefreshAt != 0 { t.Errorf("登录态已丢却仍按旧日程排队: %d", mem.DouyuAuth.NextRefreshAt) diff --git a/src/servers/handler.go b/src/servers/handler.go index 0e5453d..4b26d1f 100644 --- a/src/servers/handler.go +++ b/src/servers/handler.go @@ -1084,76 +1084,34 @@ func putRawConfig(writer http.ResponseWriter, r *http.Request) { }) return } - curConfig := configs.GetCurrentConfig() - if curConfig == nil { + // snapshot 是本次保存的基准(用户打开明文页时所见的配置),只用来判断"这一处用户改没改"。 + // 真正与提交结果比较的是 prevConfig(提交时刻的配置),见下面的三方合并说明。 + snapshot := configs.GetCurrentConfig() + if snapshot == nil { writeJsonWithStatusCode(writer, http.StatusInternalServerError, commonResp{ ErrNo: http.StatusInternalServerError, ErrMsg: "配置尚未加载", }) return } - // 在副本上刷新索引:配置是不可变快照,直接在 GetCurrentConfig() 返回的共享对象上 - // 调 RefreshLiveRoomIndexCache 会就地改写一份正被所有并发请求读取的 map - // (Go 运行时 fatal error: concurrent map read and map write)。 - // CloneConfigShallow 会连 liveRoomIndexCache 一起换新 map,所以在副本上刷新是安全的。 - 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) - oldMap := make(map[string]configs.LiveRoom, len(oldConfig.LiveRooms)) - for _, room := range oldConfig.LiveRooms { - oldMap[room.Url] = room - } - for i := range newConfig.LiveRooms { - if rOld, ok := oldMap[newConfig.LiveRooms[i].Url]; ok { - newConfig.LiveRooms[i].LiveId = rOld.LiveId - } - } - // 手工编辑"设置明文"里的斗鱼 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 在锁内完成替换+持久化, - // 并在版本冲突时重试,读到新配置的其它写者不会丢失更新。 + // 直接用这份基于旧快照的配置盖掉别人刚落盘的内容。 + // + // 但只把提交挪进锁里还不够:UpdateWithRetry 每次重试都会把"最新配置的克隆"传进 mutator, + // 若 mutator 依旧 *c = *基于入口快照算好的文档,重试就毫无意义(伪重试)——解析这几十毫秒里 + // 完成的并发写入会被静默覆盖回旧值,且随本次提交一起落盘,内存与磁盘同时倒带。 + // 实测:4000 条 cookies 的明文提交耗时约 60ms,窗口内的斗鱼续期结果 100% 被抹掉。 + // 因此所有继承/作废判定都必须以提交时刻的最新配置为基准在闭包内重算一遍(三方合并)。 + var prevConfig *configs.Config committed, err := configs.UpdateWithRetry(func(c *configs.Config) error { - *c = *newConfig + // c 是最新配置的私有克隆:先另存一份提交前状态,供运行态房间差异比对使用 + prevConfig = configs.CloneConfigShallow(c) + prevConfig.RefreshLiveRoomIndexCache() + applyRawConfigDoc(c, snapshot, newConfig, authSectionEdited, cookiesSectionEdited) return nil }, 3, 10*time.Millisecond) if err != nil { @@ -1165,7 +1123,7 @@ func putRawConfig(writer http.ResponseWriter, r *http.Request) { } // 先落配置再驱动运行态差异:新增房间在初始化时会按全局配置取登录 cookie, // 若此时配置还是旧的,新加的房间就以匿名态建连。 - if err := applyLiveRoomsByConfig(ctx, oldConfig, committed); err != nil { + if err := applyLiveRoomsByConfig(ctx, prevConfig, committed); err != nil { writeJSON(writer, map[string]any{ "error": err.Error(), }) @@ -1176,6 +1134,123 @@ func putRawConfig(writer http.ResponseWriter, r *http.Request) { }) } +// applyRawConfigDoc 把"设置明文"页提交的文档落到提交时刻的最新配置上(结果就地写回 latest)。 +// snapshot 是用户打开该页时的配置,仅用于判断"这一处用户到底改没改"。 +// 三方规则:文档相对快照未改动的内容一律取 latest(保住解析 YAML 这几十毫秒里完成的并发写入), +// 改动过的取文档值(以用户这次编辑为准)。latest 由 UpdateWithRetry 在锁内传入,是本函数的唯一写入目标。 +func applyRawConfigDoc(latest, snapshot, doc *configs.Config, authSectionEdited, cookiesSectionEdited bool) { + // 每次重试都从原始文档重新算:mutator 可能被调用多次,不能污染 doc + merged := configs.CloneConfigShallow(doc) + + // 明文页提交的是"整份配置",但"文档里根本没写这一节"与"写了但留空"含义不同:前者极可能是旧前端 + // 缓存或局部提交的客户端(它不认识斗鱼登录字段),后者才是用户显式清空。不区分的话,一次普通保存 + // 就把不可再生的长期凭证连同续期日程静默抹掉,表现为自动续期永久失效且没有任何提示(只能重新扫码)。 + // 实测:把 GET /raw-config 的响应体原样提交回去(YAML 里两节都缺)即可复现。 + // + // 掩码是服务端自己写进文档的,不能算用户改动,所以判"改没改"要先把掩码换回快照里的原值再比。 + docAuth := doc.DouyuAuth + if docAuth.LTP0 == douyuSecretMask { + docAuth.LTP0 = snapshot.DouyuAuth.LTP0 + } + if docAuth.DyDid == douyuSecretMask { + docAuth.DyDid = snapshot.DouyuAuth.DyDid + } + if !authSectionEdited || docAuth == snapshot.DouyuAuth { + // 整节没提交、或整节照抄没改(只想改别的东西时最常见):续期日程、失效标记这些机器字段以最新值为准, + // 否则一次普通保存就会把 keeper 刚写进去的 NextRefreshAt/NeedRescan/LastRenewSuccessAt 倒回入口快照。 + merged.DouyuAuth = latest.DouyuAuth + } else { + // 用户确实编辑了这一段:文档里的掩码仍要还原成最新票据,否则"打开明文页点一次保存" + // 就会把掩码字符串本身当成票据存下来。真正要清除时留空 ltp0 即可。 + if merged.DouyuAuth.LTP0 == douyuSecretMask { + merged.DouyuAuth.LTP0 = latest.DouyuAuth.LTP0 + } + if merged.DouyuAuth.DyDid == douyuSecretMask { + merged.DouyuAuth.DyDid = latest.DouyuAuth.DyDid + } + } + + // cookies 同理做逐主机三方合并:用户在编辑器里没动过的那条,若已被后台续期改写,取最新值。 + // 整节缺失视为"这一节没提交"(局部客户端不认识它),不是"把所有登录 cookie 清空"。 + if !cookiesSectionEdited { + merged.Cookies = cloneStrMap(latest.Cookies) + } else { + merged.Cookies = mergeCookies(snapshot.Cookies, doc.Cookies, latest.Cookies) + } + + // OpenList 令牌由出封面阶段在每次轮换后自动回写,文档里带的还是打开页面那一刻的旧值: + // 用户没改过这一项就取最新值,否则一次保存把新令牌倒回去,下次上传得重新登录。 + if doc.OpenList.Token == snapshot.OpenList.Token { + merged.OpenList.Token = latest.OpenList.Token + } + + // 继承原配置的文件路径 + merged.File = latest.File + // 预先将旧配置中的 LiveId 迁移到新配置(相同 URL) + oldMap := make(map[string]configs.LiveRoom, len(latest.LiveRooms)) + for _, room := range latest.LiveRooms { + oldMap[room.Url] = room + } + for i := range merged.LiveRooms { + if rOld, ok := oldMap[merged.LiveRooms[i].Url]; ok { + merged.LiveRooms[i].LiveId = rOld.LiveId + } + } + // 手工编辑"设置明文"里的斗鱼 cookie,只有换了登录账号才算换掉了一份登录态:按 acf_uid 判断。 + // 不能整串比较后一律清票据——补一个字段、删一个过期字段这类同账号微调非常常见, + // 顺手清掉长期凭证的结果是自动续期静默失效,用户只看到"过几天又断流"却毫无线索。 + // 反之若真的换了账号却保留盘上的 LTP0,后台下次排期会用原账号换票并把它的 acf_* 合并回来, + // 表现为"我手改的 cookie 过几天自己变了回去"(静默混号)。宁可放弃自动续期,也不能悄悄换人。 + // 比较的是三方合并后的最终登录态与当前盘上的登录态:同账号微调、或根本没提交这一节都不会触发。 + if oldUid, newUid := douyuCookieUid(latest.Cookies[dy.CookieHost]), douyuCookieUid(merged.Cookies[dy.CookieHost]); oldUid != newUid { + // cookies 整节缺失时 newUid 必然为空,那不是"换了账号"而是"没提交这一节",不能据此作废票据。 + if cookiesSectionEdited { + if merged.DouyuAuth.LTP0 != "" { + applog.GetLogger().Warnf("检测到斗鱼登录 Cookie 换了账号(acf_uid %s -> %s),已同时清除斗鱼长期凭证(需重新扫码才能启用自动续期)", oldUid, newUid) + } + merged.DouyuAuth = configs.DouyuAuth{} + } + } + // 票据因"未提交该节"而被沿用、登录 cookie 却已不在(cookies 整节被局部提交带走)时, + // 把排期改为"立即":否则要空转到原排期(最长 3 天)才换回登录态,中间一直是匿名录制。 + if merged.DouyuAuth.LTP0 != "" && douyuCookieUid(merged.Cookies[dy.CookieHost]) == "" { + merged.DouyuAuth.NextRefreshAt = 0 + } + *latest = *merged +} + +// mergeCookies 逐主机三方合并 cookies:snapshot 是用户打开页面所见,doc 是提交内容,latest 是当前最新值。 +// 文档里没写这条主机时,区分"用户把它删了"(快照里有)与"提交期间别人才加上"(快照里没有),后者要保留。 +func mergeCookies(snapshot, doc, latest map[string]string) map[string]string { + merged := make(map[string]string, len(doc)+len(latest)) + for host, cookie := range doc { + if old, ok := snapshot[host]; ok && old == cookie { + // 用户没动这一条:以最新值为准(斗鱼自动续期可能已经把它换掉了) + if cur, ok := latest[host]; ok { + cookie = cur + } + } + merged[host] = cookie + } + for host, cookie := range latest { + if _, inDoc := doc[host]; inDoc { + continue + } + if _, wasInSnapshot := snapshot[host]; !wasInSnapshot { + merged[host] = cookie + } + } + return merged +} + +func cloneStrMap(src map[string]string) map[string]string { + cp := make(map[string]string, len(src)) + for k, v := range src { + cp[k] = v + } + return cp +} + func applyLiveRoomsByConfig(ctx context.Context, oldConfig *configs.Config, newConfig *configs.Config) error { inst := instance.GetInstance(ctx) newLiveRooms := newConfig.LiveRooms