# 2026-08-27 工作日志 ## 抖音私信发送 decision=KICK 修复 + 第二套发送方案 ### 根因诊断(已完成) - 现象:账号1(my_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 hook(manager 层驱动,避免 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.toDataURL()` 提取;对 `` 优先读 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 存储上传(腾讯云 VOD,AWS 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` 残留。 ### 运行时验证(待重启后端) 配置账号 UA(account.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 + 抓包 + frida(ssl 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 冒烟:辅助方法存在且为 static;expires 提取(含 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_id:extra/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_* - 账号 1:im_session_data.my_uid 7678646545793812008(web_id) → 2609567359568155,uid_verified=True - 账号 8/9:仅补 uid_verified=True - 验证:3 账号均 device_id==my_uid==douyin_uid,ALL_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 + 新解析逻辑自然清洗。