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:
1 parent
73518c6723
commit
fc71b311ed
4 files changed
+275
-82
No files matched your search
+71
-8
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user