fix(servers,configs,live/douyu): 明文配置并发提交与三处静默失效

- putRawConfig 改为锁内三方合并:继承与作废判定以提交时刻的最新配置为准,
  解析整份 YAML 那几十毫秒里后台续期写入的票据、登录 cookie 与 OpenList 令牌
  不再被入口快照倒回旧值(内存与磁盘同时倒带,重启也回不来)。
- 房间 URL 规范化后的去重改为逐字段补齐:整条丢弃会把重复条目上单独配的原画、
  输出目录、弹幕开关、转 mp4 无声清零,并随本次加载写回磁盘。
- 换票链未真正到达终点时不再报登录成功:3xx 无 Location、8 跳耗尽、终点 4xx/5xx
  一律判失败,否则残缺 cookie 落盘后面板显示已登录而实际录不上,下次续期还排到三天后。
- 扫码接口非 2xx 与缺少 error 字段的响应直接判失败,错误串不再夹带上游正文,
  避免一次性 token 与风控页原文进入 HTTP 响应和应用日志。

行为变更:明文页整块删除 cookies: 一节视为"未提交",不再清空登录态;
确要清除请写 cookies: {} 或把对应主机留空。
This commit is contained in:
Joftal committed 2026-09-26 00:23:21 +08:00
1 parent 73518c6723
commit fc71b311ed
4 files changed
+275 -82

No files matched your search

+71 -8
View File
@@ -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 不停机升级的关键保障)。
+48 -11
View File
@@ -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: <!DOCTYPE html>…"这种既误导又外泄上游原文的错误。
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)
+22 -4
View File
@@ -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)
+134 -59
View File
@@ -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