This commit is contained in:
Your Name
2026-08-27 18:32:03 +08:00
parent 4ac6990efe
commit 1f3addcf79
50 changed files with 9145 additions and 1760 deletions
+221
View File
@@ -0,0 +1,221 @@
# 2026-08-27 工作日志
## 抖音私信发送 decision=KICK 修复 + 第二套发送方案
### 根因诊断(已完成)
- 现象:账号1my_uid=2609567359568155)收消息正常(UA=Chrome/148),发消息被安全网关 decision=KICK。
- 根因:**UA 全链路不一致**——凭证采集时写入 storage_state/im_session_data 的 UA 是 Chrome/148,但 worker 构建 IM 会话时 `_load_user_agent()``resolve_user_agent` 回退默认 Chrome/120 覆盖了采集 UA。a_bogus 签名、IM 请求头、Protobuf body 用 Chrome/120,浏览器上下文/凭证却是 Chrome/148,设备指纹不一致触发 KICK。
- 数据库证据:`account.user_agent=None``im_session_data.user_agent=Chrome/120`(被覆盖后写入)、`cookie_data.user_agent=Chrome/148`
### 修改(backend/rpa_engine/playwright_worker.py
1. `__init__` 新增 `self._raw_user_agent = ""`
2. 新增 `_load_raw_user_agent()`:读账号表显式配置的 UA(未配置返回空串)。
3. `_build_im_session_from_storage`:账号表显式配置 UA 时用 `resolve_user_agent(raw_ua)`;否则**保留** storage_state 采集的 UA(不再覆盖),加日志。
4. 新增 `send_im_via_browser_page(conversation_id, content)`:第二套发送方案——构建 IM session → `DouyinImHttpClient.resolve_conversation_meta` 解析 ticket/short_id → `ProtoBuilder.build_send_message_request` 构造 protobuf → 打开抖音 /message 页面(非 headless 最小化)→ 页面 JS fetch `/v1/message/send`security-sdk 注入 a_bogus/bd-ticket-guard)→ `analyze_send_response` 判定。受 `KEFU_BROWSER_SEND_TIMEOUT`(默认45s)控制。
5. `_run_im_direct_service` 注入 `send_fallback=self.send_im_via_browser_page`
### 修改(backend/rpa_engine/douyin_im/service.py
- `__init__` 新增 `send_fallback` 参数。
- `_send_text` 失败分支:`last_error``DECISION=KICK`/`STATUS_CODE=7911`/`INVALID_REQUEST` 且存在 `send_fallback` 时调用兜底;成功则重置 `_session_invalid_strikes/_fired` 并返回 True;失败继续走 `_note_session_invalid` 自动下线。
### 方法签名验证(全部通过,py_compile OK
- `resolve_conversation_meta(auth, conv_id, my_uid, peer_uid) → (conv_id, short_id, ticket)`
- `ProtoBuilder.build_send_message_request(auth, conv_id, short_id, ticket, msg_content, message_type=7)` 静态方法;`build_msg_payload(spec) → (msg_content, message_type)` 展开正确
- `parse_reply_content(raw) → dict`(含 `type` 键,`"image"` 时兜底拒绝)
- `analyze_send_response(bytes) → dict`ok/decision/status_code/summary/server_message_id
- `DouyinAuth.from_im_session` / `ImSession.can_direct_im/cookies/my_uid/user_agent` / `DouyinImHttpClient(session, account_id)` async with 均匹配
### 待办/注意
- 后端当前未运行;下次启动自动生效(无需重启)。
- 浏览器兜底暂不支持图片回复(返回"请改用纯文字")。
- 运行时验证未执行:需要账号重新托管后实际触发发送观察;KICK 后兜底页面需等待 security-sdk 加载(15s 轮询)。
## 登录态常驻方案(A 保活 + B 自动重登录)✅ 已实施并通过编译/导入验证
背景:sessionid/passport 无 refresh token 可自动换新;IM 通道活跃**不刷新** passport 登录态,托管约 30 天失效 → 抖音返回「用户未登录」。
### A 保活(backend/rpa_engine/playwright_worker.py
- 配置:`KEFU_KEEPALIVE_INTERVAL`(默认 21600s=6h,最小 300s)、`KEFU_KEEPALIVE_DISABLED`(设为 1 关闭)。
- `_keepalive_loop`:每间隔调用 `_keepalive_touch`
- `_keepalive_touch``controller.browser_slot(account_id,"keepalive")` 串行 → 非 headless 最小化 Chromium → storage_state + `self._user_agent` 建 context → **临时挂载 `(self.playwright/browser/context/page)`** 复用 `_has_visible_login_prompt/_persist_cookies` → goto douyin.com → sleep 6s → 检测登录提示(有则返回失败"将触发自动重登录")→ `_persist_cookies()` → finally 恢复引用并关浏览器。
- 挂载点:`_run_im_direct_service` run() 前 `_start_keepalive()`finally 后 `_stop_keepalive()``stop()` 开头也 `_stop_keepalive()`(防悬挂)。
### B 失效自动重登录(backend/main.py + worker 侧)
- worker`__init__` 新增 `relogin_hook: Optional[Callable[[int], Awaitable[None]]]``on_im_session_invalid` 末尾 await hookmanager 层驱动,避免 worker 自 cancel 扭曲 + browser_slot 竞争)。
- manager`_AUTO_RELOGIN_COOLDOWN`(默认 1800s=30min 防抖,env `KEFU_AUTO_RELOGIN_COOLDOWN`);`_schedule_auto_relogin` 快速返回(防抖→查重→create_task);`_auto_relogin_account`:轮询等旧 worker 退出(100×0.2s)→ DB 置 `logging_in`、清 qr/error → `start_worker(login_mode="browser", wait_until_ready=False)` → 浏览器弹码 → 前端账号卡片轮询展示 qr_code_base64 → 扫码自动恢复托管;异常置 offline+error_message。
- 前端无需改(Accounts.vue 已支持 logging_in + 二维码)。
### 验证记录
- `py_compile playwright_worker.py main.py` → COMPILE_OK。
- AST 检查修正版(须同时匹配 `ast.AsyncFunctionDef`!)确认:DouyinWorker 含 `_keepalive_interval(sync)/_keepalive_disabled(sync)/_start_keepalive/_stop_keepalive/_keepalive_loop/_keepalive_touch/on_im_session_invalid`(均 async);WorkerManager 含 `_schedule_auto_relogin/_auto_relogin_account`(均 async)。
- venv 导入冒烟(`.venv/Scripts/python.exe`)→ IMPORT_SMOKE_OK`DouyinWorker.__init__` 参数含 `relogin_hook`
### 运行时验证(待后端启动后)
1. 托管后 6h 内观察保活日志(打开 douyin.com + 持久化 cookie)。
2. 手动清 passport cookie 或等失效 → 观察自动重登录链路:offline→logging_in→弹码→扫码→恢复托管;确认 30min 防抖生效(扫码失败不会死循环)。
3. 唯一绕不开的人工动作是扫码(抖音无免扫码续期通道)。
## 二维码截图太小/无法扫描 ✅ 已修复
### 问题
自动重登录时前端弹窗显示的是**整页截图兜底**,二维码被包在整个网页截图中,尺寸过小,手机无法扫描。
### 诊断
- 后端 `_grab_qr_data_url()` 的精确选择器未命中当前 Douyin 二维码元素,导致一直回退到 `page.screenshot()`
- 使用 Playwright 诊断脚本 `backend/debug_qr_inspect.py` 观察:headless 环境触发的是验证码 iframe(`lf-rc1.yhgfb-cn-static.com/.../rmc-nocaptcha`),与真实有头浏览器弹出的扫码登录弹窗不同,因此重点改为增强二维码抓取鲁棒性。
### 修改(backend/rpa_engine/playwright_worker.py
1. 新增 `from PIL import Image``import io`
2. 扩充 `_QR_SELECTORS`:新增登录弹窗内 `img`/`canvas` 选择器(`#login-pannel``*login-guide*``*login-panel*``*account_login*` 等)。
3. 扩充 `_QR_CONTAINER_SELECTORS` / `_LOGIN_PANEL_SELECTORS`
4. `_grab_qr_in_frames`:匹配元素后增加 `_looks_like_qr_box()` 校验(80~600px、长宽比≥0.75);对 `<canvas>` 优先用 `canvas.toDataURL()` 提取;对 `<img>` 优先读 data-src/http-src;返回前统一经 `_upscale_qr_image()` 放大。
5. 新增 `_grab_qr_generic_in_frames()`:在所有 frame 中泛化扫描登录容器内最大方型 `img`/`canvas`,作为精确选择器未命中时的兜底。
6. `_grab_login_panel_shot()`:改为遍历所有 frame(含 iframe),并调用 `_ensure_panel_fits()` 临时放大 viewport 保证截图清晰。
7. 新增 `_crop_center_viewport_shot()`:截取视口中央 700x800 区域,替代直接整页截图,避免二维码过小。
8. 新增 `_upscale_qr_image()`:对小于 280px 的二维码用 Pillow 最近邻放大,提高手机扫描成功率。
9. `_grab_qr_data_url()` 增加分阶段日志;把「中心区域截图」放在「整页截图」之前;整页截图加 15s timeout 防字体加载卡死。
10. `_check_and_grab_captcha()` 的整页兜底改为先 `_crop_center_viewport_shot()`
### 验证
- `py_compile rpa_engine/playwright_worker.py` → COMPILE_OK。
- venv 导入冒烟 → IMPORT_OK;新增方法列表:`_looks_like_qr_box``_grab_qr_generic_in_frames``_ensure_panel_fits``_crop_center_viewport_shot``_upscale_qr_image`
### 待验证
- 重启后端后触发自动重登录/首次托管,观察前端二维码是否清晰可扫;查看日志应出现 `Captured QR via precise selector` / `generic scan` / `login panel screenshot` 等字样,而不是 `fell back to full-viewport screenshot`
## 排查:KICK 是否由代码主动退出登录触发 ✅ 结论:否
### 用户疑问
收到 `decision=KICK` 错误,怀疑代码里有主动退出登录的逻辑。
### 排查结果
全库搜索 `logout / 退出登录 / clear_cookie / sessionid.*None / passport.*logout` 等,**没有发现任何主动调用抖音退出登录接口或自动清空 session cookie 的代码**。
- `clear_cookie_file()` 仅在 3 个**手动 API** 中被调用:
- `DELETE /api/accounts/{id}`(删除账号)
- `POST /api/accounts/{id}/clear-cookie`(手动清除 Cookie
- `_reset_account_credentials`(重置账号凭证接口)
- `on_im_session_invalid()` 只停止 worker、更新 DB 状态为 offline、触发 `relogin_hook` 自动重登录,**不会删除 cookie / session**。
- `_keepalive_touch()` 仅访问 `https://www.douyin.com/` 并持久化 cookie,不会登出。
`decision=KICK` 是**抖音服务端安全网关返回的**,常见原因:
1. passport/sessionid 自然过期;
2. 账号在其它设备/浏览器登录,挤掉当前会话;
3. 设备指纹/签名不一致触发风控;
4. 服务端主动下线。
### 同步更新提示文案
发现 `http_client.py``service.py` 中 KICK 提示仍写着"请停止托管后用浏览器模式重新登录...",与已实施的自动重登录方案矛盾。已修改为"系统正在自动重登录,请留意账号卡片上的登录二维码并扫码"。
### 修改文件
- `backend/rpa_engine/douyin_im/http_client.py`
- `backend/rpa_engine/douyin_im/service.py`
### 验证
- `py_compile` 两个文件 → COMPILE_OK。
## UA 全链路一致性修复(接收链路)✅ 已实施并通过编译/导入验证
### 需求
用户明确要求:接收消息与发送消息使用同一 User-Agent,账号配置里改了 UA,发送、接收都要同步生效。
### 背景
- 发送链路上一轮已一致:`_build_im_session_from_storage` 保留采集 UA → `session.user_agent``DouyinAuth.from_im_session(session)``auth.user_agent`
- 接收链路存在 2 个硬编码漏网点(Chrome/120 DEFAULT_USER_AGENT+ 多处无参 `DouyinAuth()` 构造导致 `self.user_agent` 未设置。
### 修改
1. **`douyin_im/auth.py`**
- `__init__` 增加 `self.user_agent = None`
- `perepare_auth` 增加 `user_agent: str = ""` 参数,非空时保存 `self.user_agent`(避免覆盖 from_im_session 已设值)。
- `query_my_uid()` 的请求头与 `generate_a_bogus` 改用 `ua = self.user_agent or DEFAULT_USER_AGENT`(消除硬编码)。
- `from_im_session` 在 perepare_auth 时直接传 `user_agent=session.user_agent or DEFAULT_USER_AGENT`
2. **`douyin_im/dy_util.py`**`generate_webid(auth=None, url="", user_agent="")` 新增参数;内部 UA 优先级:显式参数 > `auth.user_agent` > DEFAULT(消除硬编码)。
3. **调用点全部显式传 UA**`session.user_agent or DEFAULT_USER_AGENT`):
- `frontier.py fetch_device_id``follower_poll.py``main.py:2911`conversations 兜底)、`service.py send_message`uid 兜底)
- `peer_profile.py _build_auth``image_upload.py``account_profile.py _build_auth`(传 `ua` 变量)
### 保留原样(非风险点)
- `image_upload.py:496/599``DEFAULT_USER_AGENT`:VOD 存储上传(腾讯云 VODAWS SigV4/JWT 独立鉴权,非抖音 web API、无 a_bogus),不会触发 7911。
- `proto_builder.py _ua_headers``http_client.py:740`:已走 `auth.user_agent`,自动生效。
- `device_profiles.py` DEFAULT_USER_AGENT:仅作为无显式配置时的默认 profile。
### 验证
- `py_compile` 9 个文件 → PY_COMPILE_OK。
- venv 导入冒烟:perepare_auth 签名含 user_agent、设置/不设置路径正确、generate_webid 签名与缓存命中正确 → IMPORT_SMOKE_OK。
- Grep 确认接收链路(douyin_im)无 `user_agent=DEFAULT_USER_AGENT` / `"User-Agent": DEFAULT_USER_AGENT` 残留。
### 运行时验证(待重启后端)
配置账号 UAaccount.user_agent)→ 重启后端 → 观察日志确认 a_bogus 签名与请求头 UA 一致(Chrome/148 或其他配置值,而非 Chrome/120)。
## 凭证登录头(user_agent)自动回填账号 ✅ 已实施并通过编译/冒烟测试
### 需求
用户问:「获取登录凭证里有没有登录头,如果有的话保存账户的时候直接填上去」。确认采集器导出的 storage_state 顶层带 `user_agent`(如 Chrome/148),保存 Cookie 时之前只存进 `cookie_data``account.user_agent` 列不回填。
### 修改
1. **`backend/utils/cookie_store.py`**:新增 `extract_user_agent_from_cookie_data(cookie_data) -> str`——解析 JSON 取顶层 `user_agent`(兼容 DYCRED 的 `ua` 键),无效/缺失返回空串。
2. **`backend/main.py`**
- 新增 `_backfill_user_agent_from_cookie(account, standard_json_str)`:仅当 `account.user_agent` 为空且凭证含 UA 时回填;**已有自定义 UA 保持不变**(用户配置优先)。
- `create_account`POST /api/accounts)与 `update_account_cookie`PUT /api/accounts/{id}/cookie)保存 Cookie 后调用回填。
- `AccountCookieResponse` 增加 `user_agent` / `user_agent_label` 字段,`_build_cookie_response` 填充,前端保存凭证后可确认回填结果。
### 验证
- `py_compile main.py utils/cookie_store.py` → PY_COMPILE_OK。
- 冒烟测试:storage_state(顶层 user_agent)→ validate_cookie_json 保留 → 提取 Chrome/148 → 空列回填成功;已有自定义 UA 不被覆盖;DYCRED 输入(ua 键)也能提取 → ALL_SMOKE_OK。
### 行为说明
- 保存/更新凭证时自动回填,发送+接收链路经 `_build_account_im_session` 统一走该 UA。
- 若用户在配置页显式改过 UA,凭证里的登录头不会覆盖它。
- 清空凭证(DELETE cookie)不会清 `account.user_agent` 列(保留设备指纹配置,可在配置页手动改)。
### 待验证
重启后端 → 保存一份带 user_agent 的凭证 → 观察账号响应中 `user_agent` 已回填(非空)且 `user_agent_label` 正确。
## 咨询:能否用抖音 APP 凭证登录 → 结论:不能,已改为优化保活 ✅
### 用户提问
能否搞到抖音 APP 登录凭证(APP sessionid)用来登录/托管。
### 结论(已给用户,含对比图)
- APP sessionid 与 Web sessionid 分属不同端,当前架构(frontier WS aid=2906 + web API + a_bogus)只认 Web 登录态 + security-sdk 密钥,APP 凭证直接喂入 → 设备指纹/签名不匹配 → 风控/限号/封号。
- 获取 APP 凭证本身要 root + 抓包 + fridassl pinning 反抓包),且绑定设备指纹,跨环境使用高风控;等于重写整套 APP 协议层,不推荐。
- 用户动机 = 摆脱频繁扫码/登录失效 → 方向改为优化 Web 保活。
### 保活增强(backend/rpa_engine/playwright_worker.py
1. `_keepalive_touch` 访问目标从首页改为 `https://www.douyin.com/im`(私信页,更贴近真实活跃、触发 IM 域请求),可用 `KEFU_KEEPALIVE_URL` 覆盖;/im 异常时回退首页。
2. 随机节奏:停留 4~8s + 一次 `mouse.wheel` 滚动,避免固定机械行为。
3. 新增 `_cookie_expires_map` / `_fmt_expires_map` 静态方法:保活前后读取 sid_guard/sessionid/sid_tt 等 passport cookie 的 expires 并打日志(before/after/renewed),用于实测抖音 Web 是否对持续活跃账号滑动续期。
### 验证
- py_compile → PY_COMPILE_OK。
- AST + venv 冒烟:辅助方法存在且为 staticexpires 提取(含 session cookie -1→0、空值跳过、非 passport 键过滤)、格式化、renewed 判定逻辑全部通过 → KEEPALIVE_SMOKE_OK。
### 待观察(重启后端后)
- 保活日志出现 `keepalive passport expires before[...] after[...] renewed=...`:若 renewed 有值 → 保活确实续期,可继续调优频率;若持续 renewed=none → 抖音 Web passport 不滑动续期,30 天到期仍需扫码一次(自动重登录已保证只扫一次)。
## KICK 循环修复(发送被踢下线 → 重登 → 又被踢)
### 根因(实锤)
- `DouyinImSession.from_storage_state` 把新版 `__tea_cache_tokens_6383.user_unique_id`(实际是 web_id,如 7678646545793812008)误当账号 UID → my_uid=web_id,而 device_id 取 `web_runtime_security_uid`(真实 UID)→ **device_id != my_uid** → 安全网关 decision=KICK → 自动重登 → 新旧登录态混合 → 循环。
- 重登后 cookie 落库仍混合(normalize 只修顶层 my_uid 与 web_runtime_security_uid,不清理 localStorage 混合 tea)。
- worker 的 `profile_matches_cookie` 要求 profile_updated_at >= cookie_updated_at:资料同步滞后时错误 UID 带病运行。
### 修复(4 个文件)
1. `session.py from_storage_state`:改两遍扫描收集(ls_sec_uid/ls_web_id/ls_tea_pairs),my_uid 优先级:extra/顶层 > web_runtime_security_uid > tea(user_unique_id!=web_id 才可信)device_idextra/cookies > ls_sec_uid > my_uid;末尾加一致性收敛(my_uid 与 device_id 不一致时以 my_uid 为准)。tea 解析对非 dict 值(如 int 1)类型保护。
2. `cookie_store.py _parse_tea_from_ls``if not isinstance(parsed, dict): continue`,修复 `__tea_cache_first_*` 值为 1 时的 AttributeError 崩溃(保存凭证 500)。
3. `playwright_worker.py _build_im_session_from_storage`:权威 UID 覆盖改为 `accounts.douyin_uid` 无条件优先(拿 cookie 拉的,最可靠),`profile.uid` 仅在资料不早于 cookie 更新时可信(防串号,账号 9 的 profile 表就存了账号 8 的 UID);覆盖 my_uid 时同步 device_id=verified_uid。
4. `credential.py build_im_session_from_storage`saved.uid_verified 覆盖 my_uid 时同步 device_id(之前只改 my_uid 不改 device_id,凭证残留设备号导致不一致)。
### 数据修复
- 备份:backend/.bak/kefu*.db.before_uid_fix_20260827_*
- 账号 1im_session_data.my_uid 7678646545793812008(web_id) → 2609567359568155uid_verified=True
- 账号 8/9:仅补 uid_verified=True
- 验证:3 账号均 device_id==my_uid==douyin_uidALL_CONSISTENT
### 测试
- test_sec_user_id_guard + test_im_receive_path 共 52 个用例全过(更新 3 处断言:device_id 同步为新行为;3 处 mock 从 _load_user_agent 改为 _load_raw_user_agent)。
- 全套 228 个用例剩 6 个失败为**预先存在的测试 mock 签名过期**get_owned_account 新增 write_permission 参数,测试 mock 未更新),与本次改动无关。
### 待观察(重启后端后)
- 账号 1 再发送是否还 KICK(预期不再循环);日志出现 "replaced collected IM uid ..." / "synced device_id" 即覆盖生效。
- 账号 9 的 localStorage 仍混合(www 域残留 7657072144060941834),等重新登录后由 normalize + 新解析逻辑自然清洗。