Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89cae5b8cc | ||
|
|
2fc864cc00 | ||
|
|
1f3addcf79 | ||
|
|
4ac6990efe | ||
|
|
327a0bc42f | ||
|
|
6119fdd767 |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 399 KiB |
@@ -0,0 +1,221 @@
|
|||||||
|
# 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>` 优先用 `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 存储上传(腾讯云 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 + 新解析逻辑自然清洗。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 2026-08-28 工作日志
|
||||||
|
|
||||||
|
## 抖音 IM「decision=KICK」排查(服务器 116.62.23.103)
|
||||||
|
|
||||||
|
**结论**:不是抖音改了 IM 规则/签名算法,是账号被安全网关风控踢下线。签名链路正常(同服务器另一账号可正常发送、读接口正常返回、凭证完整)。
|
||||||
|
|
||||||
|
**关键证据**(服务器 MySQL `kefu` 库 + `/www/wwwlogs/python/douyin/error.log`):
|
||||||
|
- 活跃账号:id=11「随安尔乐」my_uid=7670159096859706425、id=12「抖音账号_12」my_uid=7670157997767050299,均为 Chrome/148 UA,keys/web_protect 凭证完整(len 533/459)。
|
||||||
|
- 两个账号反复 KICK,且都在回复同一测试号「Huhao」(peer_uid=66578464308),回复内容只是 "ss"/"jjj" 测试文本(非导流话术)。
|
||||||
|
- 时序:KICK → create_conversation INVALID_REQUEST → 「用户未登录」→ 自动重登录 → 恢复 → 再发 → 再 KICK,形成死循环(约每 10~30 分钟一次)。
|
||||||
|
- 读接口(get_by_user_init)正常,只有 signed 的发送(message/send)被 KICK → 签名没问题,是账号级风控。
|
||||||
|
|
||||||
|
**根因判断**:抖音 2026 风控收紧(内容+设备+IP+行为+账号五维)。触发点最可能是:多账号同服务器 IP + 对同一陌生 peer 的自动回复行为;且 KICK→重登→再发 的紧循环本身会加重风控。
|
||||||
|
|
||||||
|
**处理方向**:
|
||||||
|
1. 停掉受影响账号的托管/自动回复,冷却几小时~一天。
|
||||||
|
2. 浏览器模式手动重登,先用互关好友或「对方先发」的真实用户测发送,别再用小号对冷门 peer 反复自动回。
|
||||||
|
3. 确认账号没在手机端同时登录(并发登录会吊销 web ticket)。
|
||||||
|
4. 后续可选代码改进:KICK 后对同一会话加冷却(重登后暂不重发),打断紧循环。
|
||||||
|
|
||||||
|
**环境备注**:后端用 MySQL(`KEFU_DB_TYPE=mysql`),`kefu.db`/`kefu1.db` 是遗留 SQLite(kefu.db 已损坏,非活跃库,可忽略)。
|
||||||
@@ -6,7 +6,26 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from models.database import get_db
|
from models.database import get_db
|
||||||
from models.models import User
|
from models.models import User
|
||||||
from .jwt_utils import decode_access_token
|
from .jwt_utils import decode_access_token
|
||||||
from .roles import can_manage_users, can_write, is_admin
|
from .permissions import (
|
||||||
|
ACCOUNTS_COOKIE,
|
||||||
|
ACCOUNTS_CREATE,
|
||||||
|
ACCOUNTS_DELETE,
|
||||||
|
ACCOUNTS_START,
|
||||||
|
ACCOUNTS_STOP,
|
||||||
|
ACCOUNTS_UPDATE,
|
||||||
|
ACCOUNTS_WRITE,
|
||||||
|
ACCOUNTS_WRITE_GRANULAR,
|
||||||
|
LINK_CARDS_WRITE,
|
||||||
|
MESSAGES_WRITE,
|
||||||
|
ORDERS_CREATE,
|
||||||
|
ROLES_MANAGE,
|
||||||
|
RULES_WRITE,
|
||||||
|
SETTINGS_DATABASE,
|
||||||
|
SYSTEM_LOGS_CLEAR,
|
||||||
|
USERS_MANAGE,
|
||||||
|
WRITE_PERMISSIONS,
|
||||||
|
)
|
||||||
|
from .roles import can_write, has_permission, is_admin
|
||||||
|
|
||||||
bearer_scheme = HTTPBearer(auto_error=False)
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
@@ -32,6 +51,20 @@ async def get_current_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(permission: str):
|
||||||
|
"""FastAPI dependency factory that checks a single permission code."""
|
||||||
|
|
||||||
|
async def _checker(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if has_permission(user.role, permission):
|
||||||
|
return user
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"缺少权限:{permission}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return _checker
|
||||||
|
|
||||||
|
|
||||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||||
if not is_admin(user.role):
|
if not is_admin(user.role):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
||||||
@@ -39,12 +72,54 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
|
|||||||
|
|
||||||
|
|
||||||
async def require_write(user: User = Depends(get_current_user)) -> User:
|
async def require_write(user: User = Depends(get_current_user)) -> User:
|
||||||
if not can_write(user.role):
|
"""Any write-capable permission (accounts/messages/rules) or legacy can_write."""
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前角色只读,无法执行此操作")
|
if is_admin(user.role) or can_write(user.role):
|
||||||
return user
|
return user
|
||||||
|
if any(has_permission(user.role, code) for code in WRITE_PERMISSIONS):
|
||||||
|
return user
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="当前角色只读,无法执行此操作",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_any(*codes: str, detail: str):
|
||||||
|
async def _checker(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if any(has_permission(user.role, code) for code in codes):
|
||||||
|
return user
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
||||||
|
|
||||||
|
return _checker
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compatible: any account write capability.
|
||||||
|
require_accounts_write = _require_any(
|
||||||
|
ACCOUNTS_WRITE,
|
||||||
|
*ACCOUNTS_WRITE_GRANULAR,
|
||||||
|
detail="缺少账号写权限",
|
||||||
|
)
|
||||||
|
require_accounts_create = require_permission(ACCOUNTS_CREATE)
|
||||||
|
require_accounts_update = require_permission(ACCOUNTS_UPDATE)
|
||||||
|
require_accounts_delete = require_permission(ACCOUNTS_DELETE)
|
||||||
|
require_accounts_start = require_permission(ACCOUNTS_START)
|
||||||
|
require_accounts_stop = require_permission(ACCOUNTS_STOP)
|
||||||
|
require_accounts_cookie = require_permission(ACCOUNTS_COOKIE)
|
||||||
|
|
||||||
|
require_messages_write = require_permission(MESSAGES_WRITE)
|
||||||
|
require_rules_write = require_permission(RULES_WRITE)
|
||||||
|
require_link_cards_write = require_permission(LINK_CARDS_WRITE)
|
||||||
|
require_system_logs_clear = require_permission(SYSTEM_LOGS_CLEAR)
|
||||||
|
require_settings_database = require_permission(SETTINGS_DATABASE)
|
||||||
|
require_orders_create = require_permission(ORDERS_CREATE)
|
||||||
|
|
||||||
|
|
||||||
async def require_user_manager(user: User = Depends(get_current_user)) -> User:
|
async def require_user_manager(user: User = Depends(get_current_user)) -> User:
|
||||||
if not can_manage_users(user.role):
|
if has_permission(user.role, USERS_MANAGE):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
return user
|
||||||
return user
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要用户管理权限")
|
||||||
|
|
||||||
|
|
||||||
|
async def require_role_manager(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if has_permission(user.role, ROLES_MANAGE):
|
||||||
|
return user
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要角色管理权限")
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"""Fixed permission catalog: menus, button actions, and data scope.
|
||||||
|
|
||||||
|
UI only selects from this list; new codes must be added in code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Menu visibility
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MENU_DASHBOARD = "menu.dashboard"
|
||||||
|
MENU_ACCOUNTS = "menu.accounts"
|
||||||
|
MENU_MESSAGES = "menu.messages"
|
||||||
|
MENU_RULES = "menu.rules"
|
||||||
|
MENU_LOGS = "menu.logs"
|
||||||
|
MENU_RECEIVED_MESSAGES = "menu.received_messages"
|
||||||
|
MENU_SYSTEM_LOGS = "menu.system_logs"
|
||||||
|
MENU_DOWNLOAD = "menu.download"
|
||||||
|
MENU_HELP = "menu.help"
|
||||||
|
MENU_USERS = "menu.users"
|
||||||
|
MENU_ROLES = "menu.roles"
|
||||||
|
MENU_SETTINGS = "menu.settings"
|
||||||
|
MENU_DESKTOP_UPDATE = "menu.desktop_update"
|
||||||
|
MENU_PAYMENT_SETTINGS = "menu.payment_settings"
|
||||||
|
MENU_PAYMENT_ORDERS = "menu.payment_orders"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Button / action permissions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Accounts (granular). Legacy ``accounts.write`` expands to the full set.
|
||||||
|
ACCOUNTS_CREATE = "accounts.create"
|
||||||
|
ACCOUNTS_UPDATE = "accounts.update"
|
||||||
|
ACCOUNTS_DELETE = "accounts.delete"
|
||||||
|
ACCOUNTS_START = "accounts.start"
|
||||||
|
ACCOUNTS_STOP = "accounts.stop"
|
||||||
|
ACCOUNTS_COOKIE = "accounts.cookie"
|
||||||
|
ACCOUNTS_WRITE = "accounts.write" # legacy bundle
|
||||||
|
|
||||||
|
MESSAGES_WRITE = "messages.write"
|
||||||
|
RULES_WRITE = "rules.write"
|
||||||
|
LINK_CARDS_WRITE = "link_cards.write"
|
||||||
|
|
||||||
|
LOGS_READ = "logs.read"
|
||||||
|
RECEIVED_MESSAGES_READ = "received_messages.read"
|
||||||
|
SYSTEM_LOGS_READ = "system_logs.read"
|
||||||
|
SYSTEM_LOGS_CLEAR = "system_logs.clear"
|
||||||
|
|
||||||
|
USERS_MANAGE = "users.manage"
|
||||||
|
ROLES_MANAGE = "roles.manage"
|
||||||
|
SETTINGS_MANAGE = "settings.manage"
|
||||||
|
SETTINGS_DATABASE = "settings.database"
|
||||||
|
DESKTOP_MANAGE = "desktop.manage"
|
||||||
|
PAYMENTS_MANAGE = "payments.manage"
|
||||||
|
ORDERS_READ = "orders.read"
|
||||||
|
ORDERS_CREATE = "orders.create"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data scope
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Without this (and without is_admin), users only see their own data.
|
||||||
|
DATA_SCOPE_ALL = "data.scope_all"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bundles / aliases expanded on save & when checking permissions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ACCOUNTS_WRITE_GRANULAR: tuple[str, ...] = (
|
||||||
|
ACCOUNTS_CREATE,
|
||||||
|
ACCOUNTS_UPDATE,
|
||||||
|
ACCOUNTS_DELETE,
|
||||||
|
ACCOUNTS_START,
|
||||||
|
ACCOUNTS_STOP,
|
||||||
|
ACCOUNTS_COOKIE,
|
||||||
|
)
|
||||||
|
|
||||||
|
LEGACY_BUNDLES: dict[str, tuple[str, ...]] = {
|
||||||
|
ACCOUNTS_WRITE: ACCOUNTS_WRITE_GRANULAR,
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_PERMISSIONS: tuple[str, ...] = (
|
||||||
|
MENU_DASHBOARD,
|
||||||
|
MENU_ACCOUNTS,
|
||||||
|
MENU_MESSAGES,
|
||||||
|
MENU_RULES,
|
||||||
|
MENU_LOGS,
|
||||||
|
MENU_RECEIVED_MESSAGES,
|
||||||
|
MENU_SYSTEM_LOGS,
|
||||||
|
MENU_DOWNLOAD,
|
||||||
|
MENU_HELP,
|
||||||
|
MENU_USERS,
|
||||||
|
MENU_ROLES,
|
||||||
|
MENU_SETTINGS,
|
||||||
|
MENU_DESKTOP_UPDATE,
|
||||||
|
MENU_PAYMENT_SETTINGS,
|
||||||
|
MENU_PAYMENT_ORDERS,
|
||||||
|
ACCOUNTS_CREATE,
|
||||||
|
ACCOUNTS_UPDATE,
|
||||||
|
ACCOUNTS_DELETE,
|
||||||
|
ACCOUNTS_START,
|
||||||
|
ACCOUNTS_STOP,
|
||||||
|
ACCOUNTS_COOKIE,
|
||||||
|
ACCOUNTS_WRITE,
|
||||||
|
MESSAGES_WRITE,
|
||||||
|
RULES_WRITE,
|
||||||
|
LINK_CARDS_WRITE,
|
||||||
|
LOGS_READ,
|
||||||
|
RECEIVED_MESSAGES_READ,
|
||||||
|
SYSTEM_LOGS_READ,
|
||||||
|
SYSTEM_LOGS_CLEAR,
|
||||||
|
USERS_MANAGE,
|
||||||
|
ROLES_MANAGE,
|
||||||
|
SETTINGS_MANAGE,
|
||||||
|
SETTINGS_DATABASE,
|
||||||
|
DESKTOP_MANAGE,
|
||||||
|
PAYMENTS_MANAGE,
|
||||||
|
ORDERS_READ,
|
||||||
|
ORDERS_CREATE,
|
||||||
|
DATA_SCOPE_ALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
PERMISSION_SET = frozenset(ALL_PERMISSIONS)
|
||||||
|
|
||||||
|
WRITE_PERMISSIONS = frozenset(
|
||||||
|
{
|
||||||
|
ACCOUNTS_WRITE,
|
||||||
|
*ACCOUNTS_WRITE_GRANULAR,
|
||||||
|
MESSAGES_WRITE,
|
||||||
|
RULES_WRITE,
|
||||||
|
LINK_CARDS_WRITE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_PERMISSION_META: dict[str, dict[str, str]] = {
|
||||||
|
MENU_DASHBOARD: {"group": "menu", "label": "数据概览"},
|
||||||
|
MENU_ACCOUNTS: {"group": "menu", "label": "账号管理"},
|
||||||
|
MENU_MESSAGES: {"group": "menu", "label": "私信收发"},
|
||||||
|
MENU_RULES: {"group": "menu", "label": "自动回复规则"},
|
||||||
|
MENU_LOGS: {"group": "menu", "label": "回复日志面板"},
|
||||||
|
MENU_RECEIVED_MESSAGES: {"group": "menu", "label": "接收消息日志"},
|
||||||
|
MENU_SYSTEM_LOGS: {"group": "menu", "label": "系统诊断日志"},
|
||||||
|
MENU_DOWNLOAD: {"group": "menu", "label": "软件下载"},
|
||||||
|
MENU_HELP: {"group": "menu", "label": "帮助中心"},
|
||||||
|
MENU_USERS: {"group": "menu", "label": "用户管理"},
|
||||||
|
MENU_ROLES: {"group": "menu", "label": "角色设定"},
|
||||||
|
MENU_SETTINGS: {"group": "menu", "label": "系统设置"},
|
||||||
|
MENU_DESKTOP_UPDATE: {"group": "menu", "label": "桌面端升级"},
|
||||||
|
MENU_PAYMENT_SETTINGS: {"group": "menu", "label": "支付配置"},
|
||||||
|
MENU_PAYMENT_ORDERS: {"group": "menu", "label": "我的订单"},
|
||||||
|
ACCOUNTS_CREATE: {"group": "action", "label": "新增抖音账号"},
|
||||||
|
ACCOUNTS_UPDATE: {"group": "action", "label": "编辑账号信息"},
|
||||||
|
ACCOUNTS_DELETE: {"group": "action", "label": "删除账号"},
|
||||||
|
ACCOUNTS_START: {"group": "action", "label": "启动托管 / 批量启动"},
|
||||||
|
ACCOUNTS_STOP: {"group": "action", "label": "停止托管"},
|
||||||
|
ACCOUNTS_COOKIE: {"group": "action", "label": "查看/修改 Cookie 与凭证"},
|
||||||
|
ACCOUNTS_WRITE: {"group": "action", "label": "账号全部写操作(兼容旧版,等同下列细项)"},
|
||||||
|
MESSAGES_WRITE: {"group": "action", "label": "发送私信 / 队列立即发送"},
|
||||||
|
RULES_WRITE: {"group": "action", "label": "编辑自动回复规则"},
|
||||||
|
LINK_CARDS_WRITE: {"group": "action", "label": "上传素材 / 生成链接卡片"},
|
||||||
|
LOGS_READ: {"group": "action", "label": "查看回复日志"},
|
||||||
|
RECEIVED_MESSAGES_READ: {"group": "action", "label": "查看接收消息日志"},
|
||||||
|
SYSTEM_LOGS_READ: {"group": "action", "label": "查看系统诊断日志"},
|
||||||
|
SYSTEM_LOGS_CLEAR: {"group": "action", "label": "清空系统诊断日志"},
|
||||||
|
USERS_MANAGE: {"group": "action", "label": "管理用户"},
|
||||||
|
ROLES_MANAGE: {"group": "action", "label": "管理角色"},
|
||||||
|
SETTINGS_MANAGE: {"group": "action", "label": "管理系统设置"},
|
||||||
|
SETTINGS_DATABASE: {"group": "action", "label": "管理数据库配置与迁移"},
|
||||||
|
DESKTOP_MANAGE: {"group": "action", "label": "管理桌面端升级"},
|
||||||
|
PAYMENTS_MANAGE: {"group": "action", "label": "管理支付配置与全部订单"},
|
||||||
|
ORDERS_READ: {"group": "action", "label": "查看我的订单"},
|
||||||
|
ORDERS_CREATE: {"group": "action", "label": "购买额度 / 创建订单"},
|
||||||
|
DATA_SCOPE_ALL: {"group": "data", "label": "查看全部用户数据(全局数据范围)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
OPERATOR_PERMISSIONS: tuple[str, ...] = (
|
||||||
|
MENU_DASHBOARD,
|
||||||
|
MENU_ACCOUNTS,
|
||||||
|
MENU_MESSAGES,
|
||||||
|
MENU_RULES,
|
||||||
|
MENU_LOGS,
|
||||||
|
MENU_RECEIVED_MESSAGES,
|
||||||
|
MENU_DOWNLOAD,
|
||||||
|
MENU_HELP,
|
||||||
|
MENU_PAYMENT_ORDERS,
|
||||||
|
*ACCOUNTS_WRITE_GRANULAR,
|
||||||
|
MESSAGES_WRITE,
|
||||||
|
RULES_WRITE,
|
||||||
|
LINK_CARDS_WRITE,
|
||||||
|
LOGS_READ,
|
||||||
|
RECEIVED_MESSAGES_READ,
|
||||||
|
ORDERS_READ,
|
||||||
|
ORDERS_CREATE,
|
||||||
|
)
|
||||||
|
|
||||||
|
VIEWER_PERMISSIONS: tuple[str, ...] = (
|
||||||
|
MENU_DASHBOARD,
|
||||||
|
MENU_ACCOUNTS,
|
||||||
|
MENU_MESSAGES,
|
||||||
|
MENU_RULES,
|
||||||
|
MENU_LOGS,
|
||||||
|
MENU_RECEIVED_MESSAGES,
|
||||||
|
MENU_DOWNLOAD,
|
||||||
|
MENU_HELP,
|
||||||
|
LOGS_READ,
|
||||||
|
RECEIVED_MESSAGES_READ,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_permissions(codes: list[str] | tuple[str, ...] | None) -> list[str]:
|
||||||
|
if not codes:
|
||||||
|
return []
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for code in codes:
|
||||||
|
value = str(code or "").strip()
|
||||||
|
if not value or value not in PERMISSION_SET or value in seen:
|
||||||
|
continue
|
||||||
|
seen.add(value)
|
||||||
|
result.append(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def expand_legacy_bundles(codes: set[str]) -> set[str]:
|
||||||
|
"""Expand legacy bundle codes into granular permissions."""
|
||||||
|
expanded = set(codes)
|
||||||
|
for bundle, parts in LEGACY_BUNDLES.items():
|
||||||
|
if bundle in expanded:
|
||||||
|
expanded.update(parts)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
# Menu → required action. Selecting a menu always grants the action.
|
||||||
|
MENU_REQUIRED_ACTIONS: dict[str, str | tuple[str, ...]] = {
|
||||||
|
MENU_USERS: USERS_MANAGE,
|
||||||
|
MENU_ROLES: ROLES_MANAGE,
|
||||||
|
MENU_SETTINGS: SETTINGS_MANAGE,
|
||||||
|
MENU_DESKTOP_UPDATE: DESKTOP_MANAGE,
|
||||||
|
MENU_PAYMENT_SETTINGS: PAYMENTS_MANAGE,
|
||||||
|
MENU_PAYMENT_ORDERS: (ORDERS_READ, ORDERS_CREATE),
|
||||||
|
MENU_LOGS: LOGS_READ,
|
||||||
|
MENU_RECEIVED_MESSAGES: RECEIVED_MESSAGES_READ,
|
||||||
|
MENU_SYSTEM_LOGS: SYSTEM_LOGS_READ,
|
||||||
|
MENU_ACCOUNTS: (), # page access only; buttons are separate
|
||||||
|
MENU_MESSAGES: (),
|
||||||
|
MENU_RULES: (),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Action → primary menu only.
|
||||||
|
ACTION_PRIMARY_MENU: dict[str, str] = {
|
||||||
|
USERS_MANAGE: MENU_USERS,
|
||||||
|
ROLES_MANAGE: MENU_ROLES,
|
||||||
|
SETTINGS_MANAGE: MENU_SETTINGS,
|
||||||
|
SETTINGS_DATABASE: MENU_SETTINGS,
|
||||||
|
DESKTOP_MANAGE: MENU_DESKTOP_UPDATE,
|
||||||
|
PAYMENTS_MANAGE: MENU_PAYMENT_SETTINGS,
|
||||||
|
ORDERS_READ: MENU_PAYMENT_ORDERS,
|
||||||
|
ORDERS_CREATE: MENU_PAYMENT_ORDERS,
|
||||||
|
LOGS_READ: MENU_LOGS,
|
||||||
|
RECEIVED_MESSAGES_READ: MENU_RECEIVED_MESSAGES,
|
||||||
|
SYSTEM_LOGS_READ: MENU_SYSTEM_LOGS,
|
||||||
|
SYSTEM_LOGS_CLEAR: MENU_SYSTEM_LOGS,
|
||||||
|
ACCOUNTS_CREATE: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_UPDATE: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_DELETE: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_START: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_STOP: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_COOKIE: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_WRITE: MENU_ACCOUNTS,
|
||||||
|
MESSAGES_WRITE: MENU_MESSAGES,
|
||||||
|
RULES_WRITE: MENU_RULES,
|
||||||
|
LINK_CARDS_WRITE: MENU_RULES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_required_actions(menu: str) -> tuple[str, ...]:
|
||||||
|
raw = MENU_REQUIRED_ACTIONS.get(menu)
|
||||||
|
if raw is None:
|
||||||
|
return ()
|
||||||
|
if isinstance(raw, str):
|
||||||
|
return (raw,)
|
||||||
|
return tuple(raw)
|
||||||
|
|
||||||
|
|
||||||
|
# Catalog / UI pairs (menu → first required action for checkbox hints).
|
||||||
|
MENU_ACTION_PAIRS: tuple[tuple[str, str], ...] = tuple(
|
||||||
|
(menu, actions[0])
|
||||||
|
for menu, actions in (
|
||||||
|
(m, _iter_required_actions(m)) for m in MENU_REQUIRED_ACTIONS
|
||||||
|
)
|
||||||
|
if actions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_paired_permissions(codes: list[str] | tuple[str, ...] | None) -> list[str]:
|
||||||
|
"""Normalize, expand legacy bundles, and auto-complete menu/action pairs."""
|
||||||
|
selected = expand_legacy_bundles(set(normalize_permissions(codes)))
|
||||||
|
for menu in list(selected):
|
||||||
|
for action in _iter_required_actions(menu):
|
||||||
|
selected.add(action)
|
||||||
|
for action, menu in ACTION_PRIMARY_MENU.items():
|
||||||
|
if action in selected:
|
||||||
|
selected.add(menu)
|
||||||
|
# If every granular account write is present, keep the legacy bundle flag.
|
||||||
|
if all(code in selected for code in ACCOUNTS_WRITE_GRANULAR):
|
||||||
|
selected.add(ACCOUNTS_WRITE)
|
||||||
|
return [code for code in ALL_PERMISSIONS if code in selected]
|
||||||
|
|
||||||
|
|
||||||
|
def permission_implies(held: set[str], needed: str) -> bool:
|
||||||
|
"""Whether a held permission set satisfies ``needed`` (incl. legacy bundles)."""
|
||||||
|
if needed in held:
|
||||||
|
return True
|
||||||
|
for bundle, parts in LEGACY_BUNDLES.items():
|
||||||
|
if needed in parts and bundle in held:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def permission_catalog() -> dict[str, Any]:
|
||||||
|
menus = []
|
||||||
|
actions = []
|
||||||
|
data = []
|
||||||
|
meta_by_code: dict[str, dict[str, str]] = {}
|
||||||
|
for code in ALL_PERMISSIONS:
|
||||||
|
meta = _PERMISSION_META[code]
|
||||||
|
item = {"code": code, "label": meta["label"]}
|
||||||
|
meta_by_code[code] = meta
|
||||||
|
group = meta["group"]
|
||||||
|
if group == "menu":
|
||||||
|
menus.append(item)
|
||||||
|
elif group == "data":
|
||||||
|
data.append(item)
|
||||||
|
else:
|
||||||
|
actions.append(item)
|
||||||
|
|
||||||
|
# Reverse index: menu → child action codes (preserve ALL_PERMISSIONS order).
|
||||||
|
children_by_menu: dict[str, list[dict[str, str]]] = {m["code"]: [] for m in menus}
|
||||||
|
for code in ALL_PERMISSIONS:
|
||||||
|
if meta_by_code[code]["group"] != "action":
|
||||||
|
continue
|
||||||
|
parent = ACTION_PRIMARY_MENU.get(code)
|
||||||
|
if parent and parent in children_by_menu:
|
||||||
|
children_by_menu[parent].append(
|
||||||
|
{"code": code, "label": meta_by_code[code]["label"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
tree: list[dict[str, Any]] = []
|
||||||
|
for menu in menus:
|
||||||
|
node: dict[str, Any] = {
|
||||||
|
"code": menu["code"],
|
||||||
|
"label": menu["label"],
|
||||||
|
"kind": "menu",
|
||||||
|
"children": children_by_menu.get(menu["code"], []),
|
||||||
|
}
|
||||||
|
tree.append(node)
|
||||||
|
|
||||||
|
if data:
|
||||||
|
tree.append(
|
||||||
|
{
|
||||||
|
"code": "__group.data__",
|
||||||
|
"label": "数据权限",
|
||||||
|
"kind": "group",
|
||||||
|
"children": [
|
||||||
|
{"code": item["code"], "label": item["label"]} for item in data
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"menus": menus,
|
||||||
|
"actions": actions,
|
||||||
|
"data": data,
|
||||||
|
"tree": tree,
|
||||||
|
"pairs": [
|
||||||
|
{"menu": menu, "action": action} for menu, action in MENU_ACTION_PAIRS
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Persist and cache roles; seed built-ins on startup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from models.models import Role, User
|
||||||
|
from .permissions import ALL_PERMISSIONS, expand_paired_permissions, normalize_permissions
|
||||||
|
from .roles import (
|
||||||
|
ROLE_ADMIN,
|
||||||
|
RoleRecord,
|
||||||
|
default_role_seeds,
|
||||||
|
ensure_role,
|
||||||
|
get_cached_role,
|
||||||
|
is_admin,
|
||||||
|
list_cached_roles,
|
||||||
|
role_label,
|
||||||
|
sanitize_role_permissions,
|
||||||
|
set_role_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("auth.roles")
|
||||||
|
|
||||||
|
_ROLE_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{1,49}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_permissions(codes: list[str]) -> str:
|
||||||
|
return json.dumps(codes, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_permissions(raw: str | None) -> list[str]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
return normalize_permissions([str(item) for item in data])
|
||||||
|
|
||||||
|
|
||||||
|
def role_to_record(row: Role) -> RoleRecord:
|
||||||
|
perms = (
|
||||||
|
list(ALL_PERMISSIONS)
|
||||||
|
if row.is_admin
|
||||||
|
else expand_paired_permissions(_decode_permissions(row.permissions))
|
||||||
|
)
|
||||||
|
return RoleRecord(
|
||||||
|
code=row.code,
|
||||||
|
label=row.label,
|
||||||
|
description=row.description or "",
|
||||||
|
is_system=bool(row.is_system),
|
||||||
|
is_admin=bool(row.is_admin),
|
||||||
|
permissions=perms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_role_cache(db: AsyncSession) -> list[RoleRecord]:
|
||||||
|
result = await db.execute(select(Role).order_by(Role.id.asc()))
|
||||||
|
rows = result.scalars().all()
|
||||||
|
records = [role_to_record(row) for row in rows]
|
||||||
|
if not records:
|
||||||
|
records = default_role_seeds()
|
||||||
|
set_role_cache(records)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_builtin_roles(db: AsyncSession) -> None:
|
||||||
|
"""Insert missing built-in roles and keep system role permissions in sync."""
|
||||||
|
seeds = {seed.code: seed for seed in default_role_seeds()}
|
||||||
|
result = await db.execute(select(Role))
|
||||||
|
existing = {row.code: row for row in result.scalars().all()}
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
for code, seed in seeds.items():
|
||||||
|
row = existing.get(code)
|
||||||
|
payload = _encode_permissions(seed.permissions)
|
||||||
|
if row is None:
|
||||||
|
db.add(
|
||||||
|
Role(
|
||||||
|
code=seed.code,
|
||||||
|
label=seed.label,
|
||||||
|
description=seed.description,
|
||||||
|
is_system=True,
|
||||||
|
is_admin=seed.is_admin,
|
||||||
|
permissions=payload,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
# Keep system flags and built-in permission sets in sync with code.
|
||||||
|
if not row.is_system:
|
||||||
|
row.is_system = True
|
||||||
|
changed = True
|
||||||
|
if seed.is_admin:
|
||||||
|
if not row.is_admin or row.permissions != payload or row.label != seed.label:
|
||||||
|
row.is_admin = True
|
||||||
|
row.permissions = payload
|
||||||
|
row.label = seed.label
|
||||||
|
if seed.description and row.description != seed.description:
|
||||||
|
row.description = seed.description
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
# operator / viewer: resync catalog so new menu/action codes ship.
|
||||||
|
desired = _encode_permissions(expand_paired_permissions(seed.permissions))
|
||||||
|
if row.permissions != desired or row.label != seed.label:
|
||||||
|
row.permissions = desired
|
||||||
|
row.label = seed.label
|
||||||
|
row.is_admin = False
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Repair custom roles that have unpaired menu/action selections.
|
||||||
|
for row in existing.values():
|
||||||
|
if row.code in seeds or row.is_admin:
|
||||||
|
continue
|
||||||
|
repaired = expand_paired_permissions(_decode_permissions(row.permissions))
|
||||||
|
encoded = _encode_permissions(repaired)
|
||||||
|
if row.permissions != encoded:
|
||||||
|
row.permissions = encoded
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
await db.commit()
|
||||||
|
await refresh_role_cache(db)
|
||||||
|
logger.info("Role cache loaded: %s", ", ".join(r.code for r in list_cached_roles()))
|
||||||
|
|
||||||
|
|
||||||
|
async def list_roles(db: AsyncSession) -> list[RoleRecord]:
|
||||||
|
await refresh_role_cache(db)
|
||||||
|
return list_cached_roles()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_role_or_404(db: AsyncSession, code: str) -> Role:
|
||||||
|
result = await db.execute(select(Role).where(Role.code == code))
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="角色不存在")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def count_users_with_role(db: AsyncSession, code: str) -> int:
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count()).select_from(User).where(User.role == code)
|
||||||
|
)
|
||||||
|
return int(result.scalar() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def count_admin_users(db: AsyncSession) -> int:
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count()).select_from(User).where(User.role == ROLE_ADMIN)
|
||||||
|
)
|
||||||
|
return int(result.scalar() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_role_code(code: str) -> str:
|
||||||
|
value = str(code or "").strip().lower()
|
||||||
|
if not _ROLE_CODE_RE.match(value):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="角色码需为小写字母开头,仅含小写字母/数字/下划线,长度 2-50",
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def create_role(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
code: str,
|
||||||
|
label: str,
|
||||||
|
description: str | None,
|
||||||
|
permissions: list[str] | None,
|
||||||
|
) -> RoleRecord:
|
||||||
|
role_code = validate_role_code(code)
|
||||||
|
if get_cached_role(role_code) or (
|
||||||
|
await db.execute(select(Role).where(Role.code == role_code))
|
||||||
|
).scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="角色码已存在")
|
||||||
|
name = (label or "").strip() or role_code
|
||||||
|
perms = sanitize_role_permissions(permissions, force_all=False)
|
||||||
|
row = Role(
|
||||||
|
code=role_code,
|
||||||
|
label=name,
|
||||||
|
description=(description or "").strip() or None,
|
||||||
|
is_system=False,
|
||||||
|
is_admin=False,
|
||||||
|
permissions=_encode_permissions(perms),
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(row)
|
||||||
|
await refresh_role_cache(db)
|
||||||
|
return role_to_record(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_role(
|
||||||
|
db: AsyncSession,
|
||||||
|
code: str,
|
||||||
|
*,
|
||||||
|
label: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
permissions: list[str] | None = None,
|
||||||
|
) -> RoleRecord:
|
||||||
|
row = await get_role_or_404(db, code)
|
||||||
|
if row.is_admin or row.code == ROLE_ADMIN:
|
||||||
|
# Admin role always keeps full permissions; label/description may update.
|
||||||
|
if label is not None:
|
||||||
|
row.label = (label or "").strip() or row.label
|
||||||
|
if description is not None:
|
||||||
|
row.description = (description or "").strip() or None
|
||||||
|
row.permissions = _encode_permissions(list(ALL_PERMISSIONS))
|
||||||
|
row.is_admin = True
|
||||||
|
row.is_system = True
|
||||||
|
row.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(row)
|
||||||
|
await refresh_role_cache(db)
|
||||||
|
return role_to_record(row)
|
||||||
|
|
||||||
|
if label is not None:
|
||||||
|
row.label = (label or "").strip() or row.label
|
||||||
|
if description is not None:
|
||||||
|
row.description = (description or "").strip() or None
|
||||||
|
if permissions is not None:
|
||||||
|
row.permissions = _encode_permissions(
|
||||||
|
sanitize_role_permissions(permissions, force_all=False)
|
||||||
|
)
|
||||||
|
row.is_admin = False
|
||||||
|
row.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(row)
|
||||||
|
await refresh_role_cache(db)
|
||||||
|
return role_to_record(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_role(db: AsyncSession, code: str) -> None:
|
||||||
|
row = await get_role_or_404(db, code)
|
||||||
|
if row.is_system or row.is_admin or row.code == ROLE_ADMIN:
|
||||||
|
raise HTTPException(status_code=400, detail="系统内置角色不可删除")
|
||||||
|
used = await count_users_with_role(db, code)
|
||||||
|
if used > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"仍有 {used} 个用户使用该角色,请先调整用户角色后再删除",
|
||||||
|
)
|
||||||
|
await db.delete(row)
|
||||||
|
await db.commit()
|
||||||
|
await refresh_role_cache(db)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_role_assignable(db: AsyncSession, role_code: str) -> str:
|
||||||
|
"""Validate role exists in DB (refresh cache if needed)."""
|
||||||
|
code = str(role_code or "").strip()
|
||||||
|
if not code:
|
||||||
|
raise HTTPException(status_code=400, detail="角色不能为空")
|
||||||
|
if get_cached_role(code) is None:
|
||||||
|
await refresh_role_cache(db)
|
||||||
|
try:
|
||||||
|
return ensure_role(code)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def guard_last_admin_change(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
new_role: str | None = None,
|
||||||
|
deactivating: bool = False,
|
||||||
|
deleting: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Prevent removing the last admin user."""
|
||||||
|
if not is_admin(user.role):
|
||||||
|
return
|
||||||
|
admin_count = await count_admin_users(db)
|
||||||
|
if admin_count > 1:
|
||||||
|
return
|
||||||
|
if deleting or deactivating:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="不能删除或禁用最后一个管理员账号",
|
||||||
|
)
|
||||||
|
if new_role is not None and not is_admin(new_role):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="不能将最后一个管理员改为非管理员角色",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_permission_payload(role_code: str) -> dict:
|
||||||
|
record = get_cached_role(role_code)
|
||||||
|
admin = bool(record.is_admin) if record else is_admin(role_code)
|
||||||
|
from .roles import permissions_for_role
|
||||||
|
|
||||||
|
return {
|
||||||
|
"role_label": role_label(role_code),
|
||||||
|
"is_admin": admin,
|
||||||
|
"permissions": permissions_for_role(role_code),
|
||||||
|
}
|
||||||
@@ -1,5 +1,21 @@
|
|||||||
|
"""Role code helpers and an in-memory role registry backed by the roles table."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .permissions import (
|
||||||
|
ALL_PERMISSIONS,
|
||||||
|
DATA_SCOPE_ALL,
|
||||||
|
OPERATOR_PERMISSIONS,
|
||||||
|
VIEWER_PERMISSIONS,
|
||||||
|
WRITE_PERMISSIONS,
|
||||||
|
expand_legacy_bundles,
|
||||||
|
permission_implies,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ROLE_ADMIN = "admin"
|
ROLE_ADMIN = "admin"
|
||||||
ROLE_OPERATOR = "operator"
|
ROLE_OPERATOR = "operator"
|
||||||
ROLE_VIEWER = "viewer"
|
ROLE_VIEWER = "viewer"
|
||||||
@@ -13,19 +29,158 @@ ROLE_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_admin(role: str) -> bool:
|
@dataclass
|
||||||
return role == ROLE_ADMIN
|
class RoleRecord:
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
description: str = ""
|
||||||
|
is_system: bool = False
|
||||||
|
is_admin: bool = False
|
||||||
|
permissions: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def can_write(role: str) -> bool:
|
_ROLE_CACHE: dict[str, RoleRecord] = {}
|
||||||
return role in (ROLE_ADMIN, ROLE_OPERATOR)
|
|
||||||
|
|
||||||
|
|
||||||
def can_manage_users(role: str) -> bool:
|
def default_role_seeds() -> list[RoleRecord]:
|
||||||
return role == ROLE_ADMIN
|
return [
|
||||||
|
RoleRecord(
|
||||||
|
code=ROLE_ADMIN,
|
||||||
|
label=ROLE_LABELS[ROLE_ADMIN],
|
||||||
|
description="拥有全部菜单、按钮与全局数据权限",
|
||||||
|
is_system=True,
|
||||||
|
is_admin=True,
|
||||||
|
permissions=list(ALL_PERMISSIONS),
|
||||||
|
),
|
||||||
|
RoleRecord(
|
||||||
|
code=ROLE_OPERATOR,
|
||||||
|
label=ROLE_LABELS[ROLE_OPERATOR],
|
||||||
|
description="管理自己的账号、规则与私信(仅本人数据)",
|
||||||
|
is_system=True,
|
||||||
|
is_admin=False,
|
||||||
|
permissions=list(OPERATOR_PERMISSIONS),
|
||||||
|
),
|
||||||
|
RoleRecord(
|
||||||
|
code=ROLE_VIEWER,
|
||||||
|
label=ROLE_LABELS[ROLE_VIEWER],
|
||||||
|
description="仅查看自己的业务数据,不可修改",
|
||||||
|
is_system=True,
|
||||||
|
is_admin=False,
|
||||||
|
permissions=list(VIEWER_PERMISSIONS),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def set_role_cache(roles: Iterable[RoleRecord]) -> None:
|
||||||
|
global _ROLE_CACHE
|
||||||
|
_ROLE_CACHE = {role.code: role for role in roles}
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_role(code: str | None) -> RoleRecord | None:
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
return _ROLE_CACHE.get(str(code))
|
||||||
|
|
||||||
|
|
||||||
|
def list_cached_roles() -> list[RoleRecord]:
|
||||||
|
return list(_ROLE_CACHE.values())
|
||||||
|
|
||||||
|
|
||||||
|
def role_label(code: str | None) -> str:
|
||||||
|
role = get_cached_role(code)
|
||||||
|
if role:
|
||||||
|
return role.label
|
||||||
|
return ROLE_LABELS.get(str(code or ""), str(code or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(role: str | None) -> bool:
|
||||||
|
"""True for the built-in admin role (global admin flag)."""
|
||||||
|
record = get_cached_role(role)
|
||||||
|
if record is not None:
|
||||||
|
return bool(record.is_admin)
|
||||||
|
return str(role or "") == ROLE_ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
def has_global_scope(role: str | None) -> bool:
|
||||||
|
"""True when the role may see all users' data (admin or data.scope_all)."""
|
||||||
|
if is_admin(role):
|
||||||
|
return True
|
||||||
|
return has_permission(role, DATA_SCOPE_ALL)
|
||||||
|
|
||||||
|
|
||||||
|
def can_write(role: str | None) -> bool:
|
||||||
|
record = get_cached_role(role)
|
||||||
|
if record is not None:
|
||||||
|
if record.is_admin:
|
||||||
|
return True
|
||||||
|
held = expand_legacy_bundles(set(record.permissions))
|
||||||
|
return any(code in WRITE_PERMISSIONS for code in held)
|
||||||
|
return str(role or "") in (ROLE_ADMIN, ROLE_OPERATOR)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_users(role: str | None) -> bool:
|
||||||
|
return has_permission(role, "users.manage")
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_roles(role: str | None) -> bool:
|
||||||
|
return has_permission(role, "roles.manage")
|
||||||
|
|
||||||
|
|
||||||
|
def has_permission(role: str | None, permission: str) -> bool:
|
||||||
|
code = str(permission or "").strip()
|
||||||
|
if not code:
|
||||||
|
return False
|
||||||
|
record = get_cached_role(role)
|
||||||
|
if record is None:
|
||||||
|
if str(role or "") == ROLE_ADMIN:
|
||||||
|
return True
|
||||||
|
if str(role or "") == ROLE_OPERATOR:
|
||||||
|
return permission_implies(expand_legacy_bundles(set(OPERATOR_PERMISSIONS)), code)
|
||||||
|
if str(role or "") == ROLE_VIEWER:
|
||||||
|
return permission_implies(set(VIEWER_PERMISSIONS), code)
|
||||||
|
return False
|
||||||
|
if record.is_admin:
|
||||||
|
return True
|
||||||
|
held = expand_legacy_bundles(set(record.permissions))
|
||||||
|
return permission_implies(held, code)
|
||||||
|
|
||||||
|
|
||||||
|
def permissions_for_role(role: str | None) -> list[str]:
|
||||||
|
record = get_cached_role(role)
|
||||||
|
if record is None:
|
||||||
|
if str(role or "") == ROLE_ADMIN:
|
||||||
|
return list(ALL_PERMISSIONS)
|
||||||
|
if str(role or "") == ROLE_OPERATOR:
|
||||||
|
from .permissions import expand_paired_permissions
|
||||||
|
|
||||||
|
return expand_paired_permissions(OPERATOR_PERMISSIONS)
|
||||||
|
if str(role or "") == ROLE_VIEWER:
|
||||||
|
return list(VIEWER_PERMISSIONS)
|
||||||
|
return []
|
||||||
|
if record.is_admin:
|
||||||
|
return list(ALL_PERMISSIONS)
|
||||||
|
return list(record.permissions)
|
||||||
|
|
||||||
|
|
||||||
def ensure_role(role: str) -> str:
|
def ensure_role(role: str) -> str:
|
||||||
if role not in ALL_ROLES:
|
"""Validate that a role code exists (cache or built-in fallback)."""
|
||||||
raise ValueError(f"无效角色: {role}")
|
code = str(role or "").strip()
|
||||||
return role
|
if not code:
|
||||||
|
raise ValueError("角色不能为空")
|
||||||
|
if get_cached_role(code) is not None:
|
||||||
|
return code
|
||||||
|
if code in ALL_ROLES:
|
||||||
|
return code
|
||||||
|
raise ValueError(f"无效角色: {code}")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_role_permissions(
|
||||||
|
codes: list[str] | None,
|
||||||
|
*,
|
||||||
|
force_all: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
if force_all:
|
||||||
|
return list(ALL_PERMISSIONS)
|
||||||
|
from .permissions import expand_paired_permissions
|
||||||
|
|
||||||
|
return expand_paired_permissions(codes)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from .account_limits import (
|
|||||||
normalize_max_accounts,
|
normalize_max_accounts,
|
||||||
)
|
)
|
||||||
from .account_quota import default_stop_worker, sync_user_account_quota
|
from .account_quota import default_stop_worker, sync_user_account_quota
|
||||||
from .dependencies import get_current_user, require_user_manager
|
from .dependencies import get_current_user, require_role_manager, require_user_manager
|
||||||
from .email_service import (
|
from .email_service import (
|
||||||
build_password_reset_link,
|
build_password_reset_link,
|
||||||
build_verification_link,
|
build_verification_link,
|
||||||
@@ -25,16 +25,31 @@ from .email_verification import create_verification_token, mask_email, verify_em
|
|||||||
from .password_reset import create_password_reset_token, verify_password_reset_token
|
from .password_reset import create_password_reset_token, verify_password_reset_token
|
||||||
from .jwt_utils import create_access_token
|
from .jwt_utils import create_access_token
|
||||||
from .passwords import hash_password, verify_password
|
from .passwords import hash_password, verify_password
|
||||||
from .roles import ALL_ROLES, ROLE_LABELS, ROLE_OPERATOR, ensure_role, is_admin
|
from .permissions import permission_catalog
|
||||||
|
from .role_service import (
|
||||||
|
count_users_with_role,
|
||||||
|
create_role,
|
||||||
|
delete_role,
|
||||||
|
ensure_role_assignable,
|
||||||
|
guard_last_admin_change,
|
||||||
|
list_roles as list_role_records,
|
||||||
|
update_role,
|
||||||
|
user_permission_payload,
|
||||||
|
)
|
||||||
|
from .roles import ROLE_OPERATOR, is_admin
|
||||||
|
from .scopes import ensure_user_manageable, users_for_manager
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
ForgotPasswordRequest,
|
ForgotPasswordRequest,
|
||||||
ForgotPasswordResponse,
|
ForgotPasswordResponse,
|
||||||
|
PermissionCatalogResponse,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
ResendVerificationRequest,
|
ResendVerificationRequest,
|
||||||
ResetPasswordRequest,
|
ResetPasswordRequest,
|
||||||
|
RoleCreate,
|
||||||
|
RoleUpdate,
|
||||||
RolesResponse,
|
RolesResponse,
|
||||||
RoleInfo,
|
RoleInfo,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
@@ -52,6 +67,10 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|||||||
|
|
||||||
async def _build_user_response(db: AsyncSession, user: User, with_count: bool = False) -> UserResponse:
|
async def _build_user_response(db: AsyncSession, user: User, with_count: bool = False) -> UserResponse:
|
||||||
payload = UserResponse.model_validate(user)
|
payload = UserResponse.model_validate(user)
|
||||||
|
perm = user_permission_payload(user.role)
|
||||||
|
payload.role_label = perm["role_label"]
|
||||||
|
payload.is_admin = perm["is_admin"]
|
||||||
|
payload.permissions = perm["permissions"]
|
||||||
if with_count:
|
if with_count:
|
||||||
breakdown = await count_user_account_breakdown(db, user.id)
|
breakdown = await count_user_account_breakdown(db, user.id)
|
||||||
payload.account_count = breakdown["total"]
|
payload.account_count = breakdown["total"]
|
||||||
@@ -371,21 +390,40 @@ async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depe
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/roles", response_model=RolesResponse)
|
@router.get("/roles", response_model=RolesResponse)
|
||||||
async def list_roles(_: User = Depends(get_current_user)):
|
async def list_auth_roles(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Lightweight role list for dropdowns (any logged-in user).
|
||||||
|
|
||||||
|
Intentionally omits permission arrays to avoid leaking the full ACL map.
|
||||||
|
"""
|
||||||
|
records = await list_role_records(db)
|
||||||
return RolesResponse(
|
return RolesResponse(
|
||||||
roles=[RoleInfo(value=r, label=ROLE_LABELS.get(r, r)) for r in ALL_ROLES]
|
roles=[
|
||||||
|
RoleInfo(
|
||||||
|
value=item.code,
|
||||||
|
label=item.label,
|
||||||
|
description=None,
|
||||||
|
is_system=item.is_system,
|
||||||
|
is_admin=item.is_admin,
|
||||||
|
permissions=[],
|
||||||
|
)
|
||||||
|
for item in records
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
users_router = APIRouter(prefix="/api/users", tags=["users"])
|
users_router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
roles_router = APIRouter(prefix="/api/roles", tags=["roles"])
|
||||||
|
|
||||||
|
|
||||||
@users_router.get("", response_model=list[UserResponse])
|
@users_router.get("", response_model=list[UserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_user_manager),
|
current: User = Depends(require_user_manager),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(User).order_by(User.id.asc()))
|
result = await db.execute(users_for_manager(current).order_by(User.id.asc()))
|
||||||
users = result.scalars().all()
|
users = result.scalars().all()
|
||||||
responses = []
|
responses = []
|
||||||
for user in users:
|
for user in users:
|
||||||
@@ -397,16 +435,15 @@ async def list_users(
|
|||||||
async def create_user(
|
async def create_user(
|
||||||
body: UserCreate,
|
body: UserCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_user_manager),
|
current: User = Depends(require_user_manager),
|
||||||
):
|
):
|
||||||
settings = await load_settings(db)
|
settings = await load_settings(db)
|
||||||
exists = await db.execute(select(User).where(User.username == body.username))
|
exists = await db.execute(select(User).where(User.username == body.username))
|
||||||
if exists.scalar_one_or_none():
|
if exists.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
try:
|
role = await ensure_role_assignable(db, body.role)
|
||||||
role = ensure_role(body.role)
|
if is_admin(role) and not is_admin(current.role):
|
||||||
except ValueError as e:
|
raise HTTPException(status_code=403, detail="只有管理员可以分配管理员角色")
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
email = await _ensure_email_available(db, str(body.email) if body.email else None)
|
email = await _ensure_email_available(db, str(body.email) if body.email else None)
|
||||||
if settings.email_binding_required and not is_admin(role) and not email:
|
if settings.email_binding_required and not is_admin(role) and not email:
|
||||||
raise HTTPException(status_code=400, detail="系统已开启「登录必须绑定邮箱」,请填写邮箱")
|
raise HTTPException(status_code=400, detail="系统已开启「登录必须绑定邮箱」,请填写邮箱")
|
||||||
@@ -424,6 +461,7 @@ async def create_user(
|
|||||||
email_verified=email_verified,
|
email_verified=email_verified,
|
||||||
email_verified_at=datetime.utcnow() if email and email_verified else None,
|
email_verified_at=datetime.utcnow() if email and email_verified else None,
|
||||||
max_accounts=normalize_max_accounts(body.max_accounts, role),
|
max_accounts=normalize_max_accounts(body.max_accounts, role),
|
||||||
|
created_by=current.id,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -442,17 +480,25 @@ async def update_user(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
ensure_user_manageable(current, user)
|
||||||
settings = await load_settings(db)
|
settings = await load_settings(db)
|
||||||
if user.id == current.id and body.is_active is False:
|
if user.id == current.id and body.is_active is False:
|
||||||
raise HTTPException(status_code=400, detail="不能禁用当前登录账号")
|
raise HTTPException(status_code=400, detail="不能禁用当前登录账号")
|
||||||
|
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if body.is_active is False:
|
||||||
|
await guard_last_admin_change(db, user=user, deactivating=True)
|
||||||
|
if body.role is not None:
|
||||||
|
new_role = await ensure_role_assignable(db, body.role)
|
||||||
|
if is_admin(new_role) and not is_admin(current.role):
|
||||||
|
raise HTTPException(status_code=403, detail="只有管理员可以分配管理员角色")
|
||||||
|
await guard_last_admin_change(db, user=user, new_role=new_role)
|
||||||
|
|
||||||
if body.display_name is not None:
|
if body.display_name is not None:
|
||||||
user.display_name = body.display_name
|
user.display_name = body.display_name
|
||||||
if body.role is not None:
|
if body.role is not None:
|
||||||
prev_role = user.role
|
prev_role = user.role
|
||||||
try:
|
user.role = await ensure_role_assignable(db, body.role)
|
||||||
user.role = ensure_role(body.role)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
if is_admin(user.role):
|
if is_admin(user.role):
|
||||||
user.max_accounts = UNLIMITED_ACCOUNTS
|
user.max_accounts = UNLIMITED_ACCOUNTS
|
||||||
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
|
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
|
||||||
@@ -465,7 +511,6 @@ async def update_user(
|
|||||||
if body.password:
|
if body.password:
|
||||||
user.password_hash = hash_password(body.password)
|
user.password_hash = hash_password(body.password)
|
||||||
|
|
||||||
updates = body.model_dump(exclude_unset=True)
|
|
||||||
if "max_accounts" in updates and not is_admin(user.role):
|
if "max_accounts" in updates and not is_admin(user.role):
|
||||||
user.max_accounts = normalize_max_accounts(updates["max_accounts"], user.role)
|
user.max_accounts = normalize_max_accounts(updates["max_accounts"], user.role)
|
||||||
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
|
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
|
||||||
@@ -506,6 +551,94 @@ async def delete_user(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
ensure_user_manageable(current, user)
|
||||||
|
await guard_last_admin_change(db, user=user, deleting=True)
|
||||||
await db.delete(user)
|
await db.delete(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"message": "用户已删除"}
|
return {"message": "用户已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
@roles_router.get("", response_model=RolesResponse)
|
||||||
|
async def admin_list_roles(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_role_manager),
|
||||||
|
):
|
||||||
|
records = await list_role_records(db)
|
||||||
|
roles = []
|
||||||
|
for item in records:
|
||||||
|
roles.append(
|
||||||
|
RoleInfo(
|
||||||
|
value=item.code,
|
||||||
|
label=item.label,
|
||||||
|
description=item.description or None,
|
||||||
|
is_system=item.is_system,
|
||||||
|
is_admin=item.is_admin,
|
||||||
|
permissions=list(item.permissions),
|
||||||
|
user_count=await count_users_with_role(db, item.code),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return RolesResponse(roles=roles)
|
||||||
|
|
||||||
|
|
||||||
|
@roles_router.get("/catalog", response_model=PermissionCatalogResponse)
|
||||||
|
async def get_permission_catalog(_: User = Depends(require_role_manager)):
|
||||||
|
return PermissionCatalogResponse(**permission_catalog())
|
||||||
|
|
||||||
|
|
||||||
|
@roles_router.post("", response_model=RoleInfo)
|
||||||
|
async def create_custom_role(
|
||||||
|
body: RoleCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_role_manager),
|
||||||
|
):
|
||||||
|
record = await create_role(
|
||||||
|
db,
|
||||||
|
code=body.code,
|
||||||
|
label=body.label,
|
||||||
|
description=body.description,
|
||||||
|
permissions=body.permissions,
|
||||||
|
)
|
||||||
|
return RoleInfo(
|
||||||
|
value=record.code,
|
||||||
|
label=record.label,
|
||||||
|
description=record.description or None,
|
||||||
|
is_system=record.is_system,
|
||||||
|
is_admin=record.is_admin,
|
||||||
|
permissions=list(record.permissions),
|
||||||
|
user_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@roles_router.put("/{code}", response_model=RoleInfo)
|
||||||
|
async def update_custom_role(
|
||||||
|
code: str,
|
||||||
|
body: RoleUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_role_manager),
|
||||||
|
):
|
||||||
|
record = await update_role(
|
||||||
|
db,
|
||||||
|
code,
|
||||||
|
label=body.label,
|
||||||
|
description=body.description,
|
||||||
|
permissions=body.permissions,
|
||||||
|
)
|
||||||
|
return RoleInfo(
|
||||||
|
value=record.code,
|
||||||
|
label=record.label,
|
||||||
|
description=record.description or None,
|
||||||
|
is_system=record.is_system,
|
||||||
|
is_admin=record.is_admin,
|
||||||
|
permissions=list(record.permissions),
|
||||||
|
user_count=await count_users_with_role(db, record.code),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@roles_router.delete("/{code}")
|
||||||
|
async def delete_custom_role(
|
||||||
|
code: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_role_manager),
|
||||||
|
):
|
||||||
|
await delete_role(db, code)
|
||||||
|
return {"message": "角色已删除"}
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class UserResponse(BaseModel):
|
|||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
display_name: Optional[str] = None
|
display_name: Optional[str] = None
|
||||||
role: str
|
role: str
|
||||||
|
role_label: str = ""
|
||||||
|
is_admin: bool = False
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
is_active: bool
|
is_active: bool
|
||||||
email_verified: bool = False
|
email_verified: bool = False
|
||||||
max_accounts: int = 3
|
max_accounts: int = 3
|
||||||
@@ -102,7 +105,33 @@ class UserUpdate(BaseModel):
|
|||||||
class RoleInfo(BaseModel):
|
class RoleInfo(BaseModel):
|
||||||
value: str
|
value: str
|
||||||
label: str
|
label: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_system: bool = False
|
||||||
|
is_admin: bool = False
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
user_count: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class RolesResponse(BaseModel):
|
class RolesResponse(BaseModel):
|
||||||
roles: list[RoleInfo]
|
roles: list[RoleInfo]
|
||||||
|
|
||||||
|
|
||||||
|
class RoleCreate(BaseModel):
|
||||||
|
code: str = Field(min_length=2, max_length=50)
|
||||||
|
label: str = Field(min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleUpdate(BaseModel):
|
||||||
|
label: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
permissions: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionCatalogResponse(BaseModel):
|
||||||
|
menus: list[dict[str, Any]]
|
||||||
|
actions: list[dict[str, Any]]
|
||||||
|
data: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
tree: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
pairs: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ from sqlalchemy import or_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.models import Account, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User
|
from models.models import Account, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User
|
||||||
from .roles import is_admin
|
from .permissions import (
|
||||||
|
ACCOUNTS_UPDATE,
|
||||||
|
ACCOUNTS_WRITE_GRANULAR,
|
||||||
|
RULES_WRITE,
|
||||||
|
)
|
||||||
|
from .roles import has_global_scope, has_permission, is_admin
|
||||||
|
|
||||||
|
|
||||||
async def get_owned_account(
|
async def get_owned_account(
|
||||||
@@ -14,29 +19,42 @@ async def get_owned_account(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
*,
|
*,
|
||||||
write: bool = False,
|
write: bool = False,
|
||||||
|
write_permission: str | None = None,
|
||||||
) -> Account:
|
) -> Account:
|
||||||
result = await db.execute(select(Account).where(Account.id == account_id))
|
result = await db.execute(select(Account).where(Account.id == account_id))
|
||||||
account = result.scalar_one_or_none()
|
account = result.scalar_one_or_none()
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
|
||||||
if is_admin(user.role):
|
if has_global_scope(user.role):
|
||||||
|
if write:
|
||||||
|
needed = write_permission or ACCOUNTS_UPDATE
|
||||||
|
if not has_permission(user.role, needed):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"缺少权限:{needed}",
|
||||||
|
)
|
||||||
return account
|
return account
|
||||||
if account.owner_id != user.id:
|
if account.owner_id != user.id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该账号")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该账号")
|
||||||
if write and user.role == "viewer":
|
if write:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
|
needed = write_permission or ACCOUNTS_UPDATE
|
||||||
|
if not has_permission(user.role, needed):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"缺少权限:{needed}",
|
||||||
|
)
|
||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
||||||
def accounts_for_user(user: User):
|
def accounts_for_user(user: User):
|
||||||
stmt = select(Account)
|
stmt = select(Account)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
stmt = stmt.where(Account.owner_id == user.id)
|
stmt = stmt.where(Account.owner_id == user.id)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
async def owned_account_ids(db: AsyncSession, user: User) -> Optional[set[int]]:
|
async def owned_account_ids(db: AsyncSession, user: User) -> Optional[set[int]]:
|
||||||
if is_admin(user.role):
|
if has_global_scope(user.role):
|
||||||
return None
|
return None
|
||||||
result = await db.execute(select(Account.id).where(Account.owner_id == user.id))
|
result = await db.execute(select(Account.id).where(Account.owner_id == user.id))
|
||||||
return {row[0] for row in result.all()}
|
return {row[0] for row in result.all()}
|
||||||
@@ -46,7 +64,7 @@ def logs_for_user(user: User, account_id: Optional[int] = None):
|
|||||||
stmt = select(MessageLog)
|
stmt = select(MessageLog)
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
stmt = stmt.where(MessageLog.account_id == account_id)
|
stmt = stmt.where(MessageLog.account_id == account_id)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||||
stmt = stmt.where(MessageLog.account_id.in_(owned))
|
stmt = stmt.where(MessageLog.account_id.in_(owned))
|
||||||
return stmt
|
return stmt
|
||||||
@@ -56,7 +74,7 @@ def received_logs_for_user(user: User, account_id: Optional[int] = None):
|
|||||||
stmt = select(ReceivedMessageLog)
|
stmt = select(ReceivedMessageLog)
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
stmt = stmt.where(ReceivedMessageLog.account_id == account_id)
|
stmt = stmt.where(ReceivedMessageLog.account_id == account_id)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||||
stmt = stmt.where(ReceivedMessageLog.account_id.in_(owned))
|
stmt = stmt.where(ReceivedMessageLog.account_id.in_(owned))
|
||||||
return stmt
|
return stmt
|
||||||
@@ -66,31 +84,33 @@ def rules_for_user(user: User, account_id: Optional[int] = None):
|
|||||||
stmt = select(AutoReplyRule)
|
stmt = select(AutoReplyRule)
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
stmt = stmt.where(AutoReplyRule.account_id == account_id)
|
stmt = stmt.where(AutoReplyRule.account_id == account_id)
|
||||||
if is_admin(user.role):
|
if has_global_scope(user.role):
|
||||||
return stmt
|
return stmt
|
||||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||||
return stmt.where(AutoReplyRule.account_id.in_(owned))
|
return stmt.where(AutoReplyRule.account_id.in_(owned))
|
||||||
|
|
||||||
|
|
||||||
async def get_accessible_rule(db: AsyncSession, user: User, rule_id: int, *, write: bool = False) -> AutoReplyRule:
|
async def get_accessible_rule(
|
||||||
|
db: AsyncSession, user: User, rule_id: int, *, write: bool = False
|
||||||
|
) -> AutoReplyRule:
|
||||||
result = await db.execute(select(AutoReplyRule).where(AutoReplyRule.id == rule_id))
|
result = await db.execute(select(AutoReplyRule).where(AutoReplyRule.id == rule_id))
|
||||||
rule = result.scalar_one_or_none()
|
rule = result.scalar_one_or_none()
|
||||||
if not rule:
|
if not rule:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="规则不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="规则不存在")
|
||||||
|
|
||||||
if is_admin(user.role):
|
if has_global_scope(user.role):
|
||||||
if write and user.role == "viewer":
|
if write and not has_permission(user.role, RULES_WRITE):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="缺少权限:rules.write")
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
if rule.account_id is None:
|
if rule.account_id is None:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问全局规则")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问全局规则")
|
||||||
|
|
||||||
account = await get_owned_account(db, user, rule.account_id, write=write)
|
account = await get_owned_account(db, user, rule.account_id, write=False)
|
||||||
if rule.owner_id and rule.owner_id != user.id and account.owner_id != user.id:
|
if rule.owner_id and rule.owner_id != user.id and account.owner_id != user.id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该规则")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该规则")
|
||||||
if write and user.role == "viewer":
|
if write and not has_permission(user.role, RULES_WRITE):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="缺少权限:rules.write")
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|
||||||
@@ -98,7 +118,7 @@ def system_logs_for_user(user: User, account_id: Optional[int] = None):
|
|||||||
stmt = select(SystemLog)
|
stmt = select(SystemLog)
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
stmt = stmt.where(SystemLog.account_id == account_id)
|
stmt = stmt.where(SystemLog.account_id == account_id)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
@@ -107,3 +127,25 @@ def system_logs_for_user(user: User, account_id: Optional[int] = None):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def users_for_manager(manager: User):
|
||||||
|
"""Admins see all users; others only see themselves and users they created."""
|
||||||
|
stmt = select(User)
|
||||||
|
if is_admin(manager.role):
|
||||||
|
return stmt
|
||||||
|
return stmt.where(or_(User.created_by == manager.id, User.id == manager.id))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_user_manageable(manager: User, target: User) -> None:
|
||||||
|
"""Raise 403 unless manager may edit/delete target user."""
|
||||||
|
if is_admin(manager.role):
|
||||||
|
return
|
||||||
|
if target.id == manager.id:
|
||||||
|
return
|
||||||
|
if target.created_by == manager.id:
|
||||||
|
return
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="只能管理自己创建的用户",
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from models.db_config import (
|
|||||||
)
|
)
|
||||||
from models.db_transfer import inspect_sqlite_source, migrate_sqlite_to_target
|
from models.db_transfer import inspect_sqlite_source, migrate_sqlite_to_target
|
||||||
from models.models import User
|
from models.models import User
|
||||||
from .dependencies import require_admin
|
from .dependencies import require_permission
|
||||||
|
from .permissions import PAYMENTS_MANAGE, SETTINGS_DATABASE, SETTINGS_MANAGE
|
||||||
from .email_service import send_test_email
|
from .email_service import send_test_email
|
||||||
from .system_settings import (
|
from .system_settings import (
|
||||||
PASSWORD_PLACEHOLDER,
|
PASSWORD_PLACEHOLDER,
|
||||||
@@ -208,7 +209,7 @@ async def get_public_settings(db: AsyncSession = Depends(get_db)):
|
|||||||
@router.get("", response_model=SystemSettingsResponse)
|
@router.get("", response_model=SystemSettingsResponse)
|
||||||
async def get_system_settings(
|
async def get_system_settings(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(SETTINGS_MANAGE)),
|
||||||
):
|
):
|
||||||
data = await load_settings(db)
|
data = await load_settings(db)
|
||||||
return SystemSettingsResponse(**settings_to_admin_response(data))
|
return SystemSettingsResponse(**settings_to_admin_response(data))
|
||||||
@@ -218,7 +219,7 @@ async def get_system_settings(
|
|||||||
async def update_system_settings(
|
async def update_system_settings(
|
||||||
body: SystemSettingsUpdate,
|
body: SystemSettingsUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(SETTINGS_MANAGE)),
|
||||||
):
|
):
|
||||||
updates = body.model_dump(exclude_unset=True)
|
updates = body.model_dump(exclude_unset=True)
|
||||||
if "app_url" in updates and updates["app_url"]:
|
if "app_url" in updates and updates["app_url"]:
|
||||||
@@ -230,7 +231,7 @@ async def update_system_settings(
|
|||||||
@router.get("/payment", response_model=PaymentSettingsResponse)
|
@router.get("/payment", response_model=PaymentSettingsResponse)
|
||||||
async def get_payment_settings(
|
async def get_payment_settings(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(PAYMENTS_MANAGE)),
|
||||||
):
|
):
|
||||||
data = await load_settings(db)
|
data = await load_settings(db)
|
||||||
return PaymentSettingsResponse(**settings_to_payment_response(data))
|
return PaymentSettingsResponse(**settings_to_payment_response(data))
|
||||||
@@ -240,7 +241,7 @@ async def get_payment_settings(
|
|||||||
async def update_payment_settings(
|
async def update_payment_settings(
|
||||||
body: PaymentSettingsUpdate,
|
body: PaymentSettingsUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(PAYMENTS_MANAGE)),
|
||||||
):
|
):
|
||||||
updates = body.model_dump(exclude_unset=True)
|
updates = body.model_dump(exclude_unset=True)
|
||||||
data = await save_settings(db, updates)
|
data = await save_settings(db, updates)
|
||||||
@@ -251,7 +252,7 @@ async def update_payment_settings(
|
|||||||
async def test_smtp_email(
|
async def test_smtp_email(
|
||||||
body: TestEmailRequest,
|
body: TestEmailRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(SETTINGS_MANAGE)),
|
||||||
):
|
):
|
||||||
data = await load_settings(db)
|
data = await load_settings(db)
|
||||||
overrides = body.model_dump(exclude_unset=True, exclude={"to_email"})
|
overrides = body.model_dump(exclude_unset=True, exclude={"to_email"})
|
||||||
@@ -276,14 +277,14 @@ async def test_smtp_email(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/database", response_model=DatabaseSettingsResponse)
|
@router.get("/database", response_model=DatabaseSettingsResponse)
|
||||||
async def get_database_settings(_: User = Depends(require_admin)):
|
async def get_database_settings(_: User = Depends(require_permission(SETTINGS_DATABASE))):
|
||||||
return DatabaseSettingsResponse(**database_config_to_response())
|
return DatabaseSettingsResponse(**database_config_to_response())
|
||||||
|
|
||||||
|
|
||||||
@router.put("/database", response_model=MessageResponse)
|
@router.put("/database", response_model=MessageResponse)
|
||||||
async def update_database_settings(
|
async def update_database_settings(
|
||||||
body: DatabaseSettingsUpdate,
|
body: DatabaseSettingsUpdate,
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(SETTINGS_DATABASE)),
|
||||||
):
|
):
|
||||||
payload = body.model_dump(exclude_unset=True)
|
payload = body.model_dump(exclude_unset=True)
|
||||||
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
|
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
|
||||||
@@ -302,7 +303,7 @@ async def update_database_settings(
|
|||||||
@router.post("/database/test", response_model=MessageResponse)
|
@router.post("/database/test", response_model=MessageResponse)
|
||||||
async def test_database_settings(
|
async def test_database_settings(
|
||||||
body: DatabaseTestRequest,
|
body: DatabaseTestRequest,
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(SETTINGS_DATABASE)),
|
||||||
):
|
):
|
||||||
payload = body.model_dump(exclude_unset=True)
|
payload = body.model_dump(exclude_unset=True)
|
||||||
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
|
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
|
||||||
@@ -317,7 +318,7 @@ async def test_database_settings(
|
|||||||
@router.get("/database/migrate/preview", response_model=DatabaseMigratePreviewResponse)
|
@router.get("/database/migrate/preview", response_model=DatabaseMigratePreviewResponse)
|
||||||
async def preview_database_migration(
|
async def preview_database_migration(
|
||||||
source_db_path: str | None = None,
|
source_db_path: str | None = None,
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(SETTINGS_DATABASE)),
|
||||||
):
|
):
|
||||||
return DatabaseMigratePreviewResponse(**await inspect_sqlite_source(source_db_path))
|
return DatabaseMigratePreviewResponse(**await inspect_sqlite_source(source_db_path))
|
||||||
|
|
||||||
@@ -325,7 +326,7 @@ async def preview_database_migration(
|
|||||||
@router.post("/database/migrate", response_model=DatabaseMigrateResponse)
|
@router.post("/database/migrate", response_model=DatabaseMigrateResponse)
|
||||||
async def migrate_database_data(
|
async def migrate_database_data(
|
||||||
body: DatabaseMigrateRequest,
|
body: DatabaseMigrateRequest,
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(SETTINGS_DATABASE)),
|
||||||
):
|
):
|
||||||
payload = body.model_dump(exclude_unset=True)
|
payload = body.model_dump(exclude_unset=True)
|
||||||
clear_target = bool(payload.pop("clear_target", False))
|
clear_target = bool(payload.pop("clear_target", False))
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,855 @@
|
|||||||
|
{
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"url": "https://www.douyin.com/",
|
||||||
|
"qrcodes": [
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "AXNt5Hoz",
|
||||||
|
"outer": "<img class=\"AXNt5Hoz\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "Wzqh8kMJ",
|
||||||
|
"outer": "<img class=\"Wzqh8kMJ\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "MaDupF4a",
|
||||||
|
"outer": "<img class=\"MaDupF4a\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "RSP3dVtx",
|
||||||
|
"outer": "<img class=\"RSP3dVtx\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "M3dFOzE4",
|
||||||
|
"outer": "<img class=\"M3dFOzE4\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "sB_GUV4n",
|
||||||
|
"outer": "<img class=\"sB_GUV4n\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "BD9BarA8",
|
||||||
|
"outer": "<img class=\"BD9BarA8\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "jMPyhzfG",
|
||||||
|
"outer": "<img class=\"jMPyhzfG\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "efPPcdLl",
|
||||||
|
"outer": "<img class=\"efPPcdLl\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "RnpNMA46",
|
||||||
|
"outer": "<img class=\"RnpNMA46\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "FqRV7w1P",
|
||||||
|
"outer": "<img class=\"FqRV7w1P\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "_Whzlv1b",
|
||||||
|
"outer": "<img class=\"_Whzlv1b\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "j1TwxzPC",
|
||||||
|
"outer": "<img class=\"j1TwxzPC\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "zYgniQaG",
|
||||||
|
"outer": "<img class=\"zYgniQaG\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "uJKU1tdN",
|
||||||
|
"outer": "<img class=\"uJKU1tdN\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "HkY6seUs",
|
||||||
|
"outer": "<img class=\"HkY6seUs\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "khDBMSjy",
|
||||||
|
"outer": "<img class=\"khDBMSjy\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "GkION2OQ",
|
||||||
|
"outer": "<img class=\"GkION2OQ\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "q2uupcgz",
|
||||||
|
"outer": "<img class=\"q2uupcgz\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40
|
||||||
|
},
|
||||||
|
"src_prefix": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "n6fjbOcQ",
|
||||||
|
"outer": "<img class=\"n6fjbOcQ\" src=\"https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png\">",
|
||||||
|
"screenshot": "frame0_IMG_16_58.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 539.328125,
|
||||||
|
"y": 108,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375
|
||||||
|
},
|
||||||
|
"src_prefix": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_ea6e0dc453683550de835cc2ded929b0~tplv-dy-resize-walign-adapt-aq:540:q7",
|
||||||
|
"alt": "法国搞笑三人组新作,结尾太好笑了 法国喜剧#电影长尾豹马修 #喜剧电影解说",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"outer": "<img src=\"https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_ea6e0dc453683550de835cc2ded929b0~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN_WEB_NEW_PAGE&sc=cover&se=false&x-expires=1789030800&x-signatu",
|
||||||
|
"screenshot": "frame0_IMG_539_108.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 894.65625,
|
||||||
|
"y": 108,
|
||||||
|
"width": 339.34375,
|
||||||
|
"height": 190.875
|
||||||
|
},
|
||||||
|
"src_prefix": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_dfc1793c444e4e9731b08c24d409cfff~tplv-dy-resize-walign-adapt-aq:540:q7",
|
||||||
|
"alt": "你我怎么两清……#戴上耳机 #甲乙丙丁 #李佳薇 #音乐分享",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"outer": "<img src=\"https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_dfc1793c444e4e9731b08c24d409cfff~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN_WEB_NEW_PAGE&sc=cover&se=false&x-expires=1789030800&x-signatu",
|
||||||
|
"screenshot": "frame0_IMG_894_108.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 184,
|
||||||
|
"y": 412.875,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375
|
||||||
|
},
|
||||||
|
"src_prefix": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_032fb5c8e6ddd4703e115f1139fe01cf~tplv-dy-resize-walign-adapt-aq:540:q7",
|
||||||
|
"alt": "被外卖大哥不小心蹭了车,但没想到他的手机铃声竟然是我的歌…但也正因如此我才有幸走进了一个父与子的故事里#人间观察计划#外卖小哥 #看见100种生活#日常分享 #雪下的时候",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"outer": "<img src=\"https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_032fb5c8e6ddd4703e115f1139fe01cf~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN_WEB_NEW_PAGE&sc=cover&se=false&x-expires=1789030800&x-signatu",
|
||||||
|
"screenshot": "frame0_IMG_184_412.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 539.328125,
|
||||||
|
"y": 412.875,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375
|
||||||
|
},
|
||||||
|
"src_prefix": "https://p9-pc-sign.douyinpic.com/tos-cn-i-dy/ef60f35dda7a4b1396df4b5b5abfb632~tplv-dy-vqe2-sr-opt1:640:480:q80.webp?from",
|
||||||
|
"alt": "一口气听完当年火遍全网的说唱,谁的DNA动了#中文说唱 #马思唯 #kkluv #创作者扶持计划 #抖音精选",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"outer": "<img src=\"https://p9-pc-sign.douyinpic.com/tos-cn-i-dy/ef60f35dda7a4b1396df4b5b5abfb632~tplv-dy-vqe2-sr-opt1:640:480:q80.webp?from=1189464143&lk3s=46e5c84f&x-expires=1788685200&x-signature=YYrBL5uYHi0RBKP8Do8J0iMGHms%3D\" alt=\"一口气听完当年火遍全网的说唱,谁的DNA动了#中文说唱 #马思唯 #kkluv #创作者扶持计划 #抖音精选\" class=",
|
||||||
|
"screenshot": "frame0_IMG_539_412.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 894.65625,
|
||||||
|
"y": 412.875,
|
||||||
|
"width": 339.34375,
|
||||||
|
"height": 190.875
|
||||||
|
},
|
||||||
|
"src_prefix": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_d4d6aace7531ed9cbd364c4313a21352~tplv-dy-resize-walign-adapt-aq:540:q7",
|
||||||
|
"alt": "当你穿进老钱班33#老钱班 #侯绿萝#olly懂你漂亮做自己 #olly女性复合维生素",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"outer": "<img src=\"https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_d4d6aace7531ed9cbd364c4313a21352~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN_WEB_NEW_PAGE&sc=cover&se=false&x-expires=1789030800&x-signatu",
|
||||||
|
"screenshot": "frame0_IMG_894_412.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 184,
|
||||||
|
"y": 717.75,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375
|
||||||
|
},
|
||||||
|
"src_prefix": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_49bbd3ffa13175f85795123107c70169~tplv-dy-resize-walign-adapt-aq:540:q7",
|
||||||
|
"alt": "轮回神话5 女儿试炼误入绝境,获S级血统轰动全宇宙!探秘禁忌陵宫,竟发现横扫万界的创世神正是自家咸鱼老爸!#原创动画 #二次元 #剧情 #反转 #扮猪吃虎名场面",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"outer": "<img src=\"https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_49bbd3ffa13175f85795123107c70169~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN_WEB_NEW_PAGE&sc=cover&se=false&x-expires=1789030800&x-signatu",
|
||||||
|
"screenshot": "frame0_IMG_184_717.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 539.328125,
|
||||||
|
"y": 708.75,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375
|
||||||
|
},
|
||||||
|
"src_prefix": "https://p3-pc-sign.douyinpic.com/image-cut-tos/70d71f59a2bab8ab8a238f9276777e7a~tplv-dy-vqe2-sr-opt1:640:480:q80.webp?fr",
|
||||||
|
"alt": "深度解析《大明王朝1566》 明成祖朱棣定下的锦衣卫选拔标准,一般人还真达不到#大明王朝1566 #历史",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"outer": "<img src=\"https://p3-pc-sign.douyinpic.com/image-cut-tos/70d71f59a2bab8ab8a238f9276777e7a~tplv-dy-vqe2-sr-opt1:640:480:q80.webp?from=1189464143&lk3s=46e5c84f&x-expires=1788685200&x-signature=vsIbNW%2BtgYYmFILlWMn38utpuzg%3D\" alt=\"深度解析《大明王朝1566》 明成祖朱棣定下的锦衣卫选拔标准,一般人还真达不到#大明王朝1566 #历史\" clas",
|
||||||
|
"screenshot": "frame0_IMG_539_708.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 894.65625,
|
||||||
|
"y": 708.75,
|
||||||
|
"width": 339.34375,
|
||||||
|
"height": 190.875
|
||||||
|
},
|
||||||
|
"src_prefix": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_61db88f71bdba90e436a783bae92863f~tplv-dy-resize-walign-adapt-aq:540:q7",
|
||||||
|
"alt": "当大哥不接暗号,鼠鼠带着九格强行认大哥会发生什么呢? #三角洲行动 #三角洲得吃就行挑战 #鼠鼠我呀得吃了 #三角洲最仁义玩家 #洲人洲事",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"outer": "<img src=\"https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_61db88f71bdba90e436a783bae92863f~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN_WEB_NEW_PAGE&sc=cover&se=false&x-expires=1789030800&x-signatu",
|
||||||
|
"screenshot": "frame0_IMG_894_708.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "img",
|
||||||
|
"tag": "IMG",
|
||||||
|
"visible": true,
|
||||||
|
"box": {
|
||||||
|
"x": 184,
|
||||||
|
"y": 1013.625,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375
|
||||||
|
},
|
||||||
|
"src_prefix": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_17d53951c4fb7c73dbfa5acddfde35a3~tplv-dy-resize-walign-adapt-aq:540:q7",
|
||||||
|
"alt": "本想应付体验大学生活的表弟,不料竟意外发现表弟的万能用处 #搞笑 #动漫 #轻漫计划 #充能计划",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"outer": "<img src=\"https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_17d53951c4fb7c73dbfa5acddfde35a3~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN_WEB_NEW_PAGE&sc=cover&se=false&x-expires=1789030800&x-signatu",
|
||||||
|
"screenshot": "frame0_IMG_184_1013.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"panels": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"url": "https://lf-rc1.yhgfb-cn-static.com/obj/rc-verifycenter/rmc-nocaptcha/1.0.0.50/index.html",
|
||||||
|
"qrcodes": [],
|
||||||
|
"panels": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 0,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "AXNt5Hoz",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 1,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "Wzqh8kMJ",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 2,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "MaDupF4a",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 3,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "RSP3dVtx",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 4,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "M3dFOzE4",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 5,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "sB_GUV4n",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 6,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "BD9BarA8",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 7,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app.beb4a83a0d39bb5d.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "jMPyhzfG",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 8,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "efPPcdLl",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 9,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/get_app_hover.c9e81f511d248ae7.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "RnpNMA46",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 10,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "FqRV7w1P",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 11,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "_Whzlv1b",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 12,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "j1TwxzPC",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 13,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "zYgniQaG",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 14,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "uJKU1tdN",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 15,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "HkY6seUs",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 16,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "khDBMSjy",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 17,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app.795ee19b3ececa6b.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "GkION2OQ",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 18,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "q2uupcgz",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 19,
|
||||||
|
"width": 128,
|
||||||
|
"height": 40,
|
||||||
|
"x": 16,
|
||||||
|
"y": 58,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/fold_get_app_hover.911c3dd97d692c11.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "n6fjbOcQ",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 20,
|
||||||
|
"width": 128,
|
||||||
|
"height": 123,
|
||||||
|
"x": 16,
|
||||||
|
"y": 701,
|
||||||
|
"src": "https://lf-douyin-pc-web.douyinstatic.com/obj/douyin-pc-web/ies/douyin_web/media/jxBtnBgV4.4405b8dd83623e92.png",
|
||||||
|
"alt": "",
|
||||||
|
"class": "ACBHzWNP",
|
||||||
|
"parentText": "手机随时看更方便\n下载 APP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 27,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375,
|
||||||
|
"x": 184,
|
||||||
|
"y": -343,
|
||||||
|
"src": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_32cb3c82390459d1d91bd4f30c5d8ce7~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN",
|
||||||
|
"alt": "【清稚竹马】我还想说,我想你了!#ai漫剧 #原创动画 #漫剧 #校园",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 28,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375,
|
||||||
|
"x": 539.328125,
|
||||||
|
"y": -343,
|
||||||
|
"src": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_ea6e0dc453683550de835cc2ded929b0~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN",
|
||||||
|
"alt": "法国搞笑三人组新作,结尾太好笑了 法国喜剧#电影长尾豹马修 #喜剧电影解说",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 29,
|
||||||
|
"width": 339.34375,
|
||||||
|
"height": 190.875,
|
||||||
|
"x": 894.65625,
|
||||||
|
"y": -343,
|
||||||
|
"src": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_dfc1793c444e4e9731b08c24d409cfff~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN",
|
||||||
|
"alt": "你我怎么两清……#戴上耳机 #甲乙丙丁 #李佳薇 #音乐分享",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 30,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375,
|
||||||
|
"x": 184,
|
||||||
|
"y": -38.125,
|
||||||
|
"src": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_032fb5c8e6ddd4703e115f1139fe01cf~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN",
|
||||||
|
"alt": "被外卖大哥不小心蹭了车,但没想到他的手机铃声竟然是我的歌…但也正因如此我才有幸走进了一个父与子的故事里#人间观察计划#外卖小哥 #看见100种生活#日常分享 #雪下的时候",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 31,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375,
|
||||||
|
"x": 539.328125,
|
||||||
|
"y": -38.125,
|
||||||
|
"src": "https://p9-pc-sign.douyinpic.com/tos-cn-i-dy/ef60f35dda7a4b1396df4b5b5abfb632~tplv-dy-vqe2-sr-opt1:640:480:q80.webp?from=1189464143&lk3s=46e5c84f&x-expires=1788685200&x-signature=YYrBL5uYHi0RBKP8Do8J0",
|
||||||
|
"alt": "一口气听完当年火遍全网的说唱,谁的DNA动了#中文说唱 #马思唯 #kkluv #创作者扶持计划 #抖音精选",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 32,
|
||||||
|
"width": 339.34375,
|
||||||
|
"height": 190.875,
|
||||||
|
"x": 894.65625,
|
||||||
|
"y": -38.125,
|
||||||
|
"src": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_d4d6aace7531ed9cbd364c4313a21352~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN",
|
||||||
|
"alt": "当你穿进老钱班33#老钱班 #侯绿萝#olly懂你漂亮做自己 #olly女性复合维生素",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 33,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375,
|
||||||
|
"x": 184,
|
||||||
|
"y": 266.75,
|
||||||
|
"src": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_49bbd3ffa13175f85795123107c70169~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN",
|
||||||
|
"alt": "轮回神话5 女儿试炼误入绝境,获S级血统轰动全宇宙!探秘禁忌陵宫,竟发现横扫万界的创世神正是自家咸鱼老爸!#原创动画 #二次元 #剧情 #反转 #扮猪吃虎名场面",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 34,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375,
|
||||||
|
"x": 539.328125,
|
||||||
|
"y": 266.75,
|
||||||
|
"src": "https://p3-pc-sign.douyinpic.com/image-cut-tos/70d71f59a2bab8ab8a238f9276777e7a~tplv-dy-vqe2-sr-opt1:640:480:q80.webp?from=1189464143&lk3s=46e5c84f&x-expires=1788685200&x-signature=vsIbNW%2BtgYYmFILlW",
|
||||||
|
"alt": "深度解析《大明王朝1566》 明成祖朱棣定下的锦衣卫选拔标准,一般人还真达不到#大明王朝1566 #历史",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 35,
|
||||||
|
"width": 339.34375,
|
||||||
|
"height": 190.875,
|
||||||
|
"x": 894.65625,
|
||||||
|
"y": 266.75,
|
||||||
|
"src": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_61db88f71bdba90e436a783bae92863f~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN",
|
||||||
|
"alt": "当大哥不接暗号,鼠鼠带着九格强行认大哥会发生什么呢? #三角洲行动 #三角洲得吃就行挑战 #鼠鼠我呀得吃了 #三角洲最仁义玩家 #洲人洲事",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"parentText": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "IMG",
|
||||||
|
"index": 36,
|
||||||
|
"width": 339.328125,
|
||||||
|
"height": 190.859375,
|
||||||
|
"x": 184,
|
||||||
|
"y": 571.625,
|
||||||
|
"src": "https://p3-pc-sign.douyinpic.com/image-cut-tos/dp_17d53951c4fb7c73dbfa5acddfde35a3~tplv-dy-resize-walign-adapt-aq:540:q75.jpeg?biz_tag=aweme_video&from=1189464143&lk3s=46e5c84f&s=PackSourceEnum_DOUYIN",
|
||||||
|
"alt": "本想应付体验大学生活的表弟,不料竟意外发现表弟的万能用处 #搞笑 #动漫 #轻漫计划 #充能计划",
|
||||||
|
"class": "XTdkxrLI discover-video-card-img",
|
||||||
|
"parentText": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""抖音登录二维码元素结构诊断脚本"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
PROJECT_ROOT = os.path.dirname(ROOT)
|
||||||
|
BROWSERS_PATH = os.path.join(PROJECT_ROOT, "playwright-browsers")
|
||||||
|
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", BROWSERS_PATH)
|
||||||
|
|
||||||
|
OUT_DIR = os.path.join(ROOT, "debug_qr")
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect():
|
||||||
|
async with async_playwright() as p:
|
||||||
|
print("launching browser")
|
||||||
|
browser = await p.chromium.launch(headless=True)
|
||||||
|
context = await browser.new_context(
|
||||||
|
viewport={"width": 1280, "height": 900},
|
||||||
|
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
)
|
||||||
|
page = await context.new_page()
|
||||||
|
print("goto douyin.com")
|
||||||
|
await page.goto("https://www.douyin.com", wait_until="load")
|
||||||
|
await asyncio.sleep(6)
|
||||||
|
print("page url:", page.url)
|
||||||
|
print("page title:", await page.title())
|
||||||
|
|
||||||
|
html = await page.content()
|
||||||
|
with open(os.path.join(OUT_DIR, "page_initial.html"), "w", encoding="utf-8") as f:
|
||||||
|
f.write(html)
|
||||||
|
print("saved page_initial.html")
|
||||||
|
|
||||||
|
# 点击登录按钮,触发登录弹窗
|
||||||
|
login_clicked = False
|
||||||
|
for sel in ["text=登录", "text=登录/注册", "text=立即登录", "button:has-text('登录')", "[class*='login']", "[class*='Login']"]:
|
||||||
|
try:
|
||||||
|
el = await page.wait_for_selector(sel, timeout=3000)
|
||||||
|
if el:
|
||||||
|
await el.click()
|
||||||
|
print("clicked via selector:", sel)
|
||||||
|
login_clicked = True
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"selector {sel} failed: {e}")
|
||||||
|
if not login_clicked:
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
clicked = await page.evaluate("""() => {
|
||||||
|
const nodes = [...document.querySelectorAll('button, span, div, a, p')];
|
||||||
|
for (const el of nodes) {
|
||||||
|
const t = (el.innerText || '').trim();
|
||||||
|
if ((t.includes('登录') || t.toLowerCase().includes('login')) && el.offsetParent) {
|
||||||
|
el.click();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}""")
|
||||||
|
print("clicked via js:", clicked)
|
||||||
|
if clicked:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"js click attempt {attempt} err: {e}")
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await asyncio.sleep(4)
|
||||||
|
|
||||||
|
html = await page.content()
|
||||||
|
with open(os.path.join(OUT_DIR, "page_after_login_click.html"), "w", encoding="utf-8") as f:
|
||||||
|
f.write(html)
|
||||||
|
print("saved page_after_login_click.html")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await page.screenshot(path=os.path.join(OUT_DIR, "00_viewport.png"), full_page=False, timeout=10000)
|
||||||
|
print("saved 00_viewport.png")
|
||||||
|
except Exception as e:
|
||||||
|
print("viewport screenshot failed:", e)
|
||||||
|
|
||||||
|
# 尝试切换「扫码登录」
|
||||||
|
for frame in page.frames:
|
||||||
|
try:
|
||||||
|
switched = await frame.evaluate("""() => {
|
||||||
|
const nodes = [...document.querySelectorAll('span, div, a, button, p')];
|
||||||
|
let best = null;
|
||||||
|
for (const el of nodes) {
|
||||||
|
const t = (el.innerText || '').trim();
|
||||||
|
if ((t === '扫码登录' || t === '扫码') && el.offsetParent) {
|
||||||
|
if (!best || el.children.length < best.children.length) best = el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) { best.click(); return 'switched'; }
|
||||||
|
return '';
|
||||||
|
}""")
|
||||||
|
print(f"frame {frame.url[:60]} switched={switched}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"frame switch err: {e}")
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
report = {"frames": [], "candidates": []}
|
||||||
|
|
||||||
|
# 遍历所有 frame,查找二维码相关元素
|
||||||
|
for idx, frame in enumerate(page.frames):
|
||||||
|
frame_report = {"index": idx, "url": frame.url, "qrcodes": [], "panels": []}
|
||||||
|
selectors = [
|
||||||
|
"[class*='qrcode'] img",
|
||||||
|
"[class*='QrCode'] img",
|
||||||
|
"[class*='qr-code'] img",
|
||||||
|
"img[class*='qrcode']",
|
||||||
|
"img[src*='qrcode']",
|
||||||
|
"img[alt*='二维码']",
|
||||||
|
"img[alt*='qr']",
|
||||||
|
"[class*='qrcode'] canvas",
|
||||||
|
"canvas[class*='qrcode']",
|
||||||
|
"[class*='scan'] img",
|
||||||
|
"[class*='scan'] canvas",
|
||||||
|
"img",
|
||||||
|
"canvas",
|
||||||
|
]
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
els = await frame.query_selector_all(sel)
|
||||||
|
for el in els:
|
||||||
|
try:
|
||||||
|
visible = await el.is_visible()
|
||||||
|
box = await el.bounding_box()
|
||||||
|
tag = await el.evaluate("e => e.tagName")
|
||||||
|
src = await el.get_attribute("src") or ""
|
||||||
|
alt = await el.get_attribute("alt") or ""
|
||||||
|
cls = await el.get_attribute("class") or ""
|
||||||
|
outer = await el.evaluate("e => e.outerHTML.slice(0, 300)")
|
||||||
|
info = {
|
||||||
|
"selector": sel,
|
||||||
|
"tag": tag,
|
||||||
|
"visible": visible,
|
||||||
|
"box": box,
|
||||||
|
"src_prefix": src[:120] if src else "",
|
||||||
|
"alt": alt,
|
||||||
|
"class": cls,
|
||||||
|
"outer": outer,
|
||||||
|
}
|
||||||
|
if (tag.lower() in ("img", "canvas") and box and box.get("width", 0) > 40 and visible):
|
||||||
|
frame_report["qrcodes"].append(info)
|
||||||
|
# 截图该元素
|
||||||
|
safe_name = f"frame{idx}_{tag}_{int(box['x'])}_{int(box['y'])}.png"
|
||||||
|
try:
|
||||||
|
await el.screenshot(path=os.path.join(OUT_DIR, safe_name))
|
||||||
|
info["screenshot"] = safe_name
|
||||||
|
except Exception as e:
|
||||||
|
info["screenshot_err"] = str(e)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" el inspect err: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"frame {idx} selector {sel} err: {e}")
|
||||||
|
|
||||||
|
# 登录面板/容器
|
||||||
|
panel_selectors = [
|
||||||
|
"[class*='qrcode-container']",
|
||||||
|
"[class*='qrcodeContainer']",
|
||||||
|
"[class*='qrcode']",
|
||||||
|
"[class*='QrCode']",
|
||||||
|
"[class*='login-scan']",
|
||||||
|
"[class*='scan-code']",
|
||||||
|
"#login-pannel",
|
||||||
|
"[class*='login_panel']",
|
||||||
|
"[class*='login-panel']",
|
||||||
|
"[class*='account_login']",
|
||||||
|
]
|
||||||
|
for sel in panel_selectors:
|
||||||
|
try:
|
||||||
|
els = await frame.query_selector_all(sel)
|
||||||
|
for el in els:
|
||||||
|
visible = await el.is_visible()
|
||||||
|
box = await el.bounding_box()
|
||||||
|
cls = await el.get_attribute("class") or ""
|
||||||
|
if visible and box and box.get("width", 0) > 80:
|
||||||
|
frame_report["panels"].append({
|
||||||
|
"selector": sel,
|
||||||
|
"class": cls,
|
||||||
|
"box": box,
|
||||||
|
})
|
||||||
|
safe_name = f"frame{idx}_panel_{int(box['x'])}_{int(box['y'])}.png"
|
||||||
|
try:
|
||||||
|
await el.screenshot(path=os.path.join(OUT_DIR, safe_name))
|
||||||
|
frame_report["panels"][-1]["screenshot"] = safe_name
|
||||||
|
except Exception as e:
|
||||||
|
frame_report["panels"][-1]["screenshot_err"] = str(e)
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
report["frames"].append(frame_report)
|
||||||
|
|
||||||
|
# 尝试用 JS 暴力查找所有 img/canvas 中可能为二维码的
|
||||||
|
all_candidates = await page.evaluate("""() => {
|
||||||
|
const out = [];
|
||||||
|
document.querySelectorAll('img, canvas').forEach((el, i) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width > 30 && rect.height > 30 && rect.width < 600 && rect.height < 600) {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
out.push({
|
||||||
|
tag: el.tagName,
|
||||||
|
index: i,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
src: el.tagName === 'IMG' ? (el.src || '').slice(0, 200) : '',
|
||||||
|
alt: el.alt || '',
|
||||||
|
class: el.className || '',
|
||||||
|
parentText: (el.parentElement ? el.parentElement.innerText : '').slice(0, 80),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}""")
|
||||||
|
report["candidates"] = all_candidates
|
||||||
|
|
||||||
|
with open(os.path.join(OUT_DIR, "report.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print("report saved to", os.path.join(OUT_DIR, "report.json"))
|
||||||
|
print("found qrcode-like elements:", sum(len(f["qrcodes"]) for f in report["frames"]))
|
||||||
|
print("found panels:", sum(len(f["panels"]) for f in report["frames"]))
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(inspect())
|
||||||
|
except Exception as e:
|
||||||
|
print("FATAL:", e, file=sys.stderr)
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise
|
||||||
@@ -19,7 +19,8 @@ from fastapi.responses import FileResponse
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth.dependencies import require_admin
|
from auth.dependencies import require_permission
|
||||||
|
from auth.permissions import DESKTOP_MANAGE
|
||||||
from auth.system_settings import get_cached_settings
|
from auth.system_settings import get_cached_settings
|
||||||
from desktop_release import (
|
from desktop_release import (
|
||||||
INSTALLER_DIR,
|
INSTALLER_DIR,
|
||||||
@@ -120,7 +121,7 @@ async def desktop_download(db: AsyncSession = Depends(get_db)):
|
|||||||
async def get_release(
|
async def get_release(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(DESKTOP_MANAGE)),
|
||||||
):
|
):
|
||||||
data = await load_release(db)
|
data = await load_release(db)
|
||||||
return _to_response(data, request)
|
return _to_response(data, request)
|
||||||
@@ -131,7 +132,7 @@ async def update_release(
|
|||||||
body: DesktopReleaseUpdate,
|
body: DesktopReleaseUpdate,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(DESKTOP_MANAGE)),
|
||||||
):
|
):
|
||||||
updates = body.model_dump(exclude_unset=True)
|
updates = body.model_dump(exclude_unset=True)
|
||||||
if "version" in updates and updates["version"] is not None:
|
if "version" in updates and updates["version"] is not None:
|
||||||
@@ -152,7 +153,7 @@ async def upload_installer(
|
|||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(DESKTOP_MANAGE)),
|
||||||
):
|
):
|
||||||
filename = (file.filename or "").strip()
|
filename = (file.filename or "").strip()
|
||||||
if not filename.lower().endswith(".exe"):
|
if not filename.lower().endswith(".exe"):
|
||||||
@@ -194,7 +195,7 @@ async def upload_installer(
|
|||||||
async def delete_installer(
|
async def delete_installer(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(DESKTOP_MANAGE)),
|
||||||
):
|
):
|
||||||
remove_installer()
|
remove_installer()
|
||||||
data = await save_release(db, {"installer_name": "", "installer_size": 0})
|
data = await save_release(db, {"installer_name": "", "installer_size": 0})
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth.dependencies import get_current_user, require_write
|
from auth.dependencies import get_current_user, require_link_cards_write
|
||||||
|
from auth.roles import has_permission
|
||||||
|
from auth.permissions import LINK_CARDS_WRITE
|
||||||
from link_cards import (
|
from link_cards import (
|
||||||
absolute_media_url,
|
absolute_media_url,
|
||||||
build_keywords,
|
build_keywords,
|
||||||
@@ -68,8 +70,8 @@ async def _get_owned_card(
|
|||||||
card = (await db.execute(stmt)).scalar_one_or_none()
|
card = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
if not card or card.owner_id != user.id:
|
if not card or card.owner_id != user.id:
|
||||||
raise HTTPException(status_code=404, detail="卡片不存在")
|
raise HTTPException(status_code=404, detail="卡片不存在")
|
||||||
if write and user.role == "viewer":
|
if write and not has_permission(user.role, LINK_CARDS_WRITE):
|
||||||
raise HTTPException(status_code=403, detail="无写入权限")
|
raise HTTPException(status_code=403, detail="缺少权限:link_cards.write")
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +95,7 @@ def _card_response(card: LinkCardPage, request: Request) -> LinkCardResponse:
|
|||||||
async def upload_link_card_image(
|
async def upload_link_card_image(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_link_cards_write),
|
||||||
):
|
):
|
||||||
if not file.content_type or not file.content_type.startswith("image/"):
|
if not file.content_type or not file.content_type.startswith("image/"):
|
||||||
raise HTTPException(status_code=400, detail="仅支持上传图片文件")
|
raise HTTPException(status_code=400, detail="仅支持上传图片文件")
|
||||||
@@ -137,7 +139,7 @@ async def upsert_link_card(
|
|||||||
body: LinkCardUpsert,
|
body: LinkCardUpsert,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_link_cards_write),
|
||||||
):
|
):
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
content = (body.content or "").strip()
|
content = (body.content or "").strip()
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -30,17 +32,30 @@ from models.db_migrate import (
|
|||||||
migrate_account_videos_table as _migrate_account_videos_table,
|
migrate_account_videos_table as _migrate_account_videos_table,
|
||||||
migrate_message_logs_table as _migrate_message_logs_table,
|
migrate_message_logs_table as _migrate_message_logs_table,
|
||||||
migrate_payment_orders_table as _migrate_payment_orders_table,
|
migrate_payment_orders_table as _migrate_payment_orders_table,
|
||||||
|
migrate_roles_table as _migrate_roles_table,
|
||||||
migrate_rules_table as _migrate_rules_table,
|
migrate_rules_table as _migrate_rules_table,
|
||||||
migrate_users_table as _migrate_users_table,
|
migrate_users_table as _migrate_users_table,
|
||||||
)
|
)
|
||||||
from models.db_config import database_config_to_response
|
from models.db_config import database_config_to_response
|
||||||
from models.models import Account, AccountProfileDetail, AccountVideo, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User
|
from models.models import Account, AccountProfileDetail, AccountVideo, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User
|
||||||
from auth.router import router as auth_router, users_router
|
from auth.router import router as auth_router, users_router, roles_router
|
||||||
from auth.settings_router import router as settings_router
|
from auth.settings_router import router as settings_router
|
||||||
|
from auth.role_service import seed_builtin_roles
|
||||||
from payments.router import router as payments_router
|
from payments.router import router as payments_router
|
||||||
from desktop_router import router as desktop_router
|
from desktop_router import router as desktop_router
|
||||||
from link_cards_router import router as link_cards_router, UPLOAD_DIR as LINK_CARD_UPLOAD_DIR
|
from link_cards_router import router as link_cards_router, UPLOAD_DIR as LINK_CARD_UPLOAD_DIR
|
||||||
from auth.dependencies import get_current_user, require_admin, require_write
|
from auth.dependencies import (
|
||||||
|
get_current_user,
|
||||||
|
require_accounts_cookie,
|
||||||
|
require_accounts_create,
|
||||||
|
require_accounts_delete,
|
||||||
|
require_accounts_start,
|
||||||
|
require_accounts_stop,
|
||||||
|
require_accounts_update,
|
||||||
|
require_messages_write,
|
||||||
|
require_rules_write,
|
||||||
|
require_system_logs_clear,
|
||||||
|
)
|
||||||
from auth.account_limits import ensure_can_add_account
|
from auth.account_limits import ensure_can_add_account
|
||||||
from auth.scopes import (
|
from auth.scopes import (
|
||||||
accounts_for_user,
|
accounts_for_user,
|
||||||
@@ -52,7 +67,8 @@ from auth.scopes import (
|
|||||||
received_logs_for_user,
|
received_logs_for_user,
|
||||||
system_logs_for_user,
|
system_logs_for_user,
|
||||||
)
|
)
|
||||||
from auth.roles import is_admin
|
from auth.roles import has_global_scope, has_permission, is_admin
|
||||||
|
from auth.permissions import LOGS_READ, RECEIVED_MESSAGES_READ, SYSTEM_LOGS_READ
|
||||||
from auth.passwords import hash_password
|
from auth.passwords import hash_password
|
||||||
from rpa_engine.batch_start import BatchStartQueue
|
from rpa_engine.batch_start import BatchStartQueue
|
||||||
from rpa_engine.playwright_worker import DouyinWorker
|
from rpa_engine.playwright_worker import DouyinWorker
|
||||||
@@ -68,6 +84,7 @@ from rpa_engine.credential import (
|
|||||||
assess_account_credential,
|
assess_account_credential,
|
||||||
build_im_session_from_storage,
|
build_im_session_from_storage,
|
||||||
build_cookie_credential_detail,
|
build_cookie_credential_detail,
|
||||||
|
credential_egress_mismatch,
|
||||||
)
|
)
|
||||||
from utils.cookie_store import (
|
from utils.cookie_store import (
|
||||||
write_cookie_file,
|
write_cookie_file,
|
||||||
@@ -77,10 +94,31 @@ from utils.cookie_store import (
|
|||||||
cookie_summary,
|
cookie_summary,
|
||||||
validate_cookie_json,
|
validate_cookie_json,
|
||||||
analyze_cookie,
|
analyze_cookie,
|
||||||
|
extract_user_agent_from_cookie_data,
|
||||||
)
|
)
|
||||||
from rpa_engine.device_profiles import list_device_profiles, profile_label_for_ua, resolve_user_agent
|
from rpa_engine.device_profiles import list_device_profiles, profile_label_for_ua, resolve_user_agent
|
||||||
|
from rpa_engine.egress_channels import (
|
||||||
|
clamp_attempts,
|
||||||
|
discover_egress_channels,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("main")
|
logger = logging.getLogger("main")
|
||||||
|
|
||||||
|
def _ui_conversation_page_budget() -> int:
|
||||||
|
"""用户点开会话列表时允许翻的收件箱页数。
|
||||||
|
|
||||||
|
抖音收件箱按游标分页,一次请求只给一页(实测每页约 100-500KB);某账号翻
|
||||||
|
6 页拿到 35 个会话仍未翻完。所以这里必须有预算:只拿一页会把「其中一页」
|
||||||
|
当成完整列表,不设上限又可能为一次点击拉下好几 MB。
|
||||||
|
默认 3 页只是个折中——真正花多少流量换多完整的列表是业务取舍,
|
||||||
|
用 KEFU_UI_CONVERSATION_PAGES 调整;翻不完时仍会并入本地历史,
|
||||||
|
并且不会把残缺列表伪装成完整列表。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
value = int(os.getenv("KEFU_UI_CONVERSATION_PAGES", "3") or 3)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
value = 3
|
||||||
|
return max(1, min(20, value))
|
||||||
from utils import system_logger
|
from utils import system_logger
|
||||||
|
|
||||||
app = FastAPI(title="抖音多账号自动回复管理系统 API")
|
app = FastAPI(title="抖音多账号自动回复管理系统 API")
|
||||||
@@ -94,11 +132,17 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# RPA 任务管理器
|
# RPA 任务管理器
|
||||||
|
# 自动重登录防抖:同账号 30 分钟内最多触发一次,防止「扫码失败→失效→再重登录」死循环
|
||||||
|
_AUTO_RELOGIN_COOLDOWN = float(os.getenv("KEFU_AUTO_RELOGIN_COOLDOWN", "1800") or 1800)
|
||||||
|
|
||||||
|
|
||||||
class WorkerManager:
|
class WorkerManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.workers = {} # account_id -> DouyinWorker
|
self.workers = {} # account_id -> DouyinWorker
|
||||||
self._account_locks: dict[int, asyncio.Lock] = {}
|
self._account_locks: dict[int, asyncio.Lock] = {}
|
||||||
self._preparation_locks: dict[int, asyncio.Lock] = {}
|
self._preparation_locks: dict[int, asyncio.Lock] = {}
|
||||||
|
self._auto_relogin_tasks: dict[int, asyncio.Task] = {}
|
||||||
|
self._last_auto_relogin_at: dict[int, float] = {}
|
||||||
|
|
||||||
def _account_lock(self, account_id: int) -> asyncio.Lock:
|
def _account_lock(self, account_id: int) -> asyncio.Lock:
|
||||||
return self._account_locks.setdefault(int(account_id), asyncio.Lock())
|
return self._account_locks.setdefault(int(account_id), asyncio.Lock())
|
||||||
@@ -126,6 +170,10 @@ class WorkerManager:
|
|||||||
account_id,
|
account_id,
|
||||||
login_mode=login_mode,
|
login_mode=login_mode,
|
||||||
credential_prevalidated=credential_prevalidated,
|
credential_prevalidated=credential_prevalidated,
|
||||||
|
# 登录态失效(KICK/INVALID_REQUEST/用户未登录)时自动重登录:
|
||||||
|
# 重新以 browser 模式拉起 worker,浏览器探测未登录 → 弹二维码
|
||||||
|
# → 用户扫码 → 自动采集凭证并恢复托管。
|
||||||
|
relogin_hook=self._schedule_auto_relogin,
|
||||||
)
|
)
|
||||||
self.workers[account_id] = worker
|
self.workers[account_id] = worker
|
||||||
await worker.start()
|
await worker.start()
|
||||||
@@ -190,6 +238,98 @@ class WorkerManager:
|
|||||||
worker = self.workers.get(account_id)
|
worker = self.workers.get(account_id)
|
||||||
return worker.is_running if worker else False
|
return worker.is_running if worker else False
|
||||||
|
|
||||||
|
async def _schedule_auto_relogin(self, account_id: int) -> None:
|
||||||
|
"""登录态失效后由 worker 回调:防抖 + 后台异步执行自动重登录。
|
||||||
|
|
||||||
|
注意:本方法在 service 的发送协程里被 await,必须快速返回,
|
||||||
|
实际的浏览器重登录流程放到独立 task 中执行。
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
last = self._last_auto_relogin_at.get(account_id, 0.0)
|
||||||
|
if now - last < _AUTO_RELOGIN_COOLDOWN:
|
||||||
|
logger.info(
|
||||||
|
f"Account {account_id}: auto relogin skipped "
|
||||||
|
f"(cooldown {_AUTO_RELOGIN_COOLDOWN}s)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._last_auto_relogin_at[account_id] = now
|
||||||
|
prev = self._auto_relogin_tasks.get(account_id)
|
||||||
|
if prev and not prev.done():
|
||||||
|
logger.info(f"Account {account_id}: auto relogin already in progress")
|
||||||
|
return
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._auto_relogin_account(account_id),
|
||||||
|
name=f"auto-relogin-{account_id}",
|
||||||
|
)
|
||||||
|
self._auto_relogin_tasks[account_id] = task
|
||||||
|
|
||||||
|
def _cleanup(done_task: asyncio.Task) -> None:
|
||||||
|
if self._auto_relogin_tasks.get(account_id) is done_task:
|
||||||
|
self._auto_relogin_tasks.pop(account_id, None)
|
||||||
|
|
||||||
|
task.add_done_callback(_cleanup)
|
||||||
|
|
||||||
|
async def _auto_relogin_account(self, account_id: int) -> None:
|
||||||
|
"""自动重登录:等旧 worker 退出,置 logging_in,以 browser 模式重启。
|
||||||
|
|
||||||
|
新 worker 的浏览器流程会先探测页面登录态:未登录则自动弹二维码
|
||||||
|
(qr_code_base64 写库,前端账号卡片轮询展示),用户扫码成功后自动
|
||||||
|
采集 IM 凭证并恢复托管;登录超时/失败则回落到 offline 等人工处理。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1) 等旧 worker 完全退出(on_im_session_invalid 已置 is_running=False,
|
||||||
|
# _run_loop 收尾需要一点时间)
|
||||||
|
for _ in range(100):
|
||||||
|
worker = self.workers.get(account_id)
|
||||||
|
if worker is None or not worker.is_running:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
# 2) 置 logging_in(前端显示「等待扫码」,二维码由新 worker 生成)
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
account = (
|
||||||
|
await db.execute(
|
||||||
|
select(Account).where(Account.id == account_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if account is None:
|
||||||
|
return
|
||||||
|
account.status = "logging_in"
|
||||||
|
account.qr_code_base64 = None
|
||||||
|
account.error_message = None
|
||||||
|
await db.commit()
|
||||||
|
system_logger.record(
|
||||||
|
"登录态失效,正在自动重登录",
|
||||||
|
detail=(
|
||||||
|
"系统检测到抖音登录态失效,已自动打开登录流程。"
|
||||||
|
"请留意账号卡片上的二维码,用抖音 App 扫码后托管将自动恢复。"
|
||||||
|
),
|
||||||
|
level="warning",
|
||||||
|
category="auth",
|
||||||
|
account_id=account_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) 以 browser 模式重启:浏览器探测未登录 → 弹二维码 → 扫码 → 恢复托管。
|
||||||
|
# 不等待就绪(wait_until_ready=False),让新 worker 自行走完整登录流程。
|
||||||
|
await self.start_worker(account_id, login_mode="browser")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Account {account_id}: auto relogin failed: {exc}")
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await db.execute(
|
||||||
|
update(Account)
|
||||||
|
.where(Account.id == account_id)
|
||||||
|
.values(
|
||||||
|
status="offline",
|
||||||
|
error_message=f"自动重登录失败:{exc}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
manager = WorkerManager()
|
manager = WorkerManager()
|
||||||
|
|
||||||
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "uploads", "messages")
|
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "uploads", "messages")
|
||||||
@@ -201,6 +341,7 @@ app.mount("/api/media/link-cards", StaticFiles(directory=LINK_CARD_UPLOAD_DIR),
|
|||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
|
app.include_router(roles_router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(payments_router)
|
app.include_router(payments_router)
|
||||||
app.include_router(desktop_router)
|
app.include_router(desktop_router)
|
||||||
@@ -423,11 +564,33 @@ def _account_has_cookie(account: Account) -> bool:
|
|||||||
return os.path.exists(get_cookie_path(account.id))
|
return os.path.exists(get_cookie_path(account.id))
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_user_agent_from_cookie(account: Account, standard_json_str: Optional[str]) -> None:
|
||||||
|
"""凭证里带登录头(user_agent)且账号未显式配置 UA 时自动回填。
|
||||||
|
|
||||||
|
已有自定义 UA(account.user_agent 非空)的账号保持不变,避免覆盖用户选择;
|
||||||
|
cookie_data 为空或解析失败时静默跳过。回填后发送/接收链路经
|
||||||
|
_build_account_im_session 统一走 resolve_user_agent(account.user_agent),
|
||||||
|
保证 UA 全链路一致。
|
||||||
|
"""
|
||||||
|
if account.user_agent:
|
||||||
|
return
|
||||||
|
if not standard_json_str:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ua = extract_user_agent_from_cookie_data(standard_json_str)
|
||||||
|
except Exception:
|
||||||
|
ua = ""
|
||||||
|
if ua:
|
||||||
|
account.user_agent = ua
|
||||||
|
|
||||||
|
|
||||||
def _build_account_im_session(account: Account) -> DouyinImSession:
|
def _build_account_im_session(account: Account) -> DouyinImSession:
|
||||||
cookie_data = _get_account_cookie_data(account)
|
cookie_data = _get_account_cookie_data(account)
|
||||||
storage = json.loads(cookie_data) if cookie_data else {}
|
storage = json.loads(cookie_data) if cookie_data else {}
|
||||||
session = build_im_session_from_storage(storage, account.im_session_data)
|
session = build_im_session_from_storage(storage, account.im_session_data)
|
||||||
session.user_agent = resolve_user_agent(account.user_agent or session.user_agent)
|
session.user_agent = resolve_user_agent(account.user_agent or session.user_agent)
|
||||||
|
session.egress_public_ip = str(account.egress_public_ip or "").strip()
|
||||||
|
session.egress_auto_attempts = clamp_attempts(account.egress_auto_attempts)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
@@ -610,6 +773,7 @@ async def startup():
|
|||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await conn.run_sync(_migrate_roles_table)
|
||||||
await conn.run_sync(_migrate_accounts_table)
|
await conn.run_sync(_migrate_accounts_table)
|
||||||
await conn.run_sync(_migrate_rules_table)
|
await conn.run_sync(_migrate_rules_table)
|
||||||
await conn.run_sync(_migrate_message_logs_table)
|
await conn.run_sync(_migrate_message_logs_table)
|
||||||
@@ -618,6 +782,8 @@ async def startup():
|
|||||||
await conn.run_sync(_migrate_payment_orders_table)
|
await conn.run_sync(_migrate_payment_orders_table)
|
||||||
await conn.run_sync(_migrate_accounts_quota_disabled)
|
await conn.run_sync(_migrate_accounts_quota_disabled)
|
||||||
await _seed_app_config()
|
await _seed_app_config()
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await seed_builtin_roles(db)
|
||||||
await _seed_admin_user()
|
await _seed_admin_user()
|
||||||
# 进程启动时没有任何内存 Worker;复位异常退出遗留的运行状态。
|
# 进程启动时没有任何内存 Worker;复位异常退出遗留的运行状态。
|
||||||
# 同时,账号数量/并发限制已移除,清理历史“额度停用”标记。
|
# 同时,账号数量/并发限制已移除,清理历史“额度停用”标记。
|
||||||
@@ -788,6 +954,8 @@ class AccountResponse(BaseModel):
|
|||||||
follow_welcome_content: Optional[str] = None
|
follow_welcome_content: Optional[str] = None
|
||||||
user_agent: Optional[str] = None
|
user_agent: Optional[str] = None
|
||||||
user_agent_label: Optional[str] = None
|
user_agent_label: Optional[str] = None
|
||||||
|
egress_public_ip: Optional[str] = None
|
||||||
|
egress_auto_attempts: int = 1
|
||||||
quota_disabled: bool = False
|
quota_disabled: bool = False
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -832,6 +1000,10 @@ class AccountUpdate(BaseModel):
|
|||||||
follow_welcome_enabled: Optional[bool] = None
|
follow_welcome_enabled: Optional[bool] = None
|
||||||
follow_welcome_content: Optional[str] = None
|
follow_welcome_content: Optional[str] = None
|
||||||
user_agent: Optional[str] = None
|
user_agent: Optional[str] = None
|
||||||
|
# 空字符串/null=自动选择;否则保存服务器探测到的公网 IPv4。
|
||||||
|
egress_public_ip: Optional[str] = None
|
||||||
|
# 包含首选通道在内的最大串行尝试数,范围 1~8。
|
||||||
|
egress_auto_attempts: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class ReplyQueueItemResponse(BaseModel):
|
class ReplyQueueItemResponse(BaseModel):
|
||||||
@@ -910,6 +1082,8 @@ class AccountCookieResponse(BaseModel):
|
|||||||
im_status: Optional[str] = None
|
im_status: Optional[str] = None
|
||||||
can_skip_browser: bool = False
|
can_skip_browser: bool = False
|
||||||
should_reset: bool = False
|
should_reset: bool = False
|
||||||
|
user_agent: Optional[str] = None
|
||||||
|
user_agent_label: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class AccountVideoItem(BaseModel):
|
class AccountVideoItem(BaseModel):
|
||||||
@@ -988,6 +1162,8 @@ async def _build_cookie_response(
|
|||||||
im_status=im_detail["im_status"],
|
im_status=im_detail["im_status"],
|
||||||
can_skip_browser=im_detail["can_skip_browser"],
|
can_skip_browser=im_detail["can_skip_browser"],
|
||||||
should_reset=im_detail.get("should_reset", False),
|
should_reset=im_detail.get("should_reset", False),
|
||||||
|
user_agent=account.user_agent or None,
|
||||||
|
user_agent_label=profile_label_for_ua(account.user_agent),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1056,6 +1232,8 @@ def _build_account_response(account: Account) -> AccountResponse:
|
|||||||
follow_welcome_content=account.follow_welcome_content or None,
|
follow_welcome_content=account.follow_welcome_content or None,
|
||||||
user_agent=account.user_agent or None,
|
user_agent=account.user_agent or None,
|
||||||
user_agent_label=profile_label_for_ua(account.user_agent),
|
user_agent_label=profile_label_for_ua(account.user_agent),
|
||||||
|
egress_public_ip=account.egress_public_ip or None,
|
||||||
|
egress_auto_attempts=clamp_attempts(account.egress_auto_attempts),
|
||||||
quota_disabled=bool(account.quota_disabled),
|
quota_disabled=bool(account.quota_disabled),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1136,6 +1314,10 @@ class ConversationResponse(BaseModel):
|
|||||||
class SendMessageRequest(BaseModel):
|
class SendMessageRequest(BaseModel):
|
||||||
conversation_id: str
|
conversation_id: str
|
||||||
content: str = ""
|
content: str = ""
|
||||||
|
# 调用方认定的收件人 UID。会话归属校验只保证会话属于本账号,保证不了
|
||||||
|
# 「这个人就是用户点选的那个人」——消息页按昵称兜底匹配会话时可能选中
|
||||||
|
# 同名的另一个人。带上它,发送链路会在写出去之前核对收件人。
|
||||||
|
peer_uid: Optional[str] = None
|
||||||
message_type: Optional[str] = None # text | image | sticker
|
message_type: Optional[str] = None # text | image | sticker
|
||||||
media_url: Optional[str] = None
|
media_url: Optional[str] = None
|
||||||
sticker_url: Optional[str] = None
|
sticker_url: Optional[str] = None
|
||||||
@@ -1185,12 +1367,54 @@ class DeviceProfileItem(BaseModel):
|
|||||||
user_agent: str
|
user_agent: str
|
||||||
|
|
||||||
|
|
||||||
|
class EgressChannelItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
public_ip: str
|
||||||
|
source_ip: Optional[str] = None
|
||||||
|
interface: str = ""
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class EgressChannelListResponse(BaseModel):
|
||||||
|
channels: List[EgressChannelItem] = Field(default_factory=list)
|
||||||
|
multiple: bool = False
|
||||||
|
detected_at: datetime
|
||||||
|
errors: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/device-profiles", response_model=List[DeviceProfileItem])
|
@app.get("/api/device-profiles", response_model=List[DeviceProfileItem])
|
||||||
async def get_device_profiles(user: User = Depends(get_current_user)):
|
async def get_device_profiles(user: User = Depends(get_current_user)):
|
||||||
"""可选的伪装设备头(User-Agent)预设列表。"""
|
"""可选的伪装设备头(User-Agent)预设列表。"""
|
||||||
return list_device_profiles()
|
return list_device_profiles()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/network/egress-channels", response_model=EgressChannelListResponse)
|
||||||
|
async def get_egress_channels(
|
||||||
|
refresh: bool = False,
|
||||||
|
user: User = Depends(require_accounts_update),
|
||||||
|
):
|
||||||
|
"""Detect bindable server addresses and the public IPv4 seen through each."""
|
||||||
|
|
||||||
|
del user
|
||||||
|
snapshot = await discover_egress_channels(force=refresh)
|
||||||
|
channels = [
|
||||||
|
EgressChannelItem(
|
||||||
|
id=item.id,
|
||||||
|
public_ip=item.public_ip,
|
||||||
|
source_ip=item.source_ip,
|
||||||
|
interface=item.interface,
|
||||||
|
is_default=item.is_default,
|
||||||
|
)
|
||||||
|
for item in snapshot.channels
|
||||||
|
]
|
||||||
|
return EgressChannelListResponse(
|
||||||
|
channels=channels,
|
||||||
|
multiple=len(channels) > 1,
|
||||||
|
detected_at=datetime.fromtimestamp(snapshot.detected_at, tz=timezone.utc),
|
||||||
|
errors=list(snapshot.errors),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- API 路由接口 ---
|
# --- API 路由接口 ---
|
||||||
|
|
||||||
# 1. 账号管理接口
|
# 1. 账号管理接口
|
||||||
@@ -1399,7 +1623,7 @@ async def get_account_options(
|
|||||||
Account.reply_cooldown_seconds,
|
Account.reply_cooldown_seconds,
|
||||||
Account.quota_disabled,
|
Account.quota_disabled,
|
||||||
)
|
)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
stmt = stmt.where(Account.owner_id == user.id)
|
stmt = stmt.where(Account.owner_id == user.id)
|
||||||
|
|
||||||
rows = (await db.execute(stmt.order_by(Account.id.asc()))).all()
|
rows = (await db.execute(stmt.order_by(Account.id.asc()))).all()
|
||||||
@@ -1618,9 +1842,9 @@ async def update_account(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
body: AccountUpdate,
|
body: AccountUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_accounts_update),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(db, user, account_id, write=True, write_permission="accounts.update")
|
||||||
follow_config_changed = bool(
|
follow_config_changed = bool(
|
||||||
{"follow_welcome_enabled", "follow_welcome_content"}
|
{"follow_welcome_enabled", "follow_welcome_content"}
|
||||||
& set(body.model_fields_set)
|
& set(body.model_fields_set)
|
||||||
@@ -1645,14 +1869,50 @@ async def update_account(
|
|||||||
if body.user_agent is not None:
|
if body.user_agent is not None:
|
||||||
ua = (body.user_agent or "").strip()
|
ua = (body.user_agent or "").strip()
|
||||||
account.user_agent = ua or None
|
account.user_agent = ua or None
|
||||||
|
egress_changed = False
|
||||||
|
if "egress_public_ip" in body.model_fields_set:
|
||||||
|
selected_public_ip = str(body.egress_public_ip or "").strip()
|
||||||
|
if selected_public_ip:
|
||||||
|
try:
|
||||||
|
parsed_ip = ipaddress.ip_address(selected_public_ip)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="公网通道必须是有效的 IPv4 地址")
|
||||||
|
if parsed_ip.version != 4:
|
||||||
|
raise HTTPException(status_code=400, detail="公网通道目前仅支持 IPv4")
|
||||||
|
previous_public_ip = str(account.egress_public_ip or "").strip()
|
||||||
|
egress_changed = previous_public_ip != selected_public_ip
|
||||||
|
account.egress_public_ip = selected_public_ip or None
|
||||||
|
if body.egress_auto_attempts is not None:
|
||||||
|
account.egress_auto_attempts = clamp_attempts(body.egress_auto_attempts)
|
||||||
account.updated_at = datetime.utcnow()
|
account.updated_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(account)
|
await db.refresh(account)
|
||||||
|
if egress_changed and manager.is_running(account_id):
|
||||||
|
await manager.stop_worker(account_id)
|
||||||
|
await db.execute(
|
||||||
|
update(Account).where(Account.id == account_id).values(
|
||||||
|
status="offline",
|
||||||
|
error_message=(
|
||||||
|
"公网通道已变更,请重新启动托管以使用新通道;"
|
||||||
|
"已保留登录凭证,校验通过后无需重新扫码"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(account)
|
||||||
if follow_config_changed:
|
if follow_config_changed:
|
||||||
worker = manager.workers.get(account_id)
|
worker = manager.workers.get(account_id)
|
||||||
invalidate = getattr(worker, "invalidate_follow_welcome_config", None)
|
invalidate = getattr(worker, "invalidate_follow_welcome_config", None)
|
||||||
if callable(invalidate):
|
if callable(invalidate):
|
||||||
invalidate()
|
invalidate()
|
||||||
|
worker = manager.workers.get(account_id)
|
||||||
|
runtime_service = getattr(worker, "_im_service", None) if worker else None
|
||||||
|
if runtime_service:
|
||||||
|
runtime_session = runtime_service.session
|
||||||
|
# Reconnect after a public-IP change so HTTP and the existing WS do
|
||||||
|
# not use different routes. Reconnecting does not invalidate cookies.
|
||||||
|
# Only the retry-count can be hot-updated without reconnecting.
|
||||||
|
runtime_session.egress_auto_attempts = clamp_attempts(account.egress_auto_attempts)
|
||||||
return _build_account_response(account)
|
return _build_account_response(account)
|
||||||
|
|
||||||
|
|
||||||
@@ -1686,7 +1946,7 @@ async def get_reply_queue_summaries(
|
|||||||
allowed_ids = await owned_account_ids(db, user)
|
allowed_ids = await owned_account_ids(db, user)
|
||||||
worker_entries = list(manager.workers.items())
|
worker_entries = list(manager.workers.items())
|
||||||
else:
|
else:
|
||||||
if is_admin(user.role):
|
if has_global_scope(user.role):
|
||||||
allowed_ids = set(requested_ids)
|
allowed_ids = set(requested_ids)
|
||||||
elif requested_ids:
|
elif requested_ids:
|
||||||
owned_result = await db.execute(
|
owned_result = await db.execute(
|
||||||
@@ -1776,10 +2036,10 @@ async def send_account_queued_reply_now(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
job_id: str,
|
job_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_messages_write),
|
||||||
):
|
):
|
||||||
"""将指定任务原子移入紧急队列,并把它后面的普通任务前移一槽。"""
|
"""将指定任务原子移入紧急队列,并把它后面的普通任务前移一槽。"""
|
||||||
await get_owned_account(db, user, account_id, write=True)
|
await get_owned_account(db, user, account_id, write=True, write_permission="messages.write")
|
||||||
worker = manager.workers.get(account_id)
|
worker = manager.workers.get(account_id)
|
||||||
service = worker._im_service if worker else None
|
service = worker._im_service if worker else None
|
||||||
if not worker or not worker.is_running or not service or not service._running:
|
if not worker or not worker.is_running or not service or not service._running:
|
||||||
@@ -1874,7 +2134,7 @@ async def get_account_cookie(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
purpose: Optional[str] = None,
|
purpose: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
"""Read a Cookie for management or for legacy desktop login clients.
|
"""Read a Cookie for management or for legacy desktop login clients.
|
||||||
|
|
||||||
@@ -1907,7 +2167,7 @@ async def get_account_cookie(
|
|||||||
async def get_desktop_login_credential(
|
async def get_desktop_login_credential(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
"""Return credentials only when the selected Douyin identity is verified.
|
"""Return credentials only when the selected Douyin identity is verified.
|
||||||
|
|
||||||
@@ -1931,7 +2191,7 @@ async def update_account_cookie(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
body: AccountCookieUpdate,
|
body: AccountCookieUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
# Validate first: malformed input must not take a healthy hosted account
|
# Validate first: malformed input must not take a healthy hosted account
|
||||||
# offline. Filesystem and database mutations happen only after the worker
|
# offline. Filesystem and database mutations happen only after the worker
|
||||||
@@ -1946,7 +2206,7 @@ async def update_account_cookie(
|
|||||||
# preparations. Keep the lock until the new Cookie and cleared identity
|
# preparations. Keep the lock until the new Cookie and cleared identity
|
||||||
# are committed so no worker can start in the stop/commit gap.
|
# are committed so no worker can start in the stop/commit gap.
|
||||||
async with manager.preparation_lock(account_id):
|
async with manager.preparation_lock(account_id):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(db, user, account_id, write=True, write_permission="accounts.cookie")
|
||||||
await _release_db_connection(db)
|
await _release_db_connection(db)
|
||||||
await batch_start_queue.cancel_account(account_id)
|
await batch_start_queue.cancel_account(account_id)
|
||||||
await manager.stop_worker(account_id)
|
await manager.stop_worker(account_id)
|
||||||
@@ -1960,6 +2220,7 @@ async def update_account_cookie(
|
|||||||
account.cookie_path = cookie_path
|
account.cookie_path = cookie_path
|
||||||
account.cookie_updated_at = datetime.utcnow()
|
account.cookie_updated_at = datetime.utcnow()
|
||||||
account.updated_at = datetime.utcnow()
|
account.updated_at = datetime.utcnow()
|
||||||
|
_backfill_user_agent_from_cookie(account, standard_json_str)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(AccountProfileDetail)
|
update(AccountProfileDetail)
|
||||||
.where(AccountProfileDetail.account_id == account_id)
|
.where(AccountProfileDetail.account_id == account_id)
|
||||||
@@ -1980,13 +2241,13 @@ async def update_account_cookie(
|
|||||||
async def delete_account_cookie(
|
async def delete_account_cookie(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
# Deleting credentials uses the same preparation lock as starting a
|
# Deleting credentials uses the same preparation lock as starting a
|
||||||
# worker, preventing a new worker from appearing after stop_worker but
|
# worker, preventing a new worker from appearing after stop_worker but
|
||||||
# before the cleared credentials are committed.
|
# before the cleared credentials are committed.
|
||||||
async with manager.preparation_lock(account_id):
|
async with manager.preparation_lock(account_id):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(db, user, account_id, write=True, write_permission="accounts.cookie")
|
||||||
await _release_db_connection(db)
|
await _release_db_connection(db)
|
||||||
await batch_start_queue.cancel_account(account_id)
|
await batch_start_queue.cancel_account(account_id)
|
||||||
await manager.stop_worker(account_id)
|
await manager.stop_worker(account_id)
|
||||||
@@ -2009,7 +2270,7 @@ async def delete_account_cookie(
|
|||||||
async def create_account(
|
async def create_account(
|
||||||
account_in: AccountCreate,
|
account_in: AccountCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_accounts_create),
|
||||||
):
|
):
|
||||||
cookie_data = (account_in.cookie_data or "").strip()
|
cookie_data = (account_in.cookie_data or "").strip()
|
||||||
standard_json_str = None
|
standard_json_str = None
|
||||||
@@ -2032,6 +2293,7 @@ async def create_account(
|
|||||||
account.cookie_data = standard_json_str
|
account.cookie_data = standard_json_str
|
||||||
account.cookie_path = cookie_path
|
account.cookie_path = cookie_path
|
||||||
account.cookie_updated_at = datetime.utcnow()
|
account.cookie_updated_at = datetime.utcnow()
|
||||||
|
_backfill_user_agent_from_cookie(account, standard_json_str)
|
||||||
try:
|
try:
|
||||||
from rpa_engine.account_profile import apply_douyin_profile
|
from rpa_engine.account_profile import apply_douyin_profile
|
||||||
|
|
||||||
@@ -2047,9 +2309,9 @@ async def create_account(
|
|||||||
async def delete_account(
|
async def delete_account(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_accounts_delete),
|
||||||
):
|
):
|
||||||
await get_owned_account(db, user, account_id, write=True)
|
await get_owned_account(db, user, account_id, write=True, write_permission="accounts.delete")
|
||||||
# 停止运行中的任务
|
# 停止运行中的任务
|
||||||
await _release_db_connection(db)
|
await _release_db_connection(db)
|
||||||
await batch_start_queue.cancel_account(account_id)
|
await batch_start_queue.cancel_account(account_id)
|
||||||
@@ -2065,7 +2327,7 @@ async def delete_account(
|
|||||||
async def validate_account_credential(
|
async def validate_account_credential(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id)
|
account = await get_owned_account(db, user, account_id)
|
||||||
|
|
||||||
@@ -2082,9 +2344,9 @@ async def validate_account_credential(
|
|||||||
async def reset_account_credentials(
|
async def reset_account_credentials(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
await get_owned_account(db, user, account_id, write=True)
|
await get_owned_account(db, user, account_id, write=True, write_permission="accounts.cookie")
|
||||||
await _release_db_connection(db)
|
await _release_db_connection(db)
|
||||||
await batch_start_queue.cancel_account(account_id)
|
await batch_start_queue.cancel_account(account_id)
|
||||||
account = await _reset_account_credentials(account_id, db)
|
account = await _reset_account_credentials(account_id, db)
|
||||||
@@ -2110,13 +2372,26 @@ async def _start_account_rpa_impl(
|
|||||||
# Credential assessment issues real network requests to Douyin, and a bulk
|
# Credential assessment issues real network requests to Douyin, and a bulk
|
||||||
# start runs it for every queued account. Release the connection first.
|
# start runs it for every queued account. Release the connection first.
|
||||||
await _release_db_connection(db)
|
await _release_db_connection(db)
|
||||||
|
reset_performed = False
|
||||||
|
|
||||||
|
selected_public_ip = str(getattr(account, "egress_public_ip", "") or "").strip()
|
||||||
|
if credential_egress_mismatch(cookie_data, selected_public_ip):
|
||||||
|
# The stored IP is local metadata, not a platform authentication
|
||||||
|
# verdict. Imported/legacy cookies may not have it at all. Keep the
|
||||||
|
# credentials and use normal validation on the selected route.
|
||||||
|
logger.info(
|
||||||
|
"Account %s egress marker differs; preserving credentials and "
|
||||||
|
"validating on selected channel %s",
|
||||||
|
account_id,
|
||||||
|
selected_public_ip or "default",
|
||||||
|
)
|
||||||
assessment = await assess_account_credential(
|
assessment = await assess_account_credential(
|
||||||
cookie_data,
|
cookie_data,
|
||||||
account.im_session_data,
|
account.im_session_data,
|
||||||
startup_priority=True,
|
startup_priority=True,
|
||||||
|
egress_public_ip=selected_public_ip,
|
||||||
)
|
)
|
||||||
login_mode = requested_login_mode or assessment["login_mode"]
|
login_mode = requested_login_mode or assessment["login_mode"]
|
||||||
reset_performed = False
|
|
||||||
|
|
||||||
if assessment.get("should_reset") and login_mode != "im_direct":
|
if assessment.get("should_reset") and login_mode != "im_direct":
|
||||||
account = await _reset_account_credentials(account_id, db)
|
account = await _reset_account_credentials(account_id, db)
|
||||||
@@ -2237,9 +2512,11 @@ async def start_account_rpa(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
body: StartAccountRequest = StartAccountRequest(),
|
body: StartAccountRequest = StartAccountRequest(),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_accounts_start),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(
|
||||||
|
db, user, account_id, write=True, write_permission="accounts.start"
|
||||||
|
)
|
||||||
# Cancelling waits for an in-flight queued start, and the preparation lock
|
# Cancelling waits for an in-flight queued start, and the preparation lock
|
||||||
# waits for whichever start owns this account. Neither may keep a pooled
|
# waits for whichever start owns this account. Neither may keep a pooled
|
||||||
# connection checked out while it waits.
|
# connection checked out while it waits.
|
||||||
@@ -2253,7 +2530,7 @@ async def start_account_rpa(
|
|||||||
async def submit_account_start_batch(
|
async def submit_account_start_batch(
|
||||||
body: BatchStartRequest,
|
body: BatchStartRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_accounts_start),
|
||||||
):
|
):
|
||||||
requested_ids = list(
|
requested_ids = list(
|
||||||
dict.fromkeys(int(value) for value in body.account_ids if int(value) > 0)
|
dict.fromkeys(int(value) for value in body.account_ids if int(value) > 0)
|
||||||
@@ -2266,7 +2543,7 @@ async def submit_account_start_batch(
|
|||||||
# The submit path needs only ids and the disabled flag. Do not hydrate
|
# The submit path needs only ids and the disabled flag. Do not hydrate
|
||||||
# every account's large cookie/session/QR columns just to enqueue ids.
|
# every account's large cookie/session/QR columns just to enqueue ids.
|
||||||
candidate_stmt = select(Account.id, Account.quota_disabled)
|
candidate_stmt = select(Account.id, Account.quota_disabled)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
candidate_stmt = candidate_stmt.where(Account.owner_id == user.id)
|
candidate_stmt = candidate_stmt.where(Account.owner_id == user.id)
|
||||||
if not body.all_accounts:
|
if not body.all_accounts:
|
||||||
candidate_stmt = candidate_stmt.where(Account.id.in_(requested_ids))
|
candidate_stmt = candidate_stmt.where(Account.id.in_(requested_ids))
|
||||||
@@ -2317,9 +2594,11 @@ async def get_account_start_batch(
|
|||||||
async def stop_account_rpa(
|
async def stop_account_rpa(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_accounts_stop),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(
|
||||||
|
db, user, account_id, write=True, write_permission="accounts.stop"
|
||||||
|
)
|
||||||
|
|
||||||
# Cancelling drains an in-flight queued start, which can take as long as
|
# Cancelling drains an in-flight queued start, which can take as long as
|
||||||
# the batch per-account deadline. Do not hold a pooled connection for it.
|
# the batch per-account deadline. Do not hold a pooled connection for it.
|
||||||
@@ -2395,13 +2674,13 @@ async def get_rules(
|
|||||||
async def create_rule(
|
async def create_rule(
|
||||||
rule_in: RuleCreate,
|
rule_in: RuleCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_rules_write),
|
||||||
):
|
):
|
||||||
if rule_in.match_type == "default":
|
if rule_in.match_type == "default":
|
||||||
rule_in.keyword = ""
|
rule_in.keyword = ""
|
||||||
if rule_in.account_id is None:
|
if rule_in.account_id is None:
|
||||||
raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号")
|
raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号")
|
||||||
await get_owned_account(db, user, rule_in.account_id, write=True)
|
await get_owned_account(db, user, rule_in.account_id, write=True, write_permission="rules.write")
|
||||||
|
|
||||||
sort_stmt = select(func.max(AutoReplyRule.sort_order)).where(
|
sort_stmt = select(func.max(AutoReplyRule.sort_order)).where(
|
||||||
AutoReplyRule.account_id == rule_in.account_id
|
AutoReplyRule.account_id == rule_in.account_id
|
||||||
@@ -2429,7 +2708,7 @@ async def update_rule(
|
|||||||
rule_in: RuleCreate,
|
rule_in: RuleCreate,
|
||||||
is_active: Optional[bool] = None,
|
is_active: Optional[bool] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_rules_write),
|
||||||
):
|
):
|
||||||
rule = await get_accessible_rule(db, user, rule_id, write=True)
|
rule = await get_accessible_rule(db, user, rule_id, write=True)
|
||||||
|
|
||||||
@@ -2437,7 +2716,7 @@ async def update_rule(
|
|||||||
rule_in.keyword = ""
|
rule_in.keyword = ""
|
||||||
if rule_in.account_id is None:
|
if rule_in.account_id is None:
|
||||||
raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号")
|
raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号")
|
||||||
await get_owned_account(db, user, rule_in.account_id, write=True)
|
await get_owned_account(db, user, rule_in.account_id, write=True, write_permission="rules.write")
|
||||||
|
|
||||||
rule.account_id = rule_in.account_id
|
rule.account_id = rule_in.account_id
|
||||||
rule.keyword = rule_in.keyword
|
rule.keyword = rule_in.keyword
|
||||||
@@ -2457,7 +2736,7 @@ async def update_rule(
|
|||||||
async def toggle_rule(
|
async def toggle_rule(
|
||||||
rule_id: int,
|
rule_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_rules_write),
|
||||||
):
|
):
|
||||||
rule = await get_accessible_rule(db, user, rule_id, write=True)
|
rule = await get_accessible_rule(db, user, rule_id, write=True)
|
||||||
|
|
||||||
@@ -2470,7 +2749,7 @@ async def toggle_rule(
|
|||||||
async def delete_rule(
|
async def delete_rule(
|
||||||
rule_id: int,
|
rule_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_rules_write),
|
||||||
):
|
):
|
||||||
await get_accessible_rule(db, user, rule_id, write=True)
|
await get_accessible_rule(db, user, rule_id, write=True)
|
||||||
await db.execute(delete(AutoReplyRule).where(AutoReplyRule.id == rule_id))
|
await db.execute(delete(AutoReplyRule).where(AutoReplyRule.id == rule_id))
|
||||||
@@ -2487,7 +2766,7 @@ async def move_rule(
|
|||||||
rule_id: int,
|
rule_id: int,
|
||||||
body: RuleMove,
|
body: RuleMove,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_rules_write),
|
||||||
):
|
):
|
||||||
"""在同账号规则内上移/下移一位(服务端交换排序,适配前端分页)。"""
|
"""在同账号规则内上移/下移一位(服务端交换排序,适配前端分页)。"""
|
||||||
rule = await get_accessible_rule(db, user, rule_id, write=True)
|
rule = await get_accessible_rule(db, user, rule_id, write=True)
|
||||||
@@ -2512,7 +2791,7 @@ async def move_rule(
|
|||||||
async def reorder_rules(
|
async def reorder_rules(
|
||||||
body: RuleReorder,
|
body: RuleReorder,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_rules_write),
|
||||||
):
|
):
|
||||||
if not body.rule_ids:
|
if not body.rule_ids:
|
||||||
return {"message": "No rules to reorder."}
|
return {"message": "No rules to reorder."}
|
||||||
@@ -2531,6 +2810,8 @@ async def get_logs_stats(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""消息日志全量统计(数据库计数,不受列表 limit 限制)。"""
|
"""消息日志全量统计(数据库计数,不受列表 limit 限制)。"""
|
||||||
|
if not has_permission(user.role, LOGS_READ):
|
||||||
|
raise HTTPException(status_code=403, detail="缺少权限:logs.read")
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
await get_owned_account(db, user, account_id)
|
await get_owned_account(db, user, account_id)
|
||||||
# Select only the indexed status column and calculate both counters in one
|
# Select only the indexed status column and calculate both counters in one
|
||||||
@@ -2564,6 +2845,8 @@ async def get_logs(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
if not has_permission(user.role, LOGS_READ):
|
||||||
|
raise HTTPException(status_code=403, detail="缺少权限:logs.read")
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
await get_owned_account(db, user, account_id)
|
await get_owned_account(db, user, account_id)
|
||||||
limit = max(1, min(int(limit or 50), 500))
|
limit = max(1, min(int(limit or 50), 500))
|
||||||
@@ -2585,6 +2868,8 @@ async def get_received_messages(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""接收消息原始日志:仅包含收到的消息,内容为接口/通道原样记录。"""
|
"""接收消息原始日志:仅包含收到的消息,内容为接口/通道原样记录。"""
|
||||||
|
if not has_permission(user.role, RECEIVED_MESSAGES_READ):
|
||||||
|
raise HTTPException(status_code=403, detail="缺少权限:received_messages.read")
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
await get_owned_account(db, user, account_id)
|
await get_owned_account(db, user, account_id)
|
||||||
limit = max(1, min(int(limit or 100), 500))
|
limit = max(1, min(int(limit or 100), 500))
|
||||||
@@ -2607,6 +2892,8 @@ async def get_system_logs(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""系统诊断日志:私信收发 / 实时连接 / 鉴权 等链路事件,用于排查失败原因。"""
|
"""系统诊断日志:私信收发 / 实时连接 / 鉴权 等链路事件,用于排查失败原因。"""
|
||||||
|
if not has_permission(user.role, SYSTEM_LOGS_READ):
|
||||||
|
raise HTTPException(status_code=403, detail="缺少权限:system_logs.read")
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
await get_owned_account(db, user, account_id)
|
await get_owned_account(db, user, account_id)
|
||||||
entries = system_logger.get_logs(
|
entries = system_logger.get_logs(
|
||||||
@@ -2615,7 +2902,7 @@ async def get_system_logs(
|
|||||||
category=category,
|
category=category,
|
||||||
limit=max(1, min(int(limit or 200), 1000)),
|
limit=max(1, min(int(limit or 200), 1000)),
|
||||||
)
|
)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
allowed = await owned_account_ids(db, user)
|
allowed = await owned_account_ids(db, user)
|
||||||
entries = [e for e in entries if e.get("account_id") in allowed]
|
entries = [e for e in entries if e.get("account_id") in allowed]
|
||||||
return [SystemLogResponse(**e) for e in entries]
|
return [SystemLogResponse(**e) for e in entries]
|
||||||
@@ -2624,7 +2911,7 @@ async def get_system_logs(
|
|||||||
@app.delete("/api/system-logs")
|
@app.delete("/api/system-logs")
|
||||||
async def clear_system_logs(
|
async def clear_system_logs(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_system_logs_clear),
|
||||||
):
|
):
|
||||||
"""清空系统诊断日志(内存缓冲区 + 数据库历史)。"""
|
"""清空系统诊断日志(内存缓冲区 + 数据库历史)。"""
|
||||||
system_logger.clear()
|
system_logger.clear()
|
||||||
@@ -2654,7 +2941,29 @@ async def get_account_conversations(
|
|||||||
|
|
||||||
if not conversations:
|
if not conversations:
|
||||||
async with DouyinImHttpClient(session, account_id=account_id) as http:
|
async with DouyinImHttpClient(session, account_id=account_id) as http:
|
||||||
conversations = await http.get_conversations()
|
# 用户点开会话列表:从头翻,而不是「最近半小时有动静的会话」。
|
||||||
|
# 抖音没有「一次取回全部会话」的接口,收件箱是按游标分页的,
|
||||||
|
# 所以这里花一个翻页预算;翻不完时把 DB 历史并进来补齐,
|
||||||
|
# 免得把其中一页当成完整会话列表展示给用户。
|
||||||
|
page_budget = _ui_conversation_page_budget()
|
||||||
|
conversations = await http.get_conversations(
|
||||||
|
lookback_seconds=0,
|
||||||
|
max_pages=page_budget,
|
||||||
|
)
|
||||||
|
if http.inbox_truncated:
|
||||||
|
logger.info(
|
||||||
|
"Account %s conversation list truncated at %d pages; "
|
||||||
|
"merging local history",
|
||||||
|
account_id,
|
||||||
|
page_budget,
|
||||||
|
)
|
||||||
|
known = {
|
||||||
|
str(c.get("conversation_id") or "") for c in conversations
|
||||||
|
}
|
||||||
|
my_uid = session.my_uid or 0
|
||||||
|
for item in await _conversations_from_logs(db, account_id, my_uid):
|
||||||
|
if str(item.get("conversation_id") or "") not in known:
|
||||||
|
conversations.append(item)
|
||||||
|
|
||||||
if not conversations:
|
if not conversations:
|
||||||
my_uid = session.my_uid or 0
|
my_uid = session.my_uid or 0
|
||||||
@@ -2665,6 +2974,7 @@ async def get_account_conversations(
|
|||||||
session.cookie_header(),
|
session.cookie_header(),
|
||||||
session.web_protect_str,
|
session.web_protect_str,
|
||||||
session.keys_str,
|
session.keys_str,
|
||||||
|
user_agent=session.user_agent or "",
|
||||||
)
|
)
|
||||||
my_uid = auth.get_uid() or 0
|
my_uid = auth.get_uid() or 0
|
||||||
conversations = await _conversations_from_logs(db, account_id, my_uid)
|
conversations = await _conversations_from_logs(db, account_id, my_uid)
|
||||||
@@ -2688,9 +2998,9 @@ async def upload_message_image(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_messages_write),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(db, user, account_id, write=True, write_permission="messages.write")
|
||||||
if not file.content_type or not file.content_type.startswith("image/"):
|
if not file.content_type or not file.content_type.startswith("image/"):
|
||||||
raise HTTPException(status_code=400, detail="仅支持上传图片文件")
|
raise HTTPException(status_code=400, detail="仅支持上传图片文件")
|
||||||
|
|
||||||
@@ -2779,9 +3089,9 @@ async def send_account_message(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
body: SendMessageRequest,
|
body: SendMessageRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_messages_write),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(db, user, account_id, write=True, write_permission="messages.write")
|
||||||
|
|
||||||
content = normalize_outgoing_content(
|
content = normalize_outgoing_content(
|
||||||
content=body.content or "",
|
content=body.content or "",
|
||||||
@@ -2796,6 +3106,9 @@ async def send_account_message(
|
|||||||
raise HTTPException(status_code=400, detail="消息内容不能为空")
|
raise HTTPException(status_code=400, detail="消息内容不能为空")
|
||||||
if not body.conversation_id:
|
if not body.conversation_id:
|
||||||
raise HTTPException(status_code=400, detail="conversation_id 不能为空")
|
raise HTTPException(status_code=400, detail="conversation_id 不能为空")
|
||||||
|
expected_peer_uid = str(body.peer_uid or "").strip()
|
||||||
|
if expected_peer_uid and not expected_peer_uid.isdigit():
|
||||||
|
raise HTTPException(status_code=400, detail="peer_uid 必须是数字用户 ID")
|
||||||
|
|
||||||
worker = manager.workers.get(account_id)
|
worker = manager.workers.get(account_id)
|
||||||
session = _build_account_im_session(account)
|
session = _build_account_im_session(account)
|
||||||
@@ -2807,13 +3120,21 @@ async def send_account_message(
|
|||||||
async def _do_send() -> bool:
|
async def _do_send() -> bool:
|
||||||
nonlocal last_error
|
nonlocal last_error
|
||||||
if worker and worker._im_service:
|
if worker and worker._im_service:
|
||||||
ok = await worker._im_service.send_message(body.conversation_id, content)
|
ok = await worker._im_service.send_message(
|
||||||
|
body.conversation_id,
|
||||||
|
content,
|
||||||
|
expected_peer_uid=expected_peer_uid,
|
||||||
|
)
|
||||||
last_error = worker._im_service.last_error or ""
|
last_error = worker._im_service.last_error or ""
|
||||||
if ok:
|
if ok:
|
||||||
await _persist_im_session_data(account_id, worker._im_service.session, db)
|
await _persist_im_session_data(account_id, worker._im_service.session, db)
|
||||||
return ok
|
return ok
|
||||||
async with DouyinImHttpClient(session, account_id=account_id) as http:
|
async with DouyinImHttpClient(session, account_id=account_id) as http:
|
||||||
ok = await http.send_text_message(body.conversation_id, content)
|
ok = await http.send_text_message(
|
||||||
|
body.conversation_id,
|
||||||
|
content,
|
||||||
|
expected_peer_uid=expected_peer_uid,
|
||||||
|
)
|
||||||
last_error = http.last_error or ""
|
last_error = http.last_error or ""
|
||||||
if ok:
|
if ok:
|
||||||
session.conv_meta = http.session.conv_meta
|
session.conv_meta = http.session.conv_meta
|
||||||
@@ -2882,6 +3203,9 @@ async def send_account_message(
|
|||||||
db.add(failed_log)
|
db.add(failed_log)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
normalized_last_error = (last_error or "").upper()
|
||||||
|
session_kicked = "DECISION=KICK" in normalized_last_error
|
||||||
|
invalid_request = "INVALID_REQUEST" in normalized_last_error
|
||||||
need_browser = (
|
need_browser = (
|
||||||
not session.keys_str
|
not session.keys_str
|
||||||
or not session.web_protect_str
|
or not session.web_protect_str
|
||||||
@@ -2889,11 +3213,14 @@ async def send_account_message(
|
|||||||
or "ticket" in (last_error or "")
|
or "ticket" in (last_error or "")
|
||||||
or "签名密钥" in (last_error or "")
|
or "签名密钥" in (last_error or "")
|
||||||
or "web_protect" in (last_error or "")
|
or "web_protect" in (last_error or "")
|
||||||
or last_error == "INVALID_REQUEST"
|
or invalid_request
|
||||||
|
or session_kicked
|
||||||
)
|
)
|
||||||
if need_browser:
|
if need_browser:
|
||||||
msg = last_error or "缺少 IM 签名密钥"
|
msg = last_error or "缺少 IM 签名密钥"
|
||||||
if last_error == "INVALID_REQUEST":
|
if session_kicked:
|
||||||
|
msg = "抖音已踢下当前 IM 登录态(decision=KICK),请停止托管后用浏览器模式重新登录并打开私信页"
|
||||||
|
elif invalid_request:
|
||||||
msg = "IM 会话创建失败(INVALID_REQUEST),请停止托管后用浏览器模式重新登录并打开私信页"
|
msg = "IM 会话创建失败(INVALID_REQUEST),请停止托管后用浏览器模式重新登录并打开私信页"
|
||||||
return SendMessageResponse(
|
return SendMessageResponse(
|
||||||
success=False,
|
success=False,
|
||||||
|
|||||||
@@ -53,12 +53,42 @@ def add_index_if_missing(
|
|||||||
conn.execute(text(f"CREATE INDEX {index_name} ON {table} ({safe_columns})"))
|
conn.execute(text(f"CREATE INDEX {index_name} ON {table} ({safe_columns})"))
|
||||||
|
|
||||||
|
|
||||||
|
def widen_mysql_text_columns(conn, table: str, columns: tuple[str, ...]) -> None:
|
||||||
|
"""Upgrade large account payloads from TEXT to LONGTEXT on MySQL.
|
||||||
|
|
||||||
|
SQLite and PostgreSQL TEXT values are not limited to 64 KiB, while MySQL
|
||||||
|
TEXT is. Browser storage_state and full-page QR/captcha screenshots can
|
||||||
|
legitimately exceed that size.
|
||||||
|
"""
|
||||||
|
if _dialect(conn) != "mysql":
|
||||||
|
return
|
||||||
|
allowed = {"cookie_data", "im_session_data", "qr_code_base64"}
|
||||||
|
try:
|
||||||
|
reflected = {
|
||||||
|
item["name"]: str(item.get("type") or "").upper()
|
||||||
|
for item in inspect(conn).get_columns(table)
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
for column in columns:
|
||||||
|
if column not in allowed or column not in reflected:
|
||||||
|
continue
|
||||||
|
if reflected[column] == "LONGTEXT":
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
text(f"ALTER TABLE {table} MODIFY COLUMN {column} LONGTEXT NULL")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def migrate_accounts_table(conn) -> None:
|
def migrate_accounts_table(conn) -> None:
|
||||||
add_column_if_missing(
|
add_column_if_missing(
|
||||||
conn,
|
conn,
|
||||||
"accounts",
|
"accounts",
|
||||||
"cookie_data",
|
"cookie_data",
|
||||||
{"default": "ALTER TABLE accounts ADD COLUMN cookie_data TEXT"},
|
{
|
||||||
|
"default": "ALTER TABLE accounts ADD COLUMN cookie_data TEXT",
|
||||||
|
"mysql": "ALTER TABLE accounts ADD COLUMN cookie_data LONGTEXT",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
add_column_if_missing(
|
add_column_if_missing(
|
||||||
conn,
|
conn,
|
||||||
@@ -73,7 +103,10 @@ def migrate_accounts_table(conn) -> None:
|
|||||||
conn,
|
conn,
|
||||||
"accounts",
|
"accounts",
|
||||||
"im_session_data",
|
"im_session_data",
|
||||||
{"default": "ALTER TABLE accounts ADD COLUMN im_session_data TEXT"},
|
{
|
||||||
|
"default": "ALTER TABLE accounts ADD COLUMN im_session_data TEXT",
|
||||||
|
"mysql": "ALTER TABLE accounts ADD COLUMN im_session_data LONGTEXT",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
add_column_if_missing(
|
add_column_if_missing(
|
||||||
conn,
|
conn,
|
||||||
@@ -114,6 +147,18 @@ def migrate_accounts_table(conn) -> None:
|
|||||||
"user_agent",
|
"user_agent",
|
||||||
{"default": "ALTER TABLE accounts ADD COLUMN user_agent TEXT"},
|
{"default": "ALTER TABLE accounts ADD COLUMN user_agent TEXT"},
|
||||||
)
|
)
|
||||||
|
add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"accounts",
|
||||||
|
"egress_public_ip",
|
||||||
|
{"default": "ALTER TABLE accounts ADD COLUMN egress_public_ip VARCHAR(64)"},
|
||||||
|
)
|
||||||
|
add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"accounts",
|
||||||
|
"egress_auto_attempts",
|
||||||
|
{"default": "ALTER TABLE accounts ADD COLUMN egress_auto_attempts INTEGER DEFAULT 1"},
|
||||||
|
)
|
||||||
add_column_if_missing(
|
add_column_if_missing(
|
||||||
conn,
|
conn,
|
||||||
"accounts",
|
"accounts",
|
||||||
@@ -126,6 +171,11 @@ def migrate_accounts_table(conn) -> None:
|
|||||||
"douyin_uid",
|
"douyin_uid",
|
||||||
{"default": "ALTER TABLE accounts ADD COLUMN douyin_uid VARCHAR(64)"},
|
{"default": "ALTER TABLE accounts ADD COLUMN douyin_uid VARCHAR(64)"},
|
||||||
)
|
)
|
||||||
|
widen_mysql_text_columns(
|
||||||
|
conn,
|
||||||
|
"accounts",
|
||||||
|
("cookie_data", "im_session_data", "qr_code_base64"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def migrate_account_videos_table(conn) -> None:
|
def migrate_account_videos_table(conn) -> None:
|
||||||
@@ -196,6 +246,54 @@ def migrate_rules_table(conn) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_roles_table(conn) -> None:
|
||||||
|
"""Ensure roles table exists (create_all usually handles this; keep as safety net)."""
|
||||||
|
try:
|
||||||
|
insp = inspect(conn)
|
||||||
|
if insp.has_table("roles"):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
dialect = _dialect(conn)
|
||||||
|
if dialect == "postgresql":
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE roles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
label VARCHAR(100) NOT NULL,
|
||||||
|
description VARCHAR(255),
|
||||||
|
is_system BOOLEAN DEFAULT FALSE,
|
||||||
|
is_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
permissions TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
label VARCHAR(100) NOT NULL,
|
||||||
|
description VARCHAR(255),
|
||||||
|
is_system BOOLEAN DEFAULT 0,
|
||||||
|
is_admin BOOLEAN DEFAULT 0,
|
||||||
|
permissions TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add_index_if_missing(conn, "roles", "ix_roles_code", ("code",))
|
||||||
|
|
||||||
|
|
||||||
def migrate_users_table(conn) -> None:
|
def migrate_users_table(conn) -> None:
|
||||||
add_column_if_missing(
|
add_column_if_missing(
|
||||||
conn,
|
conn,
|
||||||
@@ -227,6 +325,12 @@ def migrate_users_table(conn) -> None:
|
|||||||
"max_accounts",
|
"max_accounts",
|
||||||
{"default": "ALTER TABLE users ADD COLUMN max_accounts INTEGER DEFAULT 3"},
|
{"default": "ALTER TABLE users ADD COLUMN max_accounts INTEGER DEFAULT 3"},
|
||||||
)
|
)
|
||||||
|
add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"users",
|
||||||
|
"created_by",
|
||||||
|
{"default": "ALTER TABLE users ADD COLUMN created_by INTEGER"},
|
||||||
|
)
|
||||||
cols = _table_columns(conn, "users")
|
cols = _table_columns(conn, "users")
|
||||||
if not cols:
|
if not cols:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Index, Text, UniqueConstraint
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Index, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||||
from sqlalchemy.orm import relationship, validates
|
from sqlalchemy.orm import relationship, validates
|
||||||
from .database import Base
|
from .database import Base
|
||||||
from utils.log_limits import bound_error_log_content, bound_message_log_content
|
from utils.log_limits import bound_error_log_content, bound_message_log_content
|
||||||
|
|
||||||
|
|
||||||
|
class Role(Base):
|
||||||
|
"""Assignable role with a fixed permission-code list.
|
||||||
|
|
||||||
|
``users.role`` stores ``Role.code``. Built-in roles are seeded on startup;
|
||||||
|
custom roles are owned by admins via the users.manage / roles.manage permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "roles"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
code = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
label = Column(String(100), nullable=False)
|
||||||
|
description = Column(String(255), nullable=True)
|
||||||
|
is_system = Column(Boolean, default=False)
|
||||||
|
# Global data scope + unlimited account quota. Only the built-in admin
|
||||||
|
# role may be true; custom roles are always own-scoped.
|
||||||
|
is_admin = Column(Boolean, default=False)
|
||||||
|
permissions = Column(Text, nullable=False, default="[]") # JSON string list
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -13,11 +36,18 @@ class User(Base):
|
|||||||
email = Column(String(255), unique=True, index=True, nullable=True)
|
email = Column(String(255), unique=True, index=True, nullable=True)
|
||||||
password_hash = Column(String(255), nullable=False)
|
password_hash = Column(String(255), nullable=False)
|
||||||
display_name = Column(String(100), nullable=True)
|
display_name = Column(String(100), nullable=True)
|
||||||
role = Column(String(20), default="operator", index=True) # admin, operator, viewer
|
role = Column(String(50), default="operator", index=True) # roles.code
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
email_verified = Column(Boolean, default=False)
|
email_verified = Column(Boolean, default=False)
|
||||||
email_verified_at = Column(DateTime, nullable=True)
|
email_verified_at = Column(DateTime, nullable=True)
|
||||||
max_accounts = Column(Integer, default=3)
|
max_accounts = Column(Integer, default=3)
|
||||||
|
# User who created this account via「用户管理」; null for self-register / seeded.
|
||||||
|
created_by = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
@@ -79,15 +109,17 @@ class Account(Base):
|
|||||||
phone = Column(String(20), nullable=True) # 绑定的手机号(可选)
|
phone = Column(String(20), nullable=True) # 绑定的手机号(可选)
|
||||||
status = Column(String(50), default="offline") # offline, logging_in, online, error
|
status = Column(String(50), default="offline") # offline, logging_in, online, error
|
||||||
cookie_path = Column(String(255), nullable=True) # 存储 cookie/session 的路径
|
cookie_path = Column(String(255), nullable=True) # 存储 cookie/session 的路径
|
||||||
cookie_data = Column(Text, nullable=True) # Playwright storage_state JSON
|
cookie_data = Column(Text().with_variant(LONGTEXT(), "mysql"), nullable=True) # Playwright storage_state JSON
|
||||||
cookie_updated_at = Column(DateTime, nullable=True) # Cookie 最近更新时间
|
cookie_updated_at = Column(DateTime, nullable=True) # Cookie 最近更新时间
|
||||||
im_session_data = Column(Text, nullable=True) # IM 直连会话 (WS URL / device_id 等)
|
im_session_data = Column(Text().with_variant(LONGTEXT(), "mysql"), nullable=True) # IM 直连会话 (WS URL / device_id 等)
|
||||||
reply_delay_seconds = Column(Integer, default=0) # 账号回复排队间隔;0/NULL=继承系统默认
|
reply_delay_seconds = Column(Integer, default=0) # 账号回复排队间隔;0/NULL=继承系统默认
|
||||||
reply_cooldown_seconds = Column(Integer, nullable=True) # 自动回复冷却秒数;NULL=继承全局设置
|
reply_cooldown_seconds = Column(Integer, nullable=True) # 自动回复冷却秒数;NULL=继承全局设置
|
||||||
follow_welcome_enabled = Column(Boolean, default=False) # 新粉丝关注后自动发送欢迎语
|
follow_welcome_enabled = Column(Boolean, default=False) # 新粉丝关注后自动发送欢迎语
|
||||||
follow_welcome_content = Column(Text, nullable=True) # 关注欢迎语内容(空=不发)
|
follow_welcome_content = Column(Text, nullable=True) # 关注欢迎语内容(空=不发)
|
||||||
user_agent = Column(Text, nullable=True) # 伪装设备头(User-Agent),空=默认
|
user_agent = Column(Text, nullable=True) # 伪装设备头(User-Agent),空=默认
|
||||||
qr_code_base64 = Column(Text, nullable=True) # 当前登录二维码的 base64 字符串
|
egress_public_ip = Column(String(64), nullable=True) # 指定公网出口;空=自动选择
|
||||||
|
egress_auto_attempts = Column(Integer, nullable=False, default=1) # 发送失败时最多串行尝试的出口数
|
||||||
|
qr_code_base64 = Column(Text().with_variant(LONGTEXT(), "mysql"), nullable=True) # 当前登录二维码的 base64 字符串
|
||||||
error_message = Column(Text, nullable=True) # 错误信息
|
error_message = Column(Text, nullable=True) # 错误信息
|
||||||
quota_disabled = Column(Boolean, default=False, index=True) # 额度不足被停用
|
quota_disabled = Column(Boolean, default=False, index=True) # 额度不足被停用
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth.dependencies import get_current_user, require_admin
|
from auth.dependencies import get_current_user, require_permission
|
||||||
|
from auth.permissions import ORDERS_CREATE, ORDERS_READ, PAYMENTS_MANAGE
|
||||||
|
from auth.roles import has_permission
|
||||||
from auth.system_settings import load_settings
|
from auth.system_settings import load_settings
|
||||||
from models.database import get_db
|
from models.database import get_db
|
||||||
from models.models import User
|
from models.models import User
|
||||||
@@ -20,6 +22,18 @@ from .schemas import (
|
|||||||
router = APIRouter(prefix="/api/payments", tags=["payments"])
|
router = APIRouter(prefix="/api/payments", tags=["payments"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_orders_access(user: User) -> None:
|
||||||
|
if has_permission(user.role, ORDERS_READ) or has_permission(user.role, PAYMENTS_MANAGE):
|
||||||
|
return
|
||||||
|
raise HTTPException(status_code=403, detail="缺少权限:orders.read")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_orders_create(user: User) -> None:
|
||||||
|
if has_permission(user.role, ORDERS_CREATE) or has_permission(user.role, PAYMENTS_MANAGE):
|
||||||
|
return
|
||||||
|
raise HTTPException(status_code=403, detail="缺少权限:orders.create")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=PaymentConfigResponse)
|
@router.get("/config", response_model=PaymentConfigResponse)
|
||||||
async def get_payment_config(
|
async def get_payment_config(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -35,6 +49,7 @@ async def create_payment_order(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_require_orders_create(user)
|
||||||
order, demo_mode = await service.create_order(db, user, body.slots, body.channel)
|
order, demo_mode = await service.create_order(db, user, body.slots, body.channel)
|
||||||
return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=demo_mode))
|
return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=demo_mode))
|
||||||
|
|
||||||
@@ -48,6 +63,7 @@ async def list_payment_orders(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_require_orders_access(user)
|
||||||
if status and status not in service.ORDER_STATUSES:
|
if status and status not in service.ORDER_STATUSES:
|
||||||
raise HTTPException(status_code=400, detail="无效的订单状态")
|
raise HTTPException(status_code=400, detail="无效的订单状态")
|
||||||
data = await service.list_orders(
|
data = await service.list_orders(
|
||||||
@@ -67,6 +83,7 @@ async def get_payment_order(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_require_orders_access(user)
|
||||||
settings = await load_settings(db)
|
settings = await load_settings(db)
|
||||||
order = await service.get_user_order(db, user, order_no)
|
order = await service.get_user_order(db, user, order_no)
|
||||||
demo_mode = settings.payment_demo_mode and not settings.payment_channel_available(order.channel)
|
demo_mode = settings.payment_demo_mode and not settings.payment_channel_available(order.channel)
|
||||||
@@ -79,6 +96,7 @@ async def simulate_payment_order(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_require_orders_create(user)
|
||||||
settings = await load_settings(db)
|
settings = await load_settings(db)
|
||||||
order = await service.simulate_pay(db, user, order_no)
|
order = await service.simulate_pay(db, user, order_no)
|
||||||
return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=settings.payment_demo_mode))
|
return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=settings.payment_demo_mode))
|
||||||
@@ -89,7 +107,7 @@ async def admin_update_payment_order_status(
|
|||||||
order_no: str,
|
order_no: str,
|
||||||
body: AdminUpdateOrderStatusRequest,
|
body: AdminUpdateOrderStatusRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(PAYMENTS_MANAGE)),
|
||||||
):
|
):
|
||||||
order = await service.admin_update_order_status(db, order_no, body.status)
|
order = await service.admin_update_order_status(db, order_no, body.status)
|
||||||
return PaymentOrderListItem(**order)
|
return PaymentOrderListItem(**order)
|
||||||
@@ -99,7 +117,7 @@ async def admin_update_payment_order_status(
|
|||||||
async def admin_delete_payment_order(
|
async def admin_delete_payment_order(
|
||||||
order_no: str,
|
order_no: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_permission(PAYMENTS_MANAGE)),
|
||||||
):
|
):
|
||||||
await service.admin_delete_order(db, order_no)
|
await service.admin_delete_order(db, order_no)
|
||||||
return MessageResponse(message="订单已删除")
|
return MessageResponse(message="订单已删除")
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ from sqlalchemy import func, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth.account_quota import default_stop_worker, sync_user_account_quota
|
from auth.account_quota import default_stop_worker, sync_user_account_quota
|
||||||
from auth.roles import is_admin
|
from auth.roles import has_global_scope, has_permission, is_admin
|
||||||
|
from auth.permissions import ORDERS_READ, PAYMENTS_MANAGE
|
||||||
from auth.system_settings import SystemSettingsData, load_settings
|
from auth.system_settings import SystemSettingsData, load_settings
|
||||||
from models.models import PaymentOrder, User
|
from models.models import PaymentOrder, User
|
||||||
from . import alipay, wechat
|
from . import alipay, wechat
|
||||||
@@ -347,7 +348,12 @@ async def list_orders(
|
|||||||
page_size = max(1, min(100, page_size))
|
page_size = max(1, min(100, page_size))
|
||||||
|
|
||||||
filters = []
|
filters = []
|
||||||
if not is_admin(current_user.role):
|
# Global order list for built-in admin, payments.manage, or data.scope_all.
|
||||||
|
if not (
|
||||||
|
is_admin(current_user.role)
|
||||||
|
or has_permission(current_user.role, PAYMENTS_MANAGE)
|
||||||
|
or has_global_scope(current_user.role)
|
||||||
|
):
|
||||||
filters.append(PaymentOrder.user_id == current_user.id)
|
filters.append(PaymentOrder.user_id == current_user.id)
|
||||||
if status:
|
if status:
|
||||||
filters.append(PaymentOrder.status == status)
|
filters.append(PaymentOrder.status == status)
|
||||||
|
|||||||
@@ -10,6 +10,33 @@ from utils.cookie_store import analyze_cookie
|
|||||||
|
|
||||||
logger = logging.getLogger("credential")
|
logger = logging.getLogger("credential")
|
||||||
|
|
||||||
|
CREDENTIAL_EGRESS_PUBLIC_IP_KEY = "credential_egress_public_ip"
|
||||||
|
|
||||||
|
|
||||||
|
def credential_egress_mismatch(
|
||||||
|
cookie_data: Optional[str],
|
||||||
|
selected_public_ip: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""Compare historical browser egress metadata for diagnostics only.
|
||||||
|
|
||||||
|
This is not an authentication check: a different or missing local marker
|
||||||
|
cannot prove that cookies are invalid. Callers must keep the credentials
|
||||||
|
and use normal validation instead of forcing a reset or browser login.
|
||||||
|
"""
|
||||||
|
if not cookie_data:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
storage = json.loads(cookie_data)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
if not isinstance(storage, dict):
|
||||||
|
return False
|
||||||
|
selected = str(selected_public_ip or "").strip()
|
||||||
|
if CREDENTIAL_EGRESS_PUBLIC_IP_KEY not in storage:
|
||||||
|
return bool(selected)
|
||||||
|
stored = str(storage.get(CREDENTIAL_EGRESS_PUBLIC_IP_KEY) or "").strip()
|
||||||
|
return stored != selected
|
||||||
|
|
||||||
|
|
||||||
def _should_reset_credentials(assessment: dict) -> bool:
|
def _should_reset_credentials(assessment: dict) -> bool:
|
||||||
"""凭证全面失效时需清空 Cookie/IM 数据并重新登录。"""
|
"""凭证全面失效时需清空 Cookie/IM 数据并重新登录。"""
|
||||||
@@ -52,7 +79,19 @@ def build_im_session_from_storage(
|
|||||||
session.keys_str = saved.keys_str
|
session.keys_str = saved.keys_str
|
||||||
if saved.web_protect_str and not session.web_protect_str:
|
if saved.web_protect_str and not session.web_protect_str:
|
||||||
session.web_protect_str = saved.web_protect_str
|
session.web_protect_str = saved.web_protect_str
|
||||||
if saved.my_uid and not session.my_uid:
|
# A UID verified from the account profile must win over collector
|
||||||
|
# guesses such as web_runtime_security_uid. Persisting this flag
|
||||||
|
# keeps API/manual-send builders on the same identity as hosting.
|
||||||
|
if saved.uid_verified and saved.my_uid:
|
||||||
|
session.my_uid = saved.my_uid
|
||||||
|
# device_id 必须与 my_uid 指向同一账号:protobuf/frontier 的
|
||||||
|
# device_id 优先取 session.device_id,凭证里残留的旧设备号
|
||||||
|
# (如 www 域 web_runtime_security_uid)会导致 device_id != my_uid
|
||||||
|
# -> 安全网关 decision=KICK。用已核验 UID 同步 device_id。
|
||||||
|
if str(session.device_id or "") != str(saved.my_uid):
|
||||||
|
session.device_id = str(saved.my_uid)
|
||||||
|
session.uid_verified = True
|
||||||
|
elif saved.my_uid and not session.my_uid:
|
||||||
session.my_uid = saved.my_uid
|
session.my_uid = saved.my_uid
|
||||||
if saved.device_id and not session.device_id:
|
if saved.device_id and not session.device_id:
|
||||||
session.device_id = saved.device_id
|
session.device_id = saved.device_id
|
||||||
@@ -190,6 +229,7 @@ async def assess_account_credential(
|
|||||||
im_session_data: Optional[str] = None,
|
im_session_data: Optional[str] = None,
|
||||||
*,
|
*,
|
||||||
startup_priority: bool = False,
|
startup_priority: bool = False,
|
||||||
|
egress_public_ip: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
cookie_info = analyze_cookie(cookie_data)
|
cookie_info = analyze_cookie(cookie_data)
|
||||||
result = {
|
result = {
|
||||||
@@ -215,6 +255,18 @@ async def assess_account_credential(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
session = build_im_session_from_storage(storage, im_session_data)
|
session = build_im_session_from_storage(storage, im_session_data)
|
||||||
|
selected_public_ip = str(egress_public_ip or "").strip()
|
||||||
|
if selected_public_ip:
|
||||||
|
try:
|
||||||
|
from rpa_engine.egress_channels import resolve_fixed_channel
|
||||||
|
|
||||||
|
route = await resolve_fixed_channel(selected_public_ip)
|
||||||
|
session.egress_public_ip = selected_public_ip
|
||||||
|
session.egress_source_ip = str(route.source_ip or "")
|
||||||
|
except Exception as exc:
|
||||||
|
result["message"] = f"指定公网通道 {selected_public_ip} 当前不可用:{exc}"
|
||||||
|
result["login_mode"] = "browser"
|
||||||
|
return result
|
||||||
result["has_sessionid"] = has_im_session_token(session)
|
result["has_sessionid"] = has_im_session_token(session)
|
||||||
|
|
||||||
if not cookie_info.get("cookie_valid"):
|
if not cookie_info.get("cookie_valid"):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import requests
|
from rpa_engine.egress_channels import source_bound_requests_session
|
||||||
from .dy_util import (
|
from .dy_util import (
|
||||||
trans_cookies,
|
trans_cookies,
|
||||||
generate_msToken,
|
generate_msToken,
|
||||||
@@ -63,8 +63,16 @@ class DouyinAuth:
|
|||||||
self.uid = None
|
self.uid = None
|
||||||
self.msToken = None
|
self.msToken = None
|
||||||
self.web_id = None
|
self.web_id = None
|
||||||
|
self.source_ip = ""
|
||||||
|
self.user_agent = None
|
||||||
|
|
||||||
def perepare_auth(self, cookieStr: str, web_protect_: str = "", keys_: str = ""):
|
def perepare_auth(
|
||||||
|
self,
|
||||||
|
cookieStr: str,
|
||||||
|
web_protect_: str = "",
|
||||||
|
keys_: str = "",
|
||||||
|
user_agent: str = "",
|
||||||
|
):
|
||||||
self.cookie = trans_cookies(cookieStr)
|
self.cookie = trans_cookies(cookieStr)
|
||||||
self.cookie_str = cookieStr
|
self.cookie_str = cookieStr
|
||||||
self.msToken = self.cookie["msToken"] if "msToken" in self.cookie else generate_msToken()
|
self.msToken = self.cookie["msToken"] if "msToken" in self.cookie else generate_msToken()
|
||||||
@@ -88,6 +96,11 @@ class DouyinAuth:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"keys parse failed: {e}")
|
logger.debug(f"keys parse failed: {e}")
|
||||||
|
|
||||||
|
if user_agent:
|
||||||
|
# 让签名上下文记住调用方 UA:query_my_uid / generate_webid 等后续
|
||||||
|
# 请求会复用它,避免退回硬编码 DEFAULT_USER_AGENT 造成 UA 不一致。
|
||||||
|
self.user_agent = user_agent
|
||||||
|
|
||||||
def is_sign_ready(self) -> bool:
|
def is_sign_ready(self) -> bool:
|
||||||
return bool(
|
return bool(
|
||||||
self.private_key
|
self.private_key
|
||||||
@@ -108,12 +121,15 @@ class DouyinAuth:
|
|||||||
session.cookie_header(),
|
session.cookie_header(),
|
||||||
session.web_protect_str,
|
session.web_protect_str,
|
||||||
session.keys_str,
|
session.keys_str,
|
||||||
|
user_agent=session.user_agent or DEFAULT_USER_AGENT,
|
||||||
)
|
)
|
||||||
auth.web_id = session.web_id or session.device_id or None
|
auth.web_id = session.web_id or session.device_id or None
|
||||||
auth.user_agent = session.user_agent or DEFAULT_USER_AGENT
|
# device_id 是设备注册号(query/user 的 id),不是账号 UID;
|
||||||
|
# my_uid 只作为最后兜底,由 resolve_proto_device_id 内部处理。
|
||||||
auth.device_id = resolve_proto_device_id(
|
auth.device_id = resolve_proto_device_id(
|
||||||
session.device_id, session.web_id, session.my_uid
|
session.device_id, session.web_id, session.my_uid
|
||||||
)
|
)
|
||||||
|
auth.source_ip = str(getattr(session, "egress_source_ip", "") or "")
|
||||||
# web_protect 缺 client_cert 时,才用 frontier 抓包证书兜底(不覆盖 ts_sign)
|
# web_protect 缺 client_cert 时,才用 frontier 抓包证书兜底(不覆盖 ts_sign)
|
||||||
if not auth.client_cert and getattr(session, "sdk_cert", ""):
|
if not auth.client_cert and getattr(session, "sdk_cert", ""):
|
||||||
auth.client_cert = normalize_client_cert(session.sdk_cert)
|
auth.client_cert = normalize_client_cert(session.sdk_cert)
|
||||||
@@ -138,9 +154,10 @@ class DouyinAuth:
|
|||||||
return self.uid
|
return self.uid
|
||||||
|
|
||||||
def query_my_uid(self) -> int:
|
def query_my_uid(self) -> int:
|
||||||
|
ua = self.user_agent or DEFAULT_USER_AGENT
|
||||||
url = 'https://www.douyin.com/aweme/v1/web/query/user/'
|
url = 'https://www.douyin.com/aweme/v1/web/query/user/'
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": DEFAULT_USER_AGENT,
|
"User-Agent": ua,
|
||||||
"Referer": "https://www.douyin.com/",
|
"Referer": "https://www.douyin.com/",
|
||||||
"Accept": "application/json, text/plain, */*",
|
"Accept": "application/json, text/plain, */*",
|
||||||
}
|
}
|
||||||
@@ -155,9 +172,17 @@ class DouyinAuth:
|
|||||||
"msToken": self.msToken
|
"msToken": self.msToken
|
||||||
}
|
}
|
||||||
query = splice_url(params)
|
query = splice_url(params)
|
||||||
abogus = generate_a_bogus(query, user_agent=DEFAULT_USER_AGENT)
|
abogus = generate_a_bogus(query, user_agent=ua)
|
||||||
params['a_bogus'] = abogus
|
params['a_bogus'] = abogus
|
||||||
|
|
||||||
resp = requests.get(url, params=params, headers=headers, cookies=self.cookie, verify=False, timeout=10)
|
with source_bound_requests_session(self.source_ip) as client:
|
||||||
|
resp = client.get(
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
cookies=self.cookie,
|
||||||
|
verify=False,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
resp_json = resp.json()
|
resp_json = resp.json()
|
||||||
return int(resp_json['user_uid'])
|
return int(resp_json['user_uid'])
|
||||||
|
|||||||
@@ -45,3 +45,26 @@ def normalize_conversation_id(conversation_id: str, my_uid: int) -> str:
|
|||||||
if peer_uid and my_uid:
|
if peer_uid and my_uid:
|
||||||
return build_conversation_id(my_uid, peer_uid)
|
return build_conversation_id(my_uid, peer_uid)
|
||||||
return (conversation_id or "").strip()
|
return (conversation_id or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def conversation_belongs_to(conversation_id: str, my_uid: int) -> bool:
|
||||||
|
"""判断单聊会话是否属于 my_uid 本人。
|
||||||
|
|
||||||
|
托管多个账号时,一条属于别的账号的会话(例如 frontier 长连接按设备号寻址
|
||||||
|
造成的跨账号推送)一旦流进本账号的处理链路,resolve_peer_uid 会把末段当成
|
||||||
|
「对方」、normalize_conversation_id 再拼成 0:1:{本账号}:{别人的好友},于是
|
||||||
|
本账号就把消息发给了另一个账号的好友。这里给出唯一的归属判据。
|
||||||
|
|
||||||
|
无法判定时一律返回 True(保守放行):缺 my_uid、群聊、裸 UID 等形态本来就
|
||||||
|
不带参与方信息。只有两个参与方都已知、且都不是本账号时才判定为不属于本账号。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
uid = int(my_uid or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return True
|
||||||
|
if not uid:
|
||||||
|
return True
|
||||||
|
parts = parse_conversation_parts(conversation_id)
|
||||||
|
if not parts:
|
||||||
|
return True
|
||||||
|
return uid in parts
|
||||||
|
|||||||
@@ -123,17 +123,24 @@ def generate_fake_webid(random_length=19):
|
|||||||
return random_str
|
return random_str
|
||||||
|
|
||||||
|
|
||||||
def generate_webid(auth=None, url=""):
|
def generate_webid(auth=None, url="", user_agent=""):
|
||||||
# 优先用已采集到的 web_id(避免每次发送都发起一次阻塞的 HTTP 请求,导致事件循环卡顿)
|
# 优先用已采集到的 web_id(避免每次发送都发起一次阻塞的 HTTP 请求,导致事件循环卡顿)
|
||||||
cached = getattr(auth, "web_id", None) if auth is not None else None
|
cached = getattr(auth, "web_id", None) if auth is not None else None
|
||||||
if cached:
|
if cached:
|
||||||
return str(cached)
|
return str(cached)
|
||||||
if url == "":
|
if url == "":
|
||||||
url = "https://www.douyin.com/discover?modal_id=7376449060384935209"
|
url = "https://www.douyin.com/discover?modal_id=7376449060384935209"
|
||||||
|
# UA 优先级:显式参数 > auth.user_agent(from_im_session / perepare_auth 已带)> 全局默认。
|
||||||
|
# 必须与 a_bogus 签名、其余请求头使用同一个 UA,否则服务端重算失配 -> 7911。
|
||||||
|
ua = (
|
||||||
|
user_agent
|
||||||
|
or (getattr(auth, "user_agent", "") if auth is not None else "")
|
||||||
|
or DEFAULT_USER_AGENT
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
from .auth import DouyinAuth
|
from .auth import DouyinAuth
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": DEFAULT_USER_AGENT,
|
"User-Agent": ua,
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
||||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||||
"upgrade-insecure-requests": "1"
|
"upgrade-insecure-requests": "1"
|
||||||
|
|||||||
@@ -1,183 +1,188 @@
|
|||||||
"""抖音网页版「粉丝列表」拉取,用于检测新粉丝(关注欢迎语功能)。
|
"""抖音网页版「粉丝列表」拉取,用于检测新粉丝(关注欢迎语功能)。
|
||||||
|
|
||||||
复用与 peer_profile / account_profile 相同的 a_bogus + msToken + cookie 签名方式,
|
复用与 peer_profile / account_profile 相同的 a_bogus + msToken + cookie 签名方式,
|
||||||
调用 https://www.douyin.com/aweme/v1/web/user/follower/list/ 拉取本账号最近的粉丝。
|
调用 https://www.douyin.com/aweme/v1/web/user/follower/list/ 拉取本账号最近的粉丝。
|
||||||
|
|
||||||
返回的每个粉丝含:uid / sec_uid / nickname / follow_status / follower_status。
|
返回的每个粉丝含:uid / sec_uid / nickname / follow_status / follower_status。
|
||||||
其中 follow_status 表示「我」与对方的关系:0=未关注 1=我已关注 2=互相关注(互关)。
|
其中 follow_status 表示「我」与对方的关系:0=未关注 1=我已关注 2=互相关注(互关)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .dy_util import (
|
from .dy_util import (
|
||||||
DEFAULT_USER_AGENT,
|
DEFAULT_USER_AGENT,
|
||||||
generate_a_bogus,
|
generate_a_bogus,
|
||||||
generate_msToken,
|
generate_msToken,
|
||||||
generate_webid,
|
generate_webid,
|
||||||
splice_url,
|
splice_url,
|
||||||
)
|
)
|
||||||
from .auth import DouyinAuth
|
from .auth import DouyinAuth
|
||||||
|
|
||||||
logger = logging.getLogger("douyin_im.follower_poll")
|
logger = logging.getLogger("douyin_im.follower_poll")
|
||||||
|
|
||||||
FOLLOWER_LIST_URL = "https://www.douyin.com/aweme/v1/web/user/follower/list/"
|
FOLLOWER_LIST_URL = "https://www.douyin.com/aweme/v1/web/user/follower/list/"
|
||||||
|
|
||||||
|
|
||||||
def _requests_proxies() -> dict | None:
|
def _requests_proxies() -> dict | None:
|
||||||
try:
|
try:
|
||||||
from rpa_engine.runtime_config import requests_proxies
|
from rpa_engine.runtime_config import requests_proxies
|
||||||
|
|
||||||
return requests_proxies()
|
return requests_proxies()
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _to_int(value: Any) -> int:
|
def _to_int(value: Any) -> int:
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _extract_followers(data: dict[str, Any]) -> list[dict[str, Any]]:
|
def _extract_followers(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
raw = data.get("followers")
|
raw = data.get("followers")
|
||||||
if not isinstance(raw, list):
|
if not isinstance(raw, list):
|
||||||
return []
|
return []
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for item in raw:
|
for item in raw:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
uid = str(item.get("uid") or item.get("user_id") or "").strip()
|
uid = str(item.get("uid") or item.get("user_id") or "").strip()
|
||||||
if not uid:
|
if not uid:
|
||||||
continue
|
continue
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"uid": uid,
|
"uid": uid,
|
||||||
"sec_uid": str(item.get("sec_uid") or item.get("sec_user_id") or "").strip(),
|
"sec_uid": str(item.get("sec_uid") or item.get("sec_user_id") or "").strip(),
|
||||||
"nickname": str(item.get("nickname") or item.get("nick_name") or "").strip(),
|
"nickname": str(item.get("nickname") or item.get("nick_name") or "").strip(),
|
||||||
# follow_status:我对对方的关系(2=互关);follower_status:对方对我的关系
|
# follow_status:我对对方的关系(2=互关);follower_status:对方对我的关系
|
||||||
"follow_status": _to_int(item.get("follow_status")),
|
"follow_status": _to_int(item.get("follow_status")),
|
||||||
"follower_status": _to_int(item.get("follower_status")),
|
"follower_status": _to_int(item.get("follower_status")),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def fetch_recent_followers_sync(
|
def fetch_recent_followers_sync(
|
||||||
session,
|
session,
|
||||||
sec_user_id: str,
|
sec_user_id: str,
|
||||||
count: int = 20,
|
count: int = 20,
|
||||||
max_time: int = 0,
|
max_time: int = 0,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""同步拉取最近粉丝(第一页)。失败返回 [],并在日志里写明原因。"""
|
"""同步拉取最近粉丝(第一页)。失败返回 [],并在日志里写明原因。"""
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
sec_user_id = (sec_user_id or "").strip()
|
sec_user_id = (sec_user_id or "").strip()
|
||||||
if not sec_user_id:
|
if not sec_user_id:
|
||||||
logger.warning("fetch followers skipped: 缺少本账号 sec_user_id")
|
logger.warning("fetch followers skipped: 缺少本账号 sec_user_id")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
auth = DouyinAuth()
|
auth = DouyinAuth()
|
||||||
auth.perepare_auth(session.cookie_header(), session.web_protect_str, session.keys_str)
|
auth.perepare_auth(
|
||||||
except Exception as exc:
|
session.cookie_header(),
|
||||||
logger.warning("fetch followers: build auth failed: %s", exc)
|
session.web_protect_str,
|
||||||
return []
|
session.keys_str,
|
||||||
|
user_agent=session.user_agent or DEFAULT_USER_AGENT,
|
||||||
ua = session.user_agent or DEFAULT_USER_AGENT
|
)
|
||||||
params = {
|
except Exception as exc:
|
||||||
"device_platform": "webapp",
|
logger.warning("fetch followers: build auth failed: %s", exc)
|
||||||
"aid": "6383",
|
return []
|
||||||
"channel": "channel_pc_web",
|
|
||||||
"sec_user_id": sec_user_id,
|
ua = session.user_agent or DEFAULT_USER_AGENT
|
||||||
"count": str(count),
|
params = {
|
||||||
"max_time": str(max_time),
|
"device_platform": "webapp",
|
||||||
"min_time": "0",
|
"aid": "6383",
|
||||||
"offset": "0",
|
"channel": "channel_pc_web",
|
||||||
"source_type": "1",
|
"sec_user_id": sec_user_id,
|
||||||
"gps_access": "0",
|
"count": str(count),
|
||||||
"address_book_access": "0",
|
"max_time": str(max_time),
|
||||||
"is_top": "1",
|
"min_time": "0",
|
||||||
"update_version_code": "170400",
|
"offset": "0",
|
||||||
"pc_client_type": "1",
|
"source_type": "1",
|
||||||
"version_code": "170400",
|
"gps_access": "0",
|
||||||
"version_name": "17.4.0",
|
"address_book_access": "0",
|
||||||
"cookie_enabled": "true",
|
"is_top": "1",
|
||||||
"screen_width": "1536",
|
"update_version_code": "170400",
|
||||||
"screen_height": "960",
|
"pc_client_type": "1",
|
||||||
"browser_language": "zh-CN",
|
"version_code": "170400",
|
||||||
"browser_platform": "Win32",
|
"version_name": "17.4.0",
|
||||||
"browser_name": "Chrome",
|
"cookie_enabled": "true",
|
||||||
"browser_version": "120.0.0.0",
|
"screen_width": "1536",
|
||||||
"browser_online": "true",
|
"screen_height": "960",
|
||||||
"os_name": "Windows",
|
"browser_language": "zh-CN",
|
||||||
"os_version": "10",
|
"browser_platform": "Win32",
|
||||||
"platform": "PC",
|
"browser_name": "Chrome",
|
||||||
"webid": generate_webid(auth, "https://www.douyin.com/"),
|
"browser_version": "120.0.0.0",
|
||||||
"verifyFp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
|
"browser_online": "true",
|
||||||
"fp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
|
"os_name": "Windows",
|
||||||
"msToken": auth.msToken or generate_msToken(),
|
"os_version": "10",
|
||||||
}
|
"platform": "PC",
|
||||||
query = splice_url(params)
|
"webid": generate_webid(auth, "https://www.douyin.com/"),
|
||||||
params["a_bogus"] = generate_a_bogus(query, user_agent=ua)
|
"verifyFp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
|
||||||
|
"fp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
|
||||||
headers = {
|
"msToken": auth.msToken or generate_msToken(),
|
||||||
"User-Agent": ua,
|
}
|
||||||
"Referer": "https://www.douyin.com/",
|
query = splice_url(params)
|
||||||
"Accept": "application/json, text/plain, */*",
|
params["a_bogus"] = generate_a_bogus(query, user_agent=ua)
|
||||||
}
|
|
||||||
try:
|
headers = {
|
||||||
resp = requests.get(
|
"User-Agent": ua,
|
||||||
FOLLOWER_LIST_URL,
|
"Referer": "https://www.douyin.com/",
|
||||||
params=params,
|
"Accept": "application/json, text/plain, */*",
|
||||||
headers=headers,
|
}
|
||||||
cookies=auth.cookie,
|
try:
|
||||||
timeout=15,
|
resp = requests.get(
|
||||||
verify=False,
|
FOLLOWER_LIST_URL,
|
||||||
proxies=_requests_proxies(),
|
params=params,
|
||||||
)
|
headers=headers,
|
||||||
try:
|
cookies=auth.cookie,
|
||||||
data = resp.json()
|
timeout=15,
|
||||||
except Exception:
|
verify=False,
|
||||||
snippet = (resp.text or "")[:200].replace("\n", " ")
|
proxies=_requests_proxies(),
|
||||||
logger.warning(
|
)
|
||||||
"fetch followers: 非 JSON 响应 (HTTP %s): %s", resp.status_code, snippet
|
try:
|
||||||
)
|
data = resp.json()
|
||||||
return []
|
except Exception:
|
||||||
if not isinstance(data, dict):
|
snippet = (resp.text or "")[:200].replace("\n", " ")
|
||||||
logger.warning("fetch followers: 响应不是 JSON 对象")
|
logger.warning(
|
||||||
return []
|
"fetch followers: 非 JSON 响应 (HTTP %s): %s", resp.status_code, snippet
|
||||||
status_code = data.get("status_code")
|
)
|
||||||
if status_code not in (None, 0):
|
return []
|
||||||
logger.warning(
|
if not isinstance(data, dict):
|
||||||
"fetch followers: status_code=%s msg=%s",
|
logger.warning("fetch followers: 响应不是 JSON 对象")
|
||||||
status_code,
|
return []
|
||||||
data.get("status_msg") or data.get("message") or "",
|
status_code = data.get("status_code")
|
||||||
)
|
if status_code not in (None, 0):
|
||||||
return []
|
logger.warning(
|
||||||
followers = _extract_followers(data)
|
"fetch followers: status_code=%s msg=%s",
|
||||||
logger.info(
|
status_code,
|
||||||
"fetch followers ok: 拿到 %s 个粉丝 (has_more=%s total=%s)",
|
data.get("status_msg") or data.get("message") or "",
|
||||||
len(followers),
|
)
|
||||||
data.get("has_more"),
|
return []
|
||||||
data.get("total"),
|
followers = _extract_followers(data)
|
||||||
)
|
logger.info(
|
||||||
return followers
|
"fetch followers ok: 拿到 %s 个粉丝 (has_more=%s total=%s)",
|
||||||
except Exception as exc:
|
len(followers),
|
||||||
logger.warning("fetch followers failed: %s", exc)
|
data.get("has_more"),
|
||||||
return []
|
data.get("total"),
|
||||||
|
)
|
||||||
|
return followers
|
||||||
async def fetch_recent_followers(
|
except Exception as exc:
|
||||||
session,
|
logger.warning("fetch followers failed: %s", exc)
|
||||||
sec_user_id: str,
|
return []
|
||||||
count: int = 20,
|
|
||||||
max_time: int = 0,
|
|
||||||
) -> list[dict[str, Any]]:
|
async def fetch_recent_followers(
|
||||||
return await asyncio.to_thread(
|
session,
|
||||||
fetch_recent_followers_sync, session, sec_user_id, count, max_time
|
sec_user_id: str,
|
||||||
)
|
count: int = 20,
|
||||||
|
max_time: int = 0,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
fetch_recent_followers_sync, session, sec_user_id, count, max_time
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from urllib.parse import unquote
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .auth import DouyinAuth
|
from .auth import DouyinAuth
|
||||||
from .dy_util import generate_a_bogus, generate_msToken, generate_webid, splice_url
|
from .dy_util import DEFAULT_USER_AGENT, generate_a_bogus, generate_msToken, generate_webid, splice_url
|
||||||
from .session import DouyinImSession, is_frontier_ws_url
|
from .session import DouyinImSession, is_frontier_ws_url
|
||||||
|
|
||||||
logger = logging.getLogger("douyin_im.frontier")
|
logger = logging.getLogger("douyin_im.frontier")
|
||||||
@@ -42,6 +42,7 @@ def fetch_device_id(session: DouyinImSession) -> str:
|
|||||||
session.cookie_header(),
|
session.cookie_header(),
|
||||||
session.web_protect_str,
|
session.web_protect_str,
|
||||||
session.keys_str,
|
session.keys_str,
|
||||||
|
user_agent=session.user_agent or DEFAULT_USER_AGENT,
|
||||||
)
|
)
|
||||||
url = "https://www.douyin.com/aweme/v1/web/query/user"
|
url = "https://www.douyin.com/aweme/v1/web/query/user"
|
||||||
headers = {
|
headers = {
|
||||||
@@ -101,11 +102,16 @@ def resolve_frontier_device_id(session: DouyinImSession) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _ws_device_id(url: str) -> str:
|
def ws_device_id(url: str) -> str:
|
||||||
|
"""frontier 推送的寻址键:设备号(不是账号 UID)。"""
|
||||||
m = re.search(r"[?&]device_id=([^&\s]+)", url or "")
|
m = re.search(r"[?&]device_id=([^&\s]+)", url or "")
|
||||||
return unquote(m.group(1)) if m else ""
|
return unquote(m.group(1)) if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
# 兼容内部旧引用
|
||||||
|
_ws_device_id = ws_device_id
|
||||||
|
|
||||||
|
|
||||||
def _ws_device_matches_session(session: DouyinImSession, url: str) -> bool:
|
def _ws_device_matches_session(session: DouyinImSession, url: str) -> bool:
|
||||||
ws_dev = _ws_device_id(url)
|
ws_dev = _ws_device_id(url)
|
||||||
if not ws_dev or not ws_dev.isdigit():
|
if not ws_dev or not ws_dev.isdigit():
|
||||||
@@ -134,12 +140,31 @@ def ensure_frontier_ws(session: DouyinImSession) -> Optional[str]:
|
|||||||
logger.info("Using captured real frontier WS URL (with sdk_cert)")
|
logger.info("Using captured real frontier WS URL (with sdk_cert)")
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
# Only fpid=9 is Douyin private messaging. Other Frontier products (for
|
||||||
|
# example fpid=971 opened by the generic /chat page shell) also handshake
|
||||||
|
# successfully but never carry this account's IM push stream.
|
||||||
|
for url in session.ws_urls:
|
||||||
|
if (
|
||||||
|
is_frontier_ws_url(url)
|
||||||
|
and "zijieapi.com" in url
|
||||||
|
and "access_key=" in url
|
||||||
|
and re.search(r"[?&]fpid=9(?:&|$)", url)
|
||||||
|
and _ws_device_matches_session(session, url)
|
||||||
|
):
|
||||||
|
session.ws_urls = [url]
|
||||||
|
logger.info("Using captured browser frontier WebSocket URL")
|
||||||
|
return url
|
||||||
|
|
||||||
for url in session.ws_urls:
|
for url in session.ws_urls:
|
||||||
if is_frontier_ws_url(url) and _ws_token_looks_encoded(url):
|
if is_frontier_ws_url(url) and _ws_token_looks_encoded(url):
|
||||||
session.ws_urls = [url]
|
session.ws_urls = [url]
|
||||||
logger.info("Using captured frontier WS URL")
|
logger.info("Using captured frontier WS URL")
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
# frontier 按 device_id 寻址推送,它不是账号 UID:抖音 query/user 返回的
|
||||||
|
# id 才是本浏览器的设备注册号(my_uid 走 DouyinAuth,两者不能互换)。
|
||||||
|
# 用 my_uid 拼出来的地址握手同样成功,但订阅的是另一个地址,
|
||||||
|
# 于是长连接一直是「连上但收不到任何私信」。
|
||||||
device_id = resolve_frontier_device_id(session)
|
device_id = resolve_frontier_device_id(session)
|
||||||
if not device_id:
|
if not device_id:
|
||||||
session.ws_urls = []
|
session.ws_urls = []
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import zlib
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from rpa_engine.egress_channels import source_bound_requests_session
|
||||||
|
|
||||||
logger = logging.getLogger("douyin_im.image_upload")
|
logger = logging.getLogger("douyin_im.image_upload")
|
||||||
|
|
||||||
_LOCAL_URL_RE = re.compile(
|
_LOCAL_URL_RE = re.compile(
|
||||||
@@ -269,10 +271,8 @@ def _decode_sts(sts_token: str) -> tuple[str, str]:
|
|||||||
return "", ""
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
def _fetch_im_upload_sts(session) -> tuple[str, str, str, str]:
|
def _fetch_im_upload_sts(session, source_ip: str = "") -> tuple[str, str, str, str]:
|
||||||
"""返回 (access_key_id, secret_access_key, sts_token, space_name)。"""
|
"""返回 (access_key_id, secret_access_key, sts_token, space_name)。"""
|
||||||
import requests
|
|
||||||
|
|
||||||
from .auth import DouyinAuth
|
from .auth import DouyinAuth
|
||||||
from .dy_util import (
|
from .dy_util import (
|
||||||
DEFAULT_USER_AGENT,
|
DEFAULT_USER_AGENT,
|
||||||
@@ -283,8 +283,13 @@ def _fetch_im_upload_sts(session) -> tuple[str, str, str, str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
auth = DouyinAuth()
|
auth = DouyinAuth()
|
||||||
auth.perepare_auth(session.cookie_header(), session.web_protect_str, session.keys_str)
|
|
||||||
ua = session.user_agent or DEFAULT_USER_AGENT
|
ua = session.user_agent or DEFAULT_USER_AGENT
|
||||||
|
auth.perepare_auth(
|
||||||
|
session.cookie_header(),
|
||||||
|
session.web_protect_str,
|
||||||
|
session.keys_str,
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"device_platform": "webapp",
|
"device_platform": "webapp",
|
||||||
@@ -328,15 +333,16 @@ def _fetch_im_upload_sts(session) -> tuple[str, str, str, str]:
|
|||||||
"Referer": "https://www.douyin.com/",
|
"Referer": "https://www.douyin.com/",
|
||||||
"Accept": "application/json, text/plain, */*",
|
"Accept": "application/json, text/plain, */*",
|
||||||
}
|
}
|
||||||
resp = requests.get(
|
with source_bound_requests_session(source_ip) as client:
|
||||||
IM_UPLOAD_CONFIG_URL,
|
resp = client.get(
|
||||||
params=params,
|
IM_UPLOAD_CONFIG_URL,
|
||||||
headers=headers,
|
params=params,
|
||||||
cookies=auth.cookie,
|
headers=headers,
|
||||||
timeout=20,
|
cookies=auth.cookie,
|
||||||
verify=False,
|
timeout=20,
|
||||||
proxies=_requests_proxies(),
|
verify=False,
|
||||||
)
|
proxies=None if source_ip else _requests_proxies(),
|
||||||
|
)
|
||||||
data = _safe_json(resp)
|
data = _safe_json(resp)
|
||||||
if data.get("error"):
|
if data.get("error"):
|
||||||
raise RuntimeError(f"获取 IM 上传配置失败:{data['error']}")
|
raise RuntimeError(f"获取 IM 上传配置失败:{data['error']}")
|
||||||
@@ -453,10 +459,8 @@ def _extract_apply_inner(data: dict[str, Any]) -> tuple[str, str, str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def _vod_apply_upload_inner(
|
def _vod_apply_upload_inner(
|
||||||
ak: str, sk: str, token: str, space: str, file_size: int
|
ak: str, sk: str, token: str, space: str, file_size: int, source_ip: str = ""
|
||||||
) -> tuple[str, str, str, str]:
|
) -> tuple[str, str, str, str]:
|
||||||
import requests
|
|
||||||
|
|
||||||
from .dy_util import DEFAULT_USER_AGENT
|
from .dy_util import DEFAULT_USER_AGENT
|
||||||
|
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
@@ -483,20 +487,21 @@ def _vod_apply_upload_inner(
|
|||||||
secret_access_key=sk,
|
secret_access_key=sk,
|
||||||
service=VOD_SERVICE,
|
service=VOD_SERVICE,
|
||||||
)
|
)
|
||||||
resp = requests.get(
|
with source_bound_requests_session(source_ip) as client:
|
||||||
f"{VOD_HOST}?{qs}",
|
resp = client.get(
|
||||||
headers={
|
f"{VOD_HOST}?{qs}",
|
||||||
"accept": "*/*",
|
headers={
|
||||||
"authorization": authorization,
|
"accept": "*/*",
|
||||||
"user-agent": DEFAULT_USER_AGENT,
|
"authorization": authorization,
|
||||||
"x-amz-date": amz_date,
|
"user-agent": DEFAULT_USER_AGENT,
|
||||||
"x-amz-security-token": token,
|
"x-amz-date": amz_date,
|
||||||
"Referer": "https://www.douyin.com/",
|
"x-amz-security-token": token,
|
||||||
},
|
"Referer": "https://www.douyin.com/",
|
||||||
timeout=30,
|
},
|
||||||
verify=False,
|
timeout=30,
|
||||||
proxies=_requests_proxies(),
|
verify=False,
|
||||||
)
|
proxies=None if source_ip else _requests_proxies(),
|
||||||
|
)
|
||||||
data = _safe_json(resp)
|
data = _safe_json(resp)
|
||||||
if data.get("error"):
|
if data.get("error"):
|
||||||
raise RuntimeError(f"申请上传地址失败:{data['error']}")
|
raise RuntimeError(f"申请上传地址失败:{data['error']}")
|
||||||
@@ -512,10 +517,14 @@ def _vod_apply_upload_inner(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _vod_upload_binary(
|
def _vod_upload_binary(
|
||||||
host: str, store_uri: str, jwt_auth: str, user_id: str, raw: bytes, session=None
|
host: str,
|
||||||
|
store_uri: str,
|
||||||
|
jwt_auth: str,
|
||||||
|
user_id: str,
|
||||||
|
raw: bytes,
|
||||||
|
session=None,
|
||||||
|
source_ip: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
import requests
|
|
||||||
|
|
||||||
from .dy_util import DEFAULT_USER_AGENT
|
from .dy_util import DEFAULT_USER_AGENT
|
||||||
|
|
||||||
crc32 = format(zlib.crc32(raw) & 0xFFFFFFFF, "08x")
|
crc32 = format(zlib.crc32(raw) & 0xFFFFFFFF, "08x")
|
||||||
@@ -530,14 +539,15 @@ def _vod_upload_binary(
|
|||||||
}
|
}
|
||||||
if user_id:
|
if user_id:
|
||||||
headers["X-Storage-U"] = str(user_id)
|
headers["X-Storage-U"] = str(user_id)
|
||||||
resp = requests.post(
|
with source_bound_requests_session(source_ip) as client:
|
||||||
url,
|
resp = client.post(
|
||||||
headers=headers,
|
url,
|
||||||
data=raw,
|
headers=headers,
|
||||||
timeout=60,
|
data=raw,
|
||||||
verify=False,
|
timeout=60,
|
||||||
proxies=_requests_proxies(),
|
verify=False,
|
||||||
)
|
proxies=None if source_ip else _requests_proxies(),
|
||||||
|
)
|
||||||
data = _safe_json(resp)
|
data = _safe_json(resp)
|
||||||
if data.get("error"):
|
if data.get("error"):
|
||||||
raise RuntimeError(f"上传图片数据失败:{data['error']}")
|
raise RuntimeError(f"上传图片数据失败:{data['error']}")
|
||||||
@@ -550,10 +560,8 @@ def _vod_upload_binary(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _vod_commit_upload_inner(
|
def _vod_commit_upload_inner(
|
||||||
ak: str, sk: str, token: str, space: str, session_key: str
|
ak: str, sk: str, token: str, space: str, session_key: str, source_ip: str = ""
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
import requests
|
|
||||||
|
|
||||||
from .dy_util import DEFAULT_USER_AGENT
|
from .dy_util import DEFAULT_USER_AGENT
|
||||||
|
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
@@ -580,23 +588,24 @@ def _vod_commit_upload_inner(
|
|||||||
signed_headers=signed_headers,
|
signed_headers=signed_headers,
|
||||||
service=VOD_SERVICE,
|
service=VOD_SERVICE,
|
||||||
)
|
)
|
||||||
resp = requests.post(
|
with source_bound_requests_session(source_ip) as client:
|
||||||
f"{VOD_HOST}?{qs}",
|
resp = client.post(
|
||||||
data=body,
|
f"{VOD_HOST}?{qs}",
|
||||||
headers={
|
data=body,
|
||||||
"accept": "*/*",
|
headers={
|
||||||
"authorization": authorization,
|
"accept": "*/*",
|
||||||
"content-type": "application/json",
|
"authorization": authorization,
|
||||||
"user-agent": DEFAULT_USER_AGENT,
|
"content-type": "application/json",
|
||||||
"x-amz-content-sha256": payload_hash,
|
"user-agent": DEFAULT_USER_AGENT,
|
||||||
"x-amz-date": amz_date,
|
"x-amz-content-sha256": payload_hash,
|
||||||
"x-amz-security-token": token,
|
"x-amz-date": amz_date,
|
||||||
"Referer": "https://www.douyin.com/",
|
"x-amz-security-token": token,
|
||||||
},
|
"Referer": "https://www.douyin.com/",
|
||||||
timeout=30,
|
},
|
||||||
verify=False,
|
timeout=30,
|
||||||
proxies=_requests_proxies(),
|
verify=False,
|
||||||
)
|
proxies=None if source_ip else _requests_proxies(),
|
||||||
|
)
|
||||||
data = _safe_json(resp)
|
data = _safe_json(resp)
|
||||||
if data.get("error"):
|
if data.get("error"):
|
||||||
raise RuntimeError(f"确认上传失败:{data['error']}")
|
raise RuntimeError(f"确认上传失败:{data['error']}")
|
||||||
@@ -613,6 +622,7 @@ def upload_im_image(
|
|||||||
*,
|
*,
|
||||||
filename: str = "image.jpg",
|
filename: str = "image.jpg",
|
||||||
content_type: str = "image/jpeg",
|
content_type: str = "image/jpeg",
|
||||||
|
source_ip: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""上传图片到抖音 IM 私信图床(VOD/zhenzhen 空间)。
|
"""上传图片到抖音 IM 私信图床(VOD/zhenzhen 空间)。
|
||||||
|
|
||||||
@@ -622,16 +632,16 @@ def upload_im_image(
|
|||||||
if not raw:
|
if not raw:
|
||||||
return {"error": "图片为空"}
|
return {"error": "图片为空"}
|
||||||
try:
|
try:
|
||||||
ak, sk, token, space = _fetch_im_upload_sts(session)
|
ak, sk, token, space = _fetch_im_upload_sts(session, source_ip)
|
||||||
host, store_uri, jwt_auth, session_key = _vod_apply_upload_inner(
|
host, store_uri, jwt_auth, session_key = _vod_apply_upload_inner(
|
||||||
ak, sk, token, space, len(raw)
|
ak, sk, token, space, len(raw), source_ip
|
||||||
)
|
)
|
||||||
if not host or not store_uri or not jwt_auth:
|
if not host or not store_uri or not jwt_auth:
|
||||||
return {"error": "申请上传地址失败:缺少 UploadHost/StoreUri/Auth"}
|
return {"error": "申请上传地址失败:缺少 UploadHost/StoreUri/Auth"}
|
||||||
|
|
||||||
user_id = str(getattr(session, "my_uid", "") or "")
|
user_id = str(getattr(session, "my_uid", "") or "")
|
||||||
_vod_upload_binary(host, store_uri, jwt_auth, user_id, raw, session)
|
_vod_upload_binary(host, store_uri, jwt_auth, user_id, raw, session, source_ip)
|
||||||
_vod_commit_upload_inner(ak, sk, token, space, session_key)
|
_vod_commit_upload_inner(ak, sk, token, space, session_key, source_ip)
|
||||||
|
|
||||||
uri = store_uri.lstrip("/")
|
uri = store_uri.lstrip("/")
|
||||||
out: dict[str, Any] = {"uri": uri, "md5": hashlib.md5(raw).hexdigest()}
|
out: dict[str, Any] = {"uri": uri, "md5": hashlib.md5(raw).hexdigest()}
|
||||||
@@ -650,7 +660,12 @@ def upload_im_image(
|
|||||||
return {"error": str(exc)}
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
def prepare_image_reply_spec(spec: dict[str, Any], session, upload_dir: str) -> tuple[dict[str, Any], str]:
|
def prepare_image_reply_spec(
|
||||||
|
spec: dict[str, Any],
|
||||||
|
session,
|
||||||
|
upload_dir: str,
|
||||||
|
source_ip: str = "",
|
||||||
|
) -> tuple[dict[str, Any], str]:
|
||||||
"""若图片仍是本地地址,则上传到抖音 CDN 并补全 uri。返回 (spec, error)。"""
|
"""若图片仍是本地地址,则上传到抖音 CDN 并补全 uri。返回 (spec, error)。"""
|
||||||
if spec.get("type") != "image":
|
if spec.get("type") != "image":
|
||||||
return spec, ""
|
return spec, ""
|
||||||
@@ -705,7 +720,13 @@ def prepare_image_reply_spec(spec: dict[str, Any], session, upload_dir: str) ->
|
|||||||
return spec, "图片地址必须是抖音 CDN 或本地上传后的地址,外部 URL 无法用于 IM 发送"
|
return spec, "图片地址必须是抖音 CDN 或本地上传后的地址,外部 URL 无法用于 IM 发送"
|
||||||
return spec, "缺少可上传的图片数据"
|
return spec, "缺少可上传的图片数据"
|
||||||
|
|
||||||
uploaded = upload_im_image(session, raw, filename=filename, content_type=content_type)
|
uploaded = upload_im_image(
|
||||||
|
session,
|
||||||
|
raw,
|
||||||
|
filename=filename,
|
||||||
|
content_type=content_type,
|
||||||
|
source_ip=source_ip,
|
||||||
|
)
|
||||||
if uploaded.get("error"):
|
if uploaded.get("error"):
|
||||||
return spec, uploaded["error"]
|
return spec, uploaded["error"]
|
||||||
if not uploaded.get("uri"):
|
if not uploaded.get("uri"):
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ def analyze_send_response(raw: bytes) -> dict:
|
|||||||
"raw_check_code": None,
|
"raw_check_code": None,
|
||||||
"delivered_with_notice": False,
|
"delivered_with_notice": False,
|
||||||
"status_reason": "",
|
"status_reason": "",
|
||||||
|
"decision": "",
|
||||||
"message": "",
|
"message": "",
|
||||||
"error_desc": "",
|
"error_desc": "",
|
||||||
"server_message_id": None,
|
"server_message_id": None,
|
||||||
@@ -169,6 +170,45 @@ def analyze_send_response(raw: bytes) -> dict:
|
|||||||
if not raw:
|
if not raw:
|
||||||
info["summary"] = "空响应"
|
info["summary"] = "空响应"
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
# 风控/登录网关有时不返回 protobuf,而是直接返回 JSON,例如:
|
||||||
|
# {"decision":"KICK"}。若继续按 protobuf 解码,JSON 的首字节“{”会被
|
||||||
|
# 误读为 wire type 3,只留下 unsupported wire type 3 这种次生错误。
|
||||||
|
stripped = raw.lstrip()
|
||||||
|
if stripped.startswith(b"{"):
|
||||||
|
try:
|
||||||
|
payload = json.loads(stripped.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
payload = None
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
decision = str(
|
||||||
|
payload.get("decision") or payload.get("decision_type") or ""
|
||||||
|
).strip()
|
||||||
|
info["decision"] = decision
|
||||||
|
info["status_code"] = payload.get("status_code")
|
||||||
|
info["raw_check_code"] = payload.get("raw_check_code")
|
||||||
|
info["message"] = str(payload.get("message") or "")
|
||||||
|
info["error_desc"] = str(
|
||||||
|
payload.get("error_desc") or payload.get("error") or ""
|
||||||
|
)
|
||||||
|
info["status_reason"] = str(
|
||||||
|
payload.get("tips") or payload.get("reason") or ""
|
||||||
|
)
|
||||||
|
summary_parts = ["JSON响应"]
|
||||||
|
if decision:
|
||||||
|
summary_parts.append(f"decision={decision}")
|
||||||
|
if info["status_code"] is not None:
|
||||||
|
summary_parts.append(f"status_code={info['status_code']}")
|
||||||
|
if info["raw_check_code"] is not None:
|
||||||
|
summary_parts.append(f"raw_check_code={info['raw_check_code']}")
|
||||||
|
if info["message"]:
|
||||||
|
summary_parts.append(f"message={info['message']}")
|
||||||
|
if info["error_desc"]:
|
||||||
|
summary_parts.append(f"error_desc={info['error_desc']}")
|
||||||
|
info["summary"] = " ".join(summary_parts)
|
||||||
|
# /message/send 的正常成功响应是 protobuf;独立 JSON 是网关级响应,
|
||||||
|
# 不能据此确认消息已经写入会话。
|
||||||
|
return info
|
||||||
try:
|
try:
|
||||||
fields = decode_fields(raw)
|
fields = decode_fields(raw)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from rpa_engine.device_profiles import resolve_user_agent
|
from rpa_engine.device_profiles import resolve_user_agent
|
||||||
|
from rpa_engine.egress_channels import resolve_fixed_channel, source_bound_requests_session
|
||||||
from .auth import DouyinAuth
|
from .auth import DouyinAuth
|
||||||
from .conv_util import resolve_peer_uid
|
from .conv_util import resolve_peer_uid
|
||||||
from .dy_util import (
|
from .dy_util import (
|
||||||
@@ -74,9 +73,14 @@ def _requests_proxies() -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def _build_auth(session: DouyinImSession) -> tuple[DouyinAuth, str]:
|
def _build_auth(session: DouyinImSession) -> tuple[DouyinAuth, str]:
|
||||||
auth = DouyinAuth()
|
|
||||||
auth.perepare_auth(session.cookie_header(), session.web_protect_str, session.keys_str)
|
|
||||||
ua = resolve_user_agent(session.user_agent or DEFAULT_USER_AGENT)
|
ua = resolve_user_agent(session.user_agent or DEFAULT_USER_AGENT)
|
||||||
|
auth = DouyinAuth()
|
||||||
|
auth.perepare_auth(
|
||||||
|
session.cookie_header(),
|
||||||
|
session.web_protect_str,
|
||||||
|
session.keys_str,
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
return auth, ua
|
return auth, ua
|
||||||
|
|
||||||
|
|
||||||
@@ -99,6 +103,7 @@ def fetch_peer_profile_sync(
|
|||||||
session: DouyinImSession,
|
session: DouyinImSession,
|
||||||
peer_uid: int | str,
|
peer_uid: int | str,
|
||||||
account_id: int = 0,
|
account_id: int = 0,
|
||||||
|
source_ip: str = "",
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
uid = str(peer_uid or "").strip()
|
uid = str(peer_uid or "").strip()
|
||||||
if not uid.isdigit():
|
if not uid.isdigit():
|
||||||
@@ -156,21 +161,22 @@ def fetch_peer_profile_sync(
|
|||||||
"https://www.douyin.com/aweme/v1/web/im/user/info/",
|
"https://www.douyin.com/aweme/v1/web/im/user/info/",
|
||||||
]
|
]
|
||||||
|
|
||||||
proxies = _requests_proxies()
|
proxies = None if source_ip else _requests_proxies()
|
||||||
for url in endpoints:
|
for url in endpoints:
|
||||||
try:
|
try:
|
||||||
params = dict(base_params)
|
params = dict(base_params)
|
||||||
query = splice_url(params)
|
query = splice_url(params)
|
||||||
params["a_bogus"] = generate_a_bogus(query, user_agent=ua)
|
params["a_bogus"] = generate_a_bogus(query, user_agent=ua)
|
||||||
resp = requests.get(
|
with source_bound_requests_session(source_ip) as client:
|
||||||
url,
|
resp = client.get(
|
||||||
params=params,
|
url,
|
||||||
headers=headers,
|
params=params,
|
||||||
cookies=auth.cookie,
|
headers=headers,
|
||||||
verify=False,
|
cookies=auth.cookie,
|
||||||
timeout=12,
|
verify=False,
|
||||||
proxies=proxies,
|
timeout=12,
|
||||||
)
|
proxies=proxies,
|
||||||
|
)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
extracted = _extract_profile_from_payload(data)
|
extracted = _extract_profile_from_payload(data)
|
||||||
if extracted.get("uid") and not result["uid"]:
|
if extracted.get("uid") and not result["uid"]:
|
||||||
@@ -199,12 +205,21 @@ async def fetch_peer_profile(
|
|||||||
from .traffic_control import get_traffic_controller
|
from .traffic_control import get_traffic_controller
|
||||||
|
|
||||||
controller = get_traffic_controller()
|
controller = get_traffic_controller()
|
||||||
|
source_ip = str(getattr(session, "egress_source_ip", "") or "").strip()
|
||||||
|
selected_public_ip = str(getattr(session, "egress_public_ip", "") or "").strip()
|
||||||
|
if selected_public_ip and not source_ip:
|
||||||
|
try:
|
||||||
|
route = await resolve_fixed_channel(selected_public_ip)
|
||||||
|
source_ip = str(route.source_ip or "")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("peer profile egress resolution failed: %s", exc)
|
||||||
async with controller.background_slot(account_id, "peer profile"):
|
async with controller.background_slot(account_id, "peer profile"):
|
||||||
return await asyncio.to_thread(
|
return await asyncio.to_thread(
|
||||||
fetch_peer_profile_sync,
|
fetch_peer_profile_sync,
|
||||||
session,
|
session,
|
||||||
peer_uid,
|
peer_uid,
|
||||||
account_id,
|
account_id,
|
||||||
|
source_ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ class ProtoBuilder:
|
|||||||
request.sdk_cert = normalize_client_cert(auth.client_cert or "")
|
request.sdk_cert = normalize_client_cert(auth.client_cert or "")
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_read_request(auth, cmd):
|
||||||
|
"""读接口(收件箱/会话消息)的 Request 信封。
|
||||||
|
|
||||||
|
Request.token 必须是 x_tt_token cookie。build_normal_request 填的是
|
||||||
|
auth.ticket(bd-ticket-guard 票据):长度合法,服务端照样回
|
||||||
|
status_code=0 "OK",但把调用方当成匿名用户,body 恒为空——和「收件箱
|
||||||
|
里没有消息」完全无法区分。实测同一请求只换 token:
|
||||||
|
auth.ticket -> 73 字节 0 条;x_tt_token -> 113KB 47 条。
|
||||||
|
发送接口另有签名,沿用 build_normal_request,不在此处改动。
|
||||||
|
"""
|
||||||
|
request = ProtoBuilder.build_normal_request(auth, cmd)
|
||||||
|
cookies = getattr(auth, "cookie", None) or {}
|
||||||
|
token = str(cookies.get("x_tt_token") or "").strip()
|
||||||
|
if token:
|
||||||
|
request.token = token
|
||||||
|
return request
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_create_conversation_request(auth, toId, myId):
|
def build_create_conversation_request(auth, toId, myId):
|
||||||
request = ProtoBuilder.build_normal_request(auth, 609)
|
request = ProtoBuilder.build_normal_request(auth, 609)
|
||||||
|
|||||||
@@ -204,9 +204,46 @@ def extract_json_objects(raw: bytes | str) -> list[dict]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_push_frame(frame) -> bool:
|
||||||
|
"""判断这段字节确实是 frontier 的 PushFrame 信封。
|
||||||
|
|
||||||
|
真实帧一定带 service/method 和 frontier 自己的 headers/traceid;随手一段
|
||||||
|
二进制偶尔也能被 protobuf 宽松解析成 PushFrame,那种不算。
|
||||||
|
"""
|
||||||
|
return bool(
|
||||||
|
frame.service
|
||||||
|
or frame.method
|
||||||
|
or frame.payloadType
|
||||||
|
or frame.payloadEncoding
|
||||||
|
or frame.logIdNew
|
||||||
|
or len(frame.headersList)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_push_frame_payload(frame) -> bytes:
|
||||||
|
"""取出 PushFrame 内层负载,按 payloadEncoding 解压。
|
||||||
|
|
||||||
|
frontier 会用 gzip 压缩 payload;直接把压缩字节喂给 Response.ParseFromString
|
||||||
|
只会抛异常并被吞掉,整条私信就此丢失。
|
||||||
|
"""
|
||||||
|
body = bytes(frame.payload or b"")
|
||||||
|
if not body:
|
||||||
|
return b""
|
||||||
|
encoding = str(frame.payloadEncoding or "").lower()
|
||||||
|
if encoding in ("gzip", "gz"):
|
||||||
|
try:
|
||||||
|
return gzip.decompress(body)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to gunzip frontier frame payload: %s", exc)
|
||||||
|
return body
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
def parse_ws_payload(raw: bytes | str) -> list[dict]:
|
def parse_ws_payload(raw: bytes | str) -> list[dict]:
|
||||||
"""解析 WebSocket 二进制帧,返回标准化消息 dict 列表"""
|
"""解析 WebSocket 二进制帧,返回标准化消息 dict 列表"""
|
||||||
messages = []
|
messages = []
|
||||||
|
frame_payload = b""
|
||||||
|
is_push_frame = False
|
||||||
|
|
||||||
# 尝试 Protobuf 解包
|
# 尝试 Protobuf 解包
|
||||||
if isinstance(raw, bytes):
|
if isinstance(raw, bytes):
|
||||||
@@ -214,9 +251,14 @@ def parse_ws_payload(raw: bytes | str) -> list[dict]:
|
|||||||
from .static import Live_pb2, Response_pb2
|
from .static import Live_pb2, Response_pb2
|
||||||
frame = Live_pb2.PushFrame()
|
frame = Live_pb2.PushFrame()
|
||||||
frame.ParseFromString(raw)
|
frame.ParseFromString(raw)
|
||||||
if frame.payloadType == 'pb':
|
is_push_frame = _looks_like_push_frame(frame)
|
||||||
|
frame_payload = _decode_push_frame_payload(frame)
|
||||||
|
# payloadType 不再作为判据:现网 frontier 帧会带 'pb'、'text/json'
|
||||||
|
# 或空值,之前只认 'pb' 会把其余帧整帧丢弃。真正的判据是解出来
|
||||||
|
# 有没有 new_message_notify;解不出就照旧走下面的 JSON/文本兜底。
|
||||||
|
if frame_payload:
|
||||||
response = Response_pb2.Response()
|
response = Response_pb2.Response()
|
||||||
response.ParseFromString(frame.payload)
|
response.ParseFromString(frame_payload)
|
||||||
body = response.body
|
body = response.body
|
||||||
if body.HasField("new_message_notify"):
|
if body.HasField("new_message_notify"):
|
||||||
notify = body.new_message_notify
|
notify = body.new_message_notify
|
||||||
@@ -306,6 +348,11 @@ def parse_ws_payload(raw: bytes | str) -> list[dict]:
|
|||||||
|
|
||||||
if isinstance(raw, str):
|
if isinstance(raw, str):
|
||||||
payloads = [raw.encode("utf-8", errors="ignore")]
|
payloads = [raw.encode("utf-8", errors="ignore")]
|
||||||
|
elif is_push_frame:
|
||||||
|
# 已确认是 frontier PushFrame:只解析它的内层负载。整帧字节里还有
|
||||||
|
# seqId / traceid / payloadType 等元数据,拿去做纯文本兜底会把每条
|
||||||
|
# 「连接建立」等控制帧误当成一条用户私信记录并触发一次自动回复。
|
||||||
|
payloads = [frame_payload] if frame_payload else []
|
||||||
else:
|
else:
|
||||||
payloads = [raw]
|
payloads = [raw]
|
||||||
# 尝试 gzip 解压(frontier 常见)
|
# 尝试 gzip 解压(frontier 常见)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ from .reply_queue import AccountReplyQueue
|
|||||||
from .traffic_control import get_traffic_controller
|
from .traffic_control import get_traffic_controller
|
||||||
|
|
||||||
from .reply_payload import format_reply_display, serialize_reply_log
|
from .reply_payload import format_reply_display, serialize_reply_log
|
||||||
from .conv_util import resolve_peer_uid
|
from . import hosted_registry
|
||||||
|
from .conv_util import conversation_belongs_to, resolve_peer_uid
|
||||||
from .peer_profile import (
|
from .peer_profile import (
|
||||||
enrich_conversation_item,
|
enrich_conversation_item,
|
||||||
fetch_peer_profile,
|
fetch_peer_profile,
|
||||||
@@ -320,6 +321,7 @@ class DouyinImService:
|
|||||||
reply_cooldown_seconds: Optional[int] = None,
|
reply_cooldown_seconds: Optional[int] = None,
|
||||||
cooldown_resolver: Optional[Callable[[], Awaitable[int]]] = None,
|
cooldown_resolver: Optional[Callable[[], Awaitable[int]]] = None,
|
||||||
refresh_credentials: Optional[Callable[[], Awaitable[bool]]] = None,
|
refresh_credentials: Optional[Callable[[], Awaitable[bool]]] = None,
|
||||||
|
send_fallback: Optional[Callable[[str, str], Awaitable[tuple[bool, str]]]] = None,
|
||||||
follow_tick: Optional[Callable[[], Awaitable[None]]] = None,
|
follow_tick: Optional[Callable[[], Awaitable[None]]] = None,
|
||||||
on_session_invalid: Optional[Callable[[str], Awaitable[None]]] = None,
|
on_session_invalid: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||||
on_ready: Optional[ReadyFn] = None,
|
on_ready: Optional[ReadyFn] = None,
|
||||||
@@ -331,12 +333,16 @@ class DouyinImService:
|
|||||||
self.account_id = account_id
|
self.account_id = account_id
|
||||||
# 由 worker 注入:周期性检测新粉丝并发送关注欢迎语(约每 60s 触发一次)
|
# 由 worker 注入:周期性检测新粉丝并发送关注欢迎语(约每 60s 触发一次)
|
||||||
self.follow_tick = follow_tick
|
self.follow_tick = follow_tick
|
||||||
# 由 worker 注入:检测到 IM 登录失效(INVALID_REQUEST)时回调,用于自动下线
|
# 由 worker 注入:检测到 IM 登录失效(INVALID_REQUEST/KICK)时回调,用于自动下线
|
||||||
self.on_session_invalid = on_session_invalid
|
self.on_session_invalid = on_session_invalid
|
||||||
self._on_ready = on_ready
|
self._on_ready = on_ready
|
||||||
self._ready_notified = False
|
self._ready_notified = False
|
||||||
self._session_invalid_strikes = 0
|
self._session_invalid_strikes = 0
|
||||||
self._session_invalid_fired = False
|
self._session_invalid_fired = False
|
||||||
|
# A keepalive browser may refresh cookies/security material while an
|
||||||
|
# outbound reply is being prepared. Serialize the short credential
|
||||||
|
# hand-off with sends so one request never mixes old and new state.
|
||||||
|
self._session_lock = asyncio.Lock()
|
||||||
self.reply_delay_seconds = max(0, int(reply_delay_seconds or 0))
|
self.reply_delay_seconds = max(0, int(reply_delay_seconds or 0))
|
||||||
# 实时解析账号排队间隔:账号专属优先,否则使用系统默认值。
|
# 实时解析账号排队间隔:账号专属优先,否则使用系统默认值。
|
||||||
self._reply_delay_resolver = reply_delay_resolver
|
self._reply_delay_resolver = reply_delay_resolver
|
||||||
@@ -354,15 +360,27 @@ class DouyinImService:
|
|||||||
self._cooldown_resolver = cooldown_resolver
|
self._cooldown_resolver = cooldown_resolver
|
||||||
# 由 worker 注入:触发后台重新采集 web_protect/keys(刷新 ts_sign),返回是否刷新成功
|
# 由 worker 注入:触发后台重新采集 web_protect/keys(刷新 ts_sign),返回是否刷新成功
|
||||||
self.refresh_credentials = refresh_credentials
|
self.refresh_credentials = refresh_credentials
|
||||||
|
# 由 worker 注入的第二套发送方案:仅当 HTTP 返回非终态的 7911
|
||||||
|
# 签名错误时,可在同一账号/同一出口的浏览器页面上下文重试一次。
|
||||||
|
# KICK 与 INVALID_REQUEST 不得重放,避免在已失效会话上继续写请求。
|
||||||
|
# 签名: async (conversation_id, content) -> (ok, detail)
|
||||||
|
self.send_fallback = send_fallback
|
||||||
self._running = False
|
self._running = False
|
||||||
self._replied_keys: set[str] = set()
|
self._replied_keys: set[str] = set()
|
||||||
self._logged_keys: set[str] = set()
|
self._logged_keys: set[str] = set()
|
||||||
self._received_logged_keys: set[str] = set()
|
self._received_logged_keys: set[str] = set()
|
||||||
|
# 已告警过的「不属于本账号」的会话,避免同一条串号会话刷屏
|
||||||
|
self._foreign_conv_logged: set[str] = set()
|
||||||
|
# 已告警过的「对方也是本系统托管账号」的 peer,避免同一对账号刷屏
|
||||||
|
self._hosted_peer_logged: set[str] = set()
|
||||||
# 每个对话/用户最近一次自动回复的时间戳(monotonic 秒),用于冷却窗口去重
|
# 每个对话/用户最近一次自动回复的时间戳(monotonic 秒),用于冷却窗口去重
|
||||||
self._last_reply_at: dict[str, float] = {}
|
self._last_reply_at: dict[str, float] = {}
|
||||||
self._conv_previews: dict[str, str] = {}
|
self._conv_previews: dict[str, str] = {}
|
||||||
self._conv_names: dict[str, str] = {} # uid/conv_id -> nickname
|
self._conv_names: dict[str, str] = {} # uid/conv_id -> nickname
|
||||||
self._conv_meta: dict[str, dict] = {} # conversation_id -> meta
|
self._conv_meta: dict[str, dict] = {} # conversation_id -> meta
|
||||||
|
# 抖音判定会话列表请求本身不合法时置位:这轮托管不再重复轮询该接口,
|
||||||
|
# 实时长连接成为唯一接收通道(已在系统日志里说明)。
|
||||||
|
self._conversation_list_unsupported = False
|
||||||
self._ws_client: Optional[DouyinImWsClient] = None
|
self._ws_client: Optional[DouyinImWsClient] = None
|
||||||
self.last_error: str = ""
|
self.last_error: str = ""
|
||||||
|
|
||||||
@@ -501,6 +519,38 @@ class DouyinImService:
|
|||||||
return f"用户{sender_uid[-6:]}" if len(sender_uid) > 6 else f"用户{sender_uid}"
|
return f"用户{sender_uid[-6:]}" if len(sender_uid) > 6 else f"用户{sender_uid}"
|
||||||
return "未知用户"
|
return "未知用户"
|
||||||
|
|
||||||
|
def _conversation_is_mine(self, conv_id: str) -> bool:
|
||||||
|
"""本账号是否为该单聊会话的参与方;不是就丢弃,绝不改写后发送。"""
|
||||||
|
my_uid = int(self.session.my_uid or 0)
|
||||||
|
if conversation_belongs_to(conv_id, my_uid):
|
||||||
|
return True
|
||||||
|
conv_key = str(conv_id or "")
|
||||||
|
logger.warning(
|
||||||
|
"Account %s dropped a message from foreign conversation %s "
|
||||||
|
"(my_uid=%s); two accounts most likely share one set of credentials",
|
||||||
|
self.account_id,
|
||||||
|
conv_key,
|
||||||
|
my_uid,
|
||||||
|
)
|
||||||
|
if conv_key not in self._foreign_conv_logged:
|
||||||
|
if len(self._foreign_conv_logged) > 200:
|
||||||
|
self._foreign_conv_logged.clear()
|
||||||
|
self._foreign_conv_logged.add(conv_key)
|
||||||
|
system_logger.record(
|
||||||
|
"已丢弃不属于本账号的私信",
|
||||||
|
detail=(
|
||||||
|
f"会话 {conv_key} 的参与方都不是本账号(uid={my_uid}),"
|
||||||
|
"该消息属于另一个账号,已丢弃且不会自动回复。"
|
||||||
|
"常见原因:多个账号的凭证来自同一台机器/同一个浏览器,"
|
||||||
|
"frontier 长连接按设备号寻址导致两个账号互相收到对方的私信。"
|
||||||
|
"请为每个账号单独采集凭证(独立浏览器配置/设备)。"
|
||||||
|
),
|
||||||
|
level="warning",
|
||||||
|
category="recv",
|
||||||
|
account_id=self.account_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
def _is_self_message(self, msg: dict) -> bool:
|
def _is_self_message(self, msg: dict) -> bool:
|
||||||
sender_uid = str(msg.get("sender_uid") or "").strip()
|
sender_uid = str(msg.get("sender_uid") or "").strip()
|
||||||
if not sender_uid or not self.session.my_uid:
|
if not sender_uid or not self.session.my_uid:
|
||||||
@@ -628,10 +678,18 @@ class DouyinImService:
|
|||||||
self,
|
self,
|
||||||
msg: dict,
|
msg: dict,
|
||||||
) -> Optional[Callable[[], Awaitable[None]]]:
|
) -> Optional[Callable[[], Awaitable[None]]]:
|
||||||
|
conv_id = msg.get("conversation_id") or ""
|
||||||
|
# 跨账号隔离:只处理本账号自己的会话。frontier 按设备号寻址推送,
|
||||||
|
# 同一台机器/同一浏览器采集出来的多个账号 device_id 可能相同,两条长连接
|
||||||
|
# 会订阅到同一个地址并互相收到对方的私信。若不在这里拦住,
|
||||||
|
# normalize_conversation_id 会把别人的会话改写成
|
||||||
|
# 0:1:{本账号}:{别人的好友},本账号就把自动回复发给了另一个账号的好友。
|
||||||
|
if not self._conversation_is_mine(conv_id):
|
||||||
|
return
|
||||||
|
|
||||||
if self._is_self_message(msg):
|
if self._is_self_message(msg):
|
||||||
return
|
return
|
||||||
|
|
||||||
conv_id = msg.get("conversation_id") or ""
|
|
||||||
sender_uid = str(msg.get("sender_uid") or "")
|
sender_uid = str(msg.get("sender_uid") or "")
|
||||||
sender = self._resolve_sender_name(msg)
|
sender = self._resolve_sender_name(msg)
|
||||||
sender_avatar = str(msg.get("sender_avatar") or "").strip()
|
sender_avatar = str(msg.get("sender_avatar") or "").strip()
|
||||||
@@ -760,6 +818,44 @@ class DouyinImService:
|
|||||||
# 防止延迟排队期间被重复加入发送队列。
|
# 防止延迟排队期间被重复加入发送队列。
|
||||||
self._replied_keys.add(key)
|
self._replied_keys.add(key)
|
||||||
|
|
||||||
|
# 对方也是本系统托管的账号:双方都会自动回复,一来一回就是无限回环。
|
||||||
|
# 这种高频互发是触发抖音风控(7911)/业务拒绝(8004)的常见根因,因此消息
|
||||||
|
# 照常记录,但不再自动回复。需要回复请用消息页手动发送。
|
||||||
|
if peer_uid and hosted_registry.is_hosted(peer_uid):
|
||||||
|
await self.log_fn(
|
||||||
|
**log_kwargs,
|
||||||
|
reply=None,
|
||||||
|
status="ignored",
|
||||||
|
error=(
|
||||||
|
f"对方(UID {peer_uid})也是本系统托管中的账号,"
|
||||||
|
"自动回复会在两个账号之间形成无限回环并触发抖音风控,已跳过;"
|
||||||
|
"如需回复请在消息页手动发送"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if content:
|
||||||
|
self._conv_previews[sender] = content
|
||||||
|
if peer_uid not in self._hosted_peer_logged:
|
||||||
|
if len(self._hosted_peer_logged) > 200:
|
||||||
|
self._hosted_peer_logged.clear()
|
||||||
|
self._hosted_peer_logged.add(peer_uid)
|
||||||
|
logger.info(
|
||||||
|
"Account %s skipped auto-reply to hosted account %s",
|
||||||
|
self.account_id,
|
||||||
|
peer_uid,
|
||||||
|
)
|
||||||
|
system_logger.record(
|
||||||
|
"自动回复已跳过(对方也是托管账号)",
|
||||||
|
detail=(
|
||||||
|
f"{sender}(UID {peer_uid})是本系统托管中的另一个账号。"
|
||||||
|
"两个托管账号互相自动回复会形成无限回环,"
|
||||||
|
"属于抖音风控(7911/8004)的高发场景,因此只记录消息、不自动回复。"
|
||||||
|
),
|
||||||
|
level="warning",
|
||||||
|
category="send",
|
||||||
|
account_id=self.account_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# 同账号、同会话只保留一个尚未发送的回复任务。后续来信只追加到
|
# 同账号、同会话只保留一个尚未发送的回复任务。后续来信只追加到
|
||||||
# 原任务详情,不改变它的发送时间、位置或已经匹配好的回复。
|
# 原任务详情,不改变它的发送时间、位置或已经匹配好的回复。
|
||||||
queue_merge_keys = self._reply_queue_merge_keys(conv_id, peer_uid)
|
queue_merge_keys = self._reply_queue_merge_keys(conv_id, peer_uid)
|
||||||
@@ -1037,6 +1133,11 @@ class DouyinImService:
|
|||||||
initial: bool = False,
|
initial: bool = False,
|
||||||
defer_handlers: bool = False,
|
defer_handlers: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
|
if self._conversation_list_unsupported:
|
||||||
|
# 抖音已明确拒绝过这个请求本身;重复调用只会每轮浪费一次请求,
|
||||||
|
# 并把同一条错误反复写进日志。原因已在首次拒绝时记录。
|
||||||
|
return []
|
||||||
|
|
||||||
controller = get_traffic_controller()
|
controller = get_traffic_controller()
|
||||||
async with controller.background_slot(
|
async with controller.background_slot(
|
||||||
self.account_id,
|
self.account_id,
|
||||||
@@ -1053,6 +1154,14 @@ class DouyinImService:
|
|||||||
account_id=self.account_id,
|
account_id=self.account_id,
|
||||||
) as http:
|
) as http:
|
||||||
conversations = await http.get_conversations(enrich_profiles=False)
|
conversations = await http.get_conversations(enrich_profiles=False)
|
||||||
|
if http.conversation_list_unsupported:
|
||||||
|
self._conversation_list_unsupported = True
|
||||||
|
logger.warning(
|
||||||
|
"Account %s disabled conversation reconciliation; "
|
||||||
|
"the realtime WebSocket is now the only receive path",
|
||||||
|
self.account_id,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
# Capture the previous preview before _index_conversations overwrites
|
# Capture the previous preview before _index_conversations overwrites
|
||||||
# _conv_meta. A conversation-list preview is not inherently a new
|
# _conv_meta. A conversation-list preview is not inherently a new
|
||||||
@@ -1406,11 +1515,63 @@ class DouyinImService:
|
|||||||
"""把指定自动回复任务移入账号紧急队列;实际发送仍由单消费者串行执行。"""
|
"""把指定自动回复任务移入账号紧急队列;实际发送仍由单消费者串行执行。"""
|
||||||
return await self._reply_queue.send_now(job_id)
|
return await self._reply_queue.send_now(job_id)
|
||||||
|
|
||||||
|
async def replace_session(self, fresh: DouyinImSession) -> None:
|
||||||
|
"""Atomically install a freshly harvested login/security session.
|
||||||
|
|
||||||
|
The running WebSocket can keep its current connection, but future
|
||||||
|
reconnects and every HTTP send must see the same refreshed object.
|
||||||
|
Account egress selection lives outside persisted IM credentials, so it
|
||||||
|
is deliberately carried over from the current runtime session.
|
||||||
|
"""
|
||||||
|
async with self._session_lock:
|
||||||
|
current = self.session
|
||||||
|
current_uid = int(getattr(current, "my_uid", 0) or 0)
|
||||||
|
fresh_uid = int(getattr(fresh, "my_uid", 0) or 0)
|
||||||
|
if current_uid and fresh_uid and current_uid != fresh_uid:
|
||||||
|
raise ValueError(
|
||||||
|
f"refusing cross-account session refresh: {current_uid} != {fresh_uid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
fresh.conv_meta = {
|
||||||
|
**dict(getattr(current, "conv_meta", {}) or {}),
|
||||||
|
**dict(getattr(fresh, "conv_meta", {}) or {}),
|
||||||
|
}
|
||||||
|
if not fresh.ws_urls:
|
||||||
|
fresh.ws_urls = list(getattr(current, "ws_urls", []) or [])
|
||||||
|
fresh.egress_public_ip = str(
|
||||||
|
getattr(current, "egress_public_ip", "") or ""
|
||||||
|
)
|
||||||
|
fresh.egress_source_ip = str(
|
||||||
|
getattr(current, "egress_source_ip", "") or ""
|
||||||
|
)
|
||||||
|
fresh.egress_auto_attempts = int(
|
||||||
|
getattr(current, "egress_auto_attempts", 1) or 1
|
||||||
|
)
|
||||||
|
self.session = fresh
|
||||||
|
if self._ws_client is not None:
|
||||||
|
self._ws_client.session = fresh
|
||||||
|
|
||||||
async def _send_text(
|
async def _send_text(
|
||||||
self,
|
self,
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
conversation_short_id: str = "",
|
conversation_short_id: str = "",
|
||||||
|
expected_peer_uid: str = "",
|
||||||
|
) -> tuple[bool, Optional[dict]]:
|
||||||
|
async with self._session_lock:
|
||||||
|
return await self._send_text_unlocked(
|
||||||
|
conversation_id,
|
||||||
|
content,
|
||||||
|
conversation_short_id=conversation_short_id,
|
||||||
|
expected_peer_uid=expected_peer_uid,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_text_unlocked(
|
||||||
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
content: str,
|
||||||
|
conversation_short_id: str = "",
|
||||||
|
expected_peer_uid: str = "",
|
||||||
) -> tuple[bool, Optional[dict]]:
|
) -> tuple[bool, Optional[dict]]:
|
||||||
"""发送一条私信;若因签名凭证失效(7911)失败,刷新 web_protect 后自动重试一次。
|
"""发送一条私信;若因签名凭证失效(7911)失败,刷新 web_protect 后自动重试一次。
|
||||||
|
|
||||||
@@ -1422,6 +1583,7 @@ class DouyinImService:
|
|||||||
conversation_id,
|
conversation_id,
|
||||||
content,
|
content,
|
||||||
conversation_short_id=conversation_short_id,
|
conversation_short_id=conversation_short_id,
|
||||||
|
expected_peer_uid=expected_peer_uid,
|
||||||
)
|
)
|
||||||
self.last_error = http.last_error
|
self.last_error = http.last_error
|
||||||
needs_refresh = http.last_send_needs_refresh
|
needs_refresh = http.last_send_needs_refresh
|
||||||
@@ -1445,31 +1607,73 @@ class DouyinImService:
|
|||||||
if refreshed:
|
if refreshed:
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# 第二套发送方案(浏览器页面内发送):仅处理非终态 7911。
|
||||||
|
# KICK/INVALID_REQUEST 会停止发送并进入下线处理,不在失效会话上重放。
|
||||||
|
upper_err = (self.last_error or "").upper()
|
||||||
|
# KICK already invalidated the login and INVALID_REQUEST is a
|
||||||
|
# protocol/session rejection. Replaying either through a browser
|
||||||
|
# fetch cannot heal it and creates another risky write. 7911 is the
|
||||||
|
# only non-terminal signing failure eligible for the browser fallback.
|
||||||
|
if self.send_fallback and "STATUS_CODE=7911" in upper_err:
|
||||||
|
try:
|
||||||
|
fb_ok, fb_detail = await self.send_fallback(conversation_id, content)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"send_fallback raised for {conversation_id}: {exc}")
|
||||||
|
fb_ok, fb_detail = False, f"浏览器兜底发送异常:{exc}"
|
||||||
|
if fb_ok:
|
||||||
|
self._session_invalid_strikes = 0
|
||||||
|
self._session_invalid_fired = False # 兜底成功说明登录仍有效,撤销自动下线
|
||||||
|
system_logger.record(
|
||||||
|
"浏览器兜底发送成功",
|
||||||
|
detail=f"会话 {conversation_id}:{fb_detail}",
|
||||||
|
level="success",
|
||||||
|
category="send",
|
||||||
|
account_id=self.account_id,
|
||||||
|
)
|
||||||
|
return True, None
|
||||||
|
system_logger.record(
|
||||||
|
"浏览器兜底发送失败",
|
||||||
|
detail=f"会话 {conversation_id}:{fb_detail}",
|
||||||
|
level="error",
|
||||||
|
category="send",
|
||||||
|
account_id=self.account_id,
|
||||||
|
)
|
||||||
await self._note_session_invalid(self.last_error)
|
await self._note_session_invalid(self.last_error)
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
async def _note_session_invalid(self, error: str) -> None:
|
async def _note_session_invalid(self, error: str) -> None:
|
||||||
"""根据发送失败原因判断 IM 是否已退出登录;连续 INVALID_REQUEST 即触发自动下线。
|
"""根据发送失败原因判断 IM 是否已退出登录,并触发自动下线。
|
||||||
|
|
||||||
INVALID_REQUEST 来自 create_conversation/发送:会话/签名被抖音判为无效,强相关于「登录失效」。
|
INVALID_REQUEST 来自 create_conversation/发送:会话/签名被抖音判为无效,强相关于「登录失效」。
|
||||||
|
decision=KICK 是安全网关明确要求终止当前登录态,一次即可确认,无需等待第二次发送。
|
||||||
而 8xxx/7xxx 等业务错误(关系/频控/内容)说明请求已到达抖音、登录仍有效,重置计数。
|
而 8xxx/7xxx 等业务错误(关系/频控/内容)说明请求已到达抖音、登录仍有效,重置计数。
|
||||||
"""
|
"""
|
||||||
err = error or ""
|
err = error or ""
|
||||||
if "INVALID_REQUEST" not in err:
|
upper_err = err.upper()
|
||||||
|
is_kicked = "DECISION=KICK" in upper_err
|
||||||
|
is_invalid_request = "INVALID_REQUEST" in upper_err
|
||||||
|
if not is_invalid_request and not is_kicked:
|
||||||
self._session_invalid_strikes = 0
|
self._session_invalid_strikes = 0
|
||||||
return
|
return
|
||||||
self._session_invalid_strikes += 1
|
self._session_invalid_strikes += 1
|
||||||
if self._session_invalid_strikes < 2 or self._session_invalid_fired:
|
threshold = 1 if is_kicked else 2
|
||||||
|
if self._session_invalid_strikes < threshold or self._session_invalid_fired:
|
||||||
return
|
return
|
||||||
self._session_invalid_fired = True
|
self._session_invalid_fired = True
|
||||||
reason = "IM 会话失效(INVALID_REQUEST),登录可能已退出"
|
if is_kicked:
|
||||||
|
reason = "抖音安全网关已踢下线(decision=KICK)"
|
||||||
|
failure_detail = "发送接口返回 decision=KICK"
|
||||||
|
else:
|
||||||
|
reason = "IM 会话失效(INVALID_REQUEST),登录可能已退出"
|
||||||
|
failure_detail = f"连续 {self._session_invalid_strikes} 次发送返回 INVALID_REQUEST"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Account {self.account_id} {reason};连续 {self._session_invalid_strikes} 次 -> 自动下线"
|
f"Account {self.account_id} {reason} -> 自动下线"
|
||||||
)
|
)
|
||||||
system_logger.record(
|
system_logger.record(
|
||||||
"IM 登录失效,自动下线",
|
"IM 登录失效,自动下线",
|
||||||
detail=f"{reason}(连续 {self._session_invalid_strikes} 次发送返回 INVALID_REQUEST)。"
|
detail=f"{reason}({failure_detail})。"
|
||||||
"请停止托管后用浏览器模式重新登录并打开私信页,再重新启动托管。",
|
"系统正在自动重登录,请留意账号卡片上的登录二维码并扫码。",
|
||||||
level="error",
|
level="error",
|
||||||
category="auth",
|
category="auth",
|
||||||
account_id=self.account_id,
|
account_id=self.account_id,
|
||||||
@@ -1481,21 +1685,31 @@ class DouyinImService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"on_session_invalid handler error: {e}")
|
logger.error(f"on_session_invalid handler error: {e}")
|
||||||
|
|
||||||
async def send_message(self, conversation_id: str, content: str) -> bool:
|
async def send_message(
|
||||||
"""手动发送私信"""
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
content: str,
|
||||||
|
expected_peer_uid: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""手动发送私信。
|
||||||
|
|
||||||
|
``expected_peer_uid`` 由调用方(消息页)指定收件人,写入点会在发出去
|
||||||
|
之前核对,避免界面按昵称匹配到同名的另一个人。
|
||||||
|
"""
|
||||||
from .conv_util import normalize_conversation_id
|
from .conv_util import normalize_conversation_id
|
||||||
from .auth import DouyinAuth
|
from .auth import DouyinAuth
|
||||||
|
from .dy_util import DEFAULT_USER_AGENT
|
||||||
|
|
||||||
auth = DouyinAuth()
|
auth = DouyinAuth()
|
||||||
auth.perepare_auth(
|
auth.perepare_auth(
|
||||||
self.session.cookie_header(),
|
self.session.cookie_header(),
|
||||||
self.session.web_protect_str,
|
self.session.web_protect_str,
|
||||||
self.session.keys_str,
|
self.session.keys_str,
|
||||||
|
user_agent=self.session.user_agent or DEFAULT_USER_AGENT,
|
||||||
)
|
)
|
||||||
if getattr(self.session, "uid_verified", False) and self.session.my_uid:
|
my_uid = self.session.my_uid
|
||||||
my_uid = self.session.my_uid
|
if not my_uid:
|
||||||
else:
|
my_uid = await asyncio.to_thread(lambda: auth.get_uid()) or 0
|
||||||
my_uid = await asyncio.to_thread(lambda: auth.get_uid()) or self.session.my_uid
|
|
||||||
if my_uid:
|
if my_uid:
|
||||||
conversation_id = normalize_conversation_id(conversation_id, my_uid)
|
conversation_id = normalize_conversation_id(conversation_id, my_uid)
|
||||||
|
|
||||||
@@ -1504,6 +1718,7 @@ class DouyinImService:
|
|||||||
conversation_id,
|
conversation_id,
|
||||||
content,
|
content,
|
||||||
conversation_short_id=str(meta.get("conversation_short_id") or ""),
|
conversation_short_id=str(meta.get("conversation_short_id") or ""),
|
||||||
|
expected_peer_uid=expected_peer_uid,
|
||||||
)
|
)
|
||||||
if sent and resolved:
|
if sent and resolved:
|
||||||
self._conv_meta[conversation_id] = {
|
self._conv_meta[conversation_id] = {
|
||||||
|
|||||||
@@ -14,9 +14,21 @@ def is_frontier_ws_url(url: str) -> bool:
|
|||||||
真实抓包里 host 可能是 frontier-im.douyin.com,也可能是
|
真实抓包里 host 可能是 frontier-im.douyin.com,也可能是
|
||||||
frontierNN-normal.zijieapi.com 这类内部别名,二者都要认。
|
frontierNN-normal.zijieapi.com 这类内部别名,二者都要认。
|
||||||
"""
|
"""
|
||||||
if not url or "token=" not in url:
|
if not url:
|
||||||
return False
|
return False
|
||||||
return "frontier-im.douyin.com" in url or ("frontier" in url and "zijieapi.com" in url)
|
parsed = urlparse(url)
|
||||||
|
host = (parsed.hostname or "").lower()
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
fpid = (query.get("fpid") or [""])[0]
|
||||||
|
legacy = host == "frontier-im.douyin.com" and "token" in query and fpid == "9"
|
||||||
|
browser_frontier = (
|
||||||
|
"frontier" in host
|
||||||
|
and host.endswith("zijieapi.com")
|
||||||
|
and "access_key" in query
|
||||||
|
and "device_id" in query
|
||||||
|
and fpid == "9"
|
||||||
|
)
|
||||||
|
return legacy or browser_frontier
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -34,12 +46,18 @@ class DouyinImSession:
|
|||||||
keys_str: str = ""
|
keys_str: str = ""
|
||||||
web_protect_str: str = ""
|
web_protect_str: str = ""
|
||||||
my_uid: int = 0
|
my_uid: int = 0
|
||||||
# my_uid 是否已用 query/user 接口核验过(采集端推断的 my_uid 可能取错 tea_cache id)
|
# my_uid 是否已由账号资料 UID 等可靠来源核验。query/user 的 user_uid
|
||||||
|
# 不是所有账号的 IM UID,不能据此覆盖已采集/已同步的 my_uid。
|
||||||
uid_verified: bool = False
|
uid_verified: bool = False
|
||||||
conv_meta: dict = field(default_factory=dict)
|
conv_meta: dict = field(default_factory=dict)
|
||||||
# 方案 A:直接复用浏览器抓到的真实 frontier 连接凭证(绕开我们自己推导 token/access_key 不准的问题)
|
# 方案 A:直接复用浏览器抓到的真实 frontier 连接凭证(绕开我们自己推导 token/access_key 不准的问题)
|
||||||
sdk_cert: str = "" # bd-ticket-guard 客户端证书(frontier sdk_cert / HTTP client-cert)
|
sdk_cert: str = "" # bd-ticket-guard 客户端证书(frontier sdk_cert / HTTP client-cert)
|
||||||
frontier_ts_sign: str = "" # 抓包得到的新鲜 ts_sign(覆盖 web_protect 里可能已过期的)
|
frontier_ts_sign: str = "" # 抓包得到的新鲜 ts_sign(覆盖 web_protect 里可能已过期的)
|
||||||
|
# 账号级公网出口配置来自 accounts 表,不写回 im_session_data,避免网络配置
|
||||||
|
# 与登录凭证重复存储。egress_source_ip 是当前服务器探测出的本地绑定地址。
|
||||||
|
egress_public_ip: str = ""
|
||||||
|
egress_source_ip: str = ""
|
||||||
|
egress_auto_attempts: int = 1
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_storage_state(cls, data: dict, extra: Optional[dict] = None) -> "DouyinImSession":
|
def from_storage_state(cls, data: dict, extra: Optional[dict] = None) -> "DouyinImSession":
|
||||||
@@ -91,6 +109,14 @@ class DouyinImSession:
|
|||||||
my_uid = _as_uid(extra.get("my_uid")) or _as_uid(data.get("my_uid"))
|
my_uid = _as_uid(extra.get("my_uid")) or _as_uid(data.get("my_uid"))
|
||||||
user_agent = str(extra.get("user_agent") or data.get("user_agent") or "").strip()
|
user_agent = str(extra.get("user_agent") or data.get("user_agent") or "").strip()
|
||||||
|
|
||||||
|
# 先整段扫描 localStorage,收集字段(避免遍历顺序导致取值不确定)。
|
||||||
|
# 关键背景:新版抖音 web 端 __tea_cache_tokens_6383 的 user_unique_id 实际存的是
|
||||||
|
# web_id(如 7678646545793812008),并非账号 UID;而 web_runtime_security_uid
|
||||||
|
# 才是账号真实 UID(如 2609567359568155)。混合登录态下若把 tea 的 user_unique_id
|
||||||
|
# 当 my_uid,会导致 device_id != my_uid,IM 发送被安全网关 KICK。
|
||||||
|
ls_sec_uid = "" # web_runtime_security_uid(最可靠的账号 UID 来源)
|
||||||
|
ls_web_id = "" # 第一个 tea 条目的 web_id/user_unique_id
|
||||||
|
ls_tea_pairs = [] # [(user_unique_id, web_id), ...] 按出现顺序
|
||||||
if not device_id or not web_id or not keys_str or not web_protect_str or not my_uid:
|
if not device_id or not web_id or not keys_str or not web_protect_str or not my_uid:
|
||||||
for origin in data.get("origins", []):
|
for origin in data.get("origins", []):
|
||||||
for entry in origin.get("localStorage", []):
|
for entry in origin.get("localStorage", []):
|
||||||
@@ -102,27 +128,42 @@ class DouyinImSession:
|
|||||||
keys_str = value
|
keys_str = value
|
||||||
if name == "security-sdk/s_sdk_sign_data_key/web_protect" and not web_protect_str:
|
if name == "security-sdk/s_sdk_sign_data_key/web_protect" and not web_protect_str:
|
||||||
web_protect_str = value
|
web_protect_str = value
|
||||||
if "tea_cache_tokens" in name and not web_id:
|
if "tea_cache_tokens" in name:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(value)
|
parsed = json.loads(value)
|
||||||
web_id = str(
|
if isinstance(parsed, dict):
|
||||||
parsed.get("web_id")
|
wid = str(parsed.get("web_id") or "")
|
||||||
or parsed.get("user_unique_id")
|
uid = str(parsed.get("user_unique_id") or "")
|
||||||
or ""
|
if not ls_web_id:
|
||||||
)
|
ls_web_id = wid or uid
|
||||||
except Exception:
|
ls_tea_pairs.append((uid, wid))
|
||||||
pass
|
|
||||||
if name == "web_runtime_security_uid" and not device_id:
|
|
||||||
if str(value or "").isdigit():
|
|
||||||
device_id = value
|
|
||||||
if "tea_cache_tokens" in name and not my_uid:
|
|
||||||
try:
|
|
||||||
parsed = json.loads(value)
|
|
||||||
uid = parsed.get("user_unique_id")
|
|
||||||
if uid and str(uid).isdigit():
|
|
||||||
my_uid = int(uid)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if name == "web_runtime_security_uid":
|
||||||
|
v = str(value or "")
|
||||||
|
if v.isdigit() and not ls_sec_uid:
|
||||||
|
ls_sec_uid = v
|
||||||
|
|
||||||
|
# web_id:extra 显式值 > localStorage tea
|
||||||
|
if not web_id:
|
||||||
|
web_id = ls_web_id
|
||||||
|
|
||||||
|
# my_uid 优先级:extra/顶层 > web_runtime_security_uid(真实账号 UID)>
|
||||||
|
# tea 的 user_unique_id(仅当与自身 web_id 不同才可信,避免误取 web_id)
|
||||||
|
if not my_uid and ls_sec_uid:
|
||||||
|
my_uid = int(ls_sec_uid)
|
||||||
|
if not my_uid:
|
||||||
|
for uid, wid in ls_tea_pairs:
|
||||||
|
if uid.isdigit() and not (wid and uid == wid):
|
||||||
|
my_uid = int(uid)
|
||||||
|
break
|
||||||
|
|
||||||
|
# device_id 优先级:extra/cookies > web_runtime_security_uid(与账号 UID 绑定)
|
||||||
|
if not device_id:
|
||||||
|
if ls_sec_uid:
|
||||||
|
device_id = ls_sec_uid
|
||||||
|
elif my_uid:
|
||||||
|
device_id = str(my_uid)
|
||||||
|
|
||||||
if not my_uid:
|
if not my_uid:
|
||||||
for item in data.get("cookies", []):
|
for item in data.get("cookies", []):
|
||||||
@@ -139,6 +180,14 @@ class DouyinImSession:
|
|||||||
elif not device_id and web_id:
|
elif not device_id and web_id:
|
||||||
device_id = web_id
|
device_id = web_id
|
||||||
|
|
||||||
|
# 最终一致性收敛:protobuf/frontier 的 device_id 优先取 session.device_id
|
||||||
|
# (resolve_proto_device_id),若凭证里残留旧设备号(如 www 域
|
||||||
|
# web_runtime_security_uid),发送时 device_id != my_uid 会被安全网关
|
||||||
|
# 判为设备指纹异常 -> decision=KICK。my_uid 此时已是权威账号 UID,
|
||||||
|
# 不一致时以 my_uid 收敛 device_id。
|
||||||
|
if my_uid and device_id and str(device_id) != str(my_uid):
|
||||||
|
device_id = str(my_uid)
|
||||||
|
|
||||||
ws_urls = list(extra.get("ws_urls") or [])
|
ws_urls = list(extra.get("ws_urls") or [])
|
||||||
|
|
||||||
# 方案 A:凭证采集工具可携带浏览器抓到的真实 frontier 连接(含 token/sdk_cert/ts_sign)。
|
# 方案 A:凭证采集工具可携带浏览器抓到的真实 frontier 连接(含 token/sdk_cert/ts_sign)。
|
||||||
@@ -188,6 +237,7 @@ class DouyinImSession:
|
|||||||
"keys_str": self.keys_str,
|
"keys_str": self.keys_str,
|
||||||
"web_protect_str": self.web_protect_str,
|
"web_protect_str": self.web_protect_str,
|
||||||
"my_uid": self.my_uid,
|
"my_uid": self.my_uid,
|
||||||
|
"uid_verified": self.uid_verified,
|
||||||
"conv_meta": self.conv_meta,
|
"conv_meta": self.conv_meta,
|
||||||
"sdk_cert": self.sdk_cert,
|
"sdk_cert": self.sdk_cert,
|
||||||
"frontier_ts_sign": self.frontier_ts_sign,
|
"frontier_ts_sign": self.frontier_ts_sign,
|
||||||
@@ -207,6 +257,7 @@ class DouyinImSession:
|
|||||||
keys_str=str(data.get("keys_str") or ""),
|
keys_str=str(data.get("keys_str") or ""),
|
||||||
web_protect_str=str(data.get("web_protect_str") or ""),
|
web_protect_str=str(data.get("web_protect_str") or ""),
|
||||||
my_uid=int(data.get("my_uid") or 0),
|
my_uid=int(data.get("my_uid") or 0),
|
||||||
|
uid_verified=bool(data.get("uid_verified", False)),
|
||||||
conv_meta=dict(data.get("conv_meta") or {}),
|
conv_meta=dict(data.get("conv_meta") or {}),
|
||||||
sdk_cert=str(data.get("sdk_cert") or ""),
|
sdk_cert=str(data.get("sdk_cert") or ""),
|
||||||
frontier_ts_sign=str(data.get("frontier_ts_sign") or ""),
|
frontier_ts_sign=str(data.get("frontier_ts_sign") or ""),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import gzip
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import weakref
|
import weakref
|
||||||
@@ -14,6 +15,31 @@ logger = logging.getLogger("douyin_im.ws")
|
|||||||
|
|
||||||
MessageHandler = Callable[[dict], Awaitable[None]]
|
MessageHandler = Callable[[dict], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_frame_metadata(payload: bytes) -> str:
|
||||||
|
"""Return non-content protobuf metadata for early connection diagnostics."""
|
||||||
|
try:
|
||||||
|
from .static import Live_pb2, Response_pb2
|
||||||
|
|
||||||
|
frame = Live_pb2.PushFrame()
|
||||||
|
frame.ParseFromString(payload)
|
||||||
|
body = bytes(frame.payload)
|
||||||
|
if str(frame.payloadEncoding or "").lower() == "gzip":
|
||||||
|
body = gzip.decompress(body)
|
||||||
|
response = Response_pb2.Response()
|
||||||
|
response.ParseFromString(body)
|
||||||
|
fields = [field.name for field, _ in response.body.ListFields()]
|
||||||
|
message = str(response.message or response.error_desc or "")[:80]
|
||||||
|
return (
|
||||||
|
f"service={frame.service} method={frame.method} "
|
||||||
|
f"encoding={frame.payloadEncoding or 'none'} "
|
||||||
|
f"type={frame.payloadType or 'none'} payload_bytes={len(body)} "
|
||||||
|
f"cmd={response.cmd} body={','.join(fields) or 'none'} "
|
||||||
|
f"status={message or 'ok'}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return f"metadata_unavailable={type(exc).__name__}"
|
||||||
|
|
||||||
# Both stages are finite. The transport queue gives the receive coroutine a
|
# Both stages are finite. The transport queue gives the receive coroutine a
|
||||||
# small amount of breathing room, while the application queue decouples Pong /
|
# small amount of breathing room, while the application queue decouples Pong /
|
||||||
# frame reads from potentially slow database and reply work. Once both fill,
|
# frame reads from potentially slow database and reply work. Once both fill,
|
||||||
@@ -77,6 +103,12 @@ _LOOP_STATES: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _LoopWsState
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# frontier 按 device_id 寻址推送:两个托管账号共用同一个设备号时,两条长连接会
|
||||||
|
# 订阅到同一个地址并互相收到对方的私信。真正的拦截在 service 的会话归属校验里,
|
||||||
|
# 这里只负责把「为什么会串号」明确告诉用户。持弱引用,账号停管后自动失效。
|
||||||
|
_FRONTIER_DEVICE_OWNERS: "dict[str, weakref.ref[DouyinImWsClient]]" = {}
|
||||||
|
|
||||||
|
|
||||||
def _get_loop_state() -> _LoopWsState:
|
def _get_loop_state() -> _LoopWsState:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
state = _LOOP_STATES.get(loop)
|
state = _LOOP_STATES.get(loop)
|
||||||
@@ -127,6 +159,10 @@ class DouyinImWsClient:
|
|||||||
self._last_connection_lifetime = 0.0
|
self._last_connection_lifetime = 0.0
|
||||||
self._message_queue: Optional[asyncio.Queue[dict]] = None
|
self._message_queue: Optional[asyncio.Queue[dict]] = None
|
||||||
self._dispatcher_task: Optional[asyncio.Task] = None
|
self._dispatcher_task: Optional[asyncio.Task] = None
|
||||||
|
self._received_frame_count = 0
|
||||||
|
self._heartbeat_ack_logged = False
|
||||||
|
self._frontier_device_id = ""
|
||||||
|
self._blocked_device_owner_id: Optional[int] = None
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
if self._task and not self._task.done():
|
if self._task and not self._task.done():
|
||||||
@@ -182,6 +218,7 @@ class DouyinImWsClient:
|
|||||||
if self._task is task:
|
if self._task is task:
|
||||||
self._task = None
|
self._task = None
|
||||||
self._connection = None
|
self._connection = None
|
||||||
|
self._release_frontier_device()
|
||||||
await self._stop_dispatcher()
|
await self._stop_dispatcher()
|
||||||
|
|
||||||
def _record_connection_system_event(
|
def _record_connection_system_event(
|
||||||
@@ -219,6 +256,7 @@ class DouyinImWsClient:
|
|||||||
account_key = int(self.account_id or 0)
|
account_key = int(self.account_id or 0)
|
||||||
state.system_log_last_at.pop((account_key, "connected"), None)
|
state.system_log_last_at.pop((account_key, "connected"), None)
|
||||||
state.system_log_last_at.pop((account_key, "retry"), None)
|
state.system_log_last_at.pop((account_key, "retry"), None)
|
||||||
|
state.system_log_last_at.pop((account_key, "device_taken"), None)
|
||||||
|
|
||||||
def _ensure_dispatcher(self) -> None:
|
def _ensure_dispatcher(self) -> None:
|
||||||
if self._dispatcher_task and not self._dispatcher_task.done():
|
if self._dispatcher_task and not self._dispatcher_task.done():
|
||||||
@@ -283,8 +321,15 @@ class DouyinImWsClient:
|
|||||||
first_attempt = False
|
first_attempt = False
|
||||||
if not connect_url:
|
if not connect_url:
|
||||||
raise RuntimeError("frontier WebSocket URL is unavailable")
|
raise RuntimeError("frontier WebSocket URL is unavailable")
|
||||||
logger.info("Connecting IM WebSocket: %s...", connect_url[:100])
|
if self._claim_frontier_device(connect_url):
|
||||||
await self._run_connection(connect_url)
|
logger.info("Connecting IM WebSocket: %s...", connect_url[:100])
|
||||||
|
await self._run_connection(connect_url)
|
||||||
|
else:
|
||||||
|
# 设备号已被另一个在跑的账号占用:绝不并连同一个推送地址,
|
||||||
|
# 本账号本轮退回 HTTP 轮询兜底(connected 保持 False,
|
||||||
|
# service 会自动切到更快的会话对账节奏),并在退避后重试,
|
||||||
|
# 等占用方停管时自动接管。
|
||||||
|
self._report_frontier_device_taken(connect_url)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -320,6 +365,76 @@ class DouyinImWsClient:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
self._release_frontier_device()
|
||||||
|
|
||||||
|
def _frontier_device_owner(self, device_id: str) -> "Optional[DouyinImWsClient]":
|
||||||
|
"""当前仍活着的设备号占用方(run 循环任务还在跑才算数)。"""
|
||||||
|
reference = _FRONTIER_DEVICE_OWNERS.get(device_id)
|
||||||
|
owner = reference() if reference is not None else None
|
||||||
|
if owner is None or owner is self:
|
||||||
|
return None
|
||||||
|
task = owner._task
|
||||||
|
if not owner._running or task is None or task.done():
|
||||||
|
return None
|
||||||
|
return owner
|
||||||
|
|
||||||
|
def _claim_frontier_device(self, url: str) -> bool:
|
||||||
|
"""独占本账号的 frontier 设备地址;已被别的账号占用时返回 False。
|
||||||
|
|
||||||
|
frontier 按 device_id 寻址推送。两个账号共用同一个设备号时,同时建连
|
||||||
|
会让两条连接互相收到对方的私信(串号的根因),且抖音也可能只保留最后
|
||||||
|
一条连接、把先连上的那个账号踢成「连着但收不到」。所以同一个设备地址
|
||||||
|
永远只允许一个账号建连,另一个账号走 HTTP 轮询兜底。
|
||||||
|
"""
|
||||||
|
from .frontier import ws_device_id
|
||||||
|
|
||||||
|
device_id = ws_device_id(url)
|
||||||
|
if not device_id:
|
||||||
|
# 判不出设备号(自建地址/异常格式)时不阻断连接,交给会话归属校验兜底。
|
||||||
|
return True
|
||||||
|
owner = self._frontier_device_owner(device_id)
|
||||||
|
if owner is not None and int(owner.account_id or 0) != int(self.account_id or 0):
|
||||||
|
self._blocked_device_owner_id = owner.account_id
|
||||||
|
return False
|
||||||
|
_FRONTIER_DEVICE_OWNERS[device_id] = weakref.ref(self)
|
||||||
|
self._frontier_device_id = device_id
|
||||||
|
self._blocked_device_owner_id = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _report_frontier_device_taken(self, url: str) -> None:
|
||||||
|
from .frontier import ws_device_id
|
||||||
|
|
||||||
|
device_id = ws_device_id(url)
|
||||||
|
owner_id = self._blocked_device_owner_id
|
||||||
|
logger.error(
|
||||||
|
"Account %s cannot open frontier device_id %s: already held by "
|
||||||
|
"account %s; falling back to HTTP polling this round",
|
||||||
|
self.account_id,
|
||||||
|
device_id,
|
||||||
|
owner_id,
|
||||||
|
)
|
||||||
|
self._record_connection_system_event(
|
||||||
|
"device_taken",
|
||||||
|
"实时接收已让出:与另一个账号共用长连接设备号",
|
||||||
|
detail=(
|
||||||
|
f"本账号与账号 {owner_id} 的 frontier 设备号相同(device_id={device_id})。"
|
||||||
|
"同一个设备地址只允许一个账号建立长连接,否则两个账号会互相收到对方的"
|
||||||
|
"私信。本账号本轮不建连,改由 HTTP 会话轮询接收(有几十秒级延迟),"
|
||||||
|
"并在对方停止托管后自动接管。"
|
||||||
|
"根治办法:为每个账号在独立的浏览器配置/设备上重新采集凭证。"
|
||||||
|
),
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _release_frontier_device(self) -> None:
|
||||||
|
device_id = self._frontier_device_id
|
||||||
|
self._frontier_device_id = ""
|
||||||
|
if not device_id:
|
||||||
|
return
|
||||||
|
reference = _FRONTIER_DEVICE_OWNERS.get(device_id)
|
||||||
|
if reference is not None and reference() is self:
|
||||||
|
_FRONTIER_DEVICE_OWNERS.pop(device_id, None)
|
||||||
|
|
||||||
def _connection_headers(self) -> list[tuple[str, str]]:
|
def _connection_headers(self) -> list[tuple[str, str]]:
|
||||||
headers = [
|
headers = [
|
||||||
("Pragma", "no-cache"),
|
("Pragma", "no-cache"),
|
||||||
@@ -331,6 +446,16 @@ class DouyinImWsClient:
|
|||||||
headers.append(("Cookie", cookie))
|
headers.append(("Cookie", cookie))
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _uses_browser_frontier(url: str) -> bool:
|
||||||
|
return "zijieapi.com" in url and "access_key=" in url
|
||||||
|
|
||||||
|
async def _run_browser_heartbeat(self, websocket) -> None:
|
||||||
|
"""Mirror Frontier's browser SDK application-level ``hi`` heartbeat."""
|
||||||
|
while self._running:
|
||||||
|
await websocket.send("hi")
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
|
||||||
async def _run_connection(self, url: str) -> None:
|
async def _run_connection(self, url: str) -> None:
|
||||||
"""Open one connection and dispatch messages sequentially.
|
"""Open one connection and dispatch messages sequentially.
|
||||||
|
|
||||||
@@ -343,6 +468,10 @@ class DouyinImWsClient:
|
|||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
connected_at: float | None = None
|
connected_at: float | None = None
|
||||||
connection: Optional[WebSocketClientProtocol] = None
|
connection: Optional[WebSocketClientProtocol] = None
|
||||||
|
heartbeat_task: Optional[asyncio.Task] = None
|
||||||
|
browser_frontier = self._uses_browser_frontier(url)
|
||||||
|
source_ip = str(getattr(self.session, "egress_source_ip", "") or "").strip()
|
||||||
|
connect_kwargs = {"local_addr": (source_ip, 0)} if source_ip else {}
|
||||||
try:
|
try:
|
||||||
async with websocket_connect(
|
async with websocket_connect(
|
||||||
url,
|
url,
|
||||||
@@ -352,7 +481,9 @@ class DouyinImWsClient:
|
|||||||
user_agent_header=self.session.user_agent,
|
user_agent_header=self.session.user_agent,
|
||||||
compression="deflate",
|
compression="deflate",
|
||||||
open_timeout=10,
|
open_timeout=10,
|
||||||
ping_interval=20,
|
# The current Douyin browser Frontier SDK uses a text ``hi``
|
||||||
|
# heartbeat instead of RFC WebSocket ping frames.
|
||||||
|
ping_interval=None if browser_frontier else 20,
|
||||||
# A handler may legitimately wait up to SQLite's 30s busy
|
# A handler may legitimately wait up to SQLite's 30s busy
|
||||||
# timeout. Leave enough headroom for queued work so a healthy
|
# timeout. Leave enough headroom for queued work so a healthy
|
||||||
# socket isn't mistaken for a dead peer during that stall.
|
# socket isn't mistaken for a dead peer during that stall.
|
||||||
@@ -363,12 +494,16 @@ class DouyinImWsClient:
|
|||||||
# receive memory genuinely bounded across hundreds of peers.
|
# receive memory genuinely bounded across hundreds of peers.
|
||||||
max_size=_INCOMING_MAX_SIZE,
|
max_size=_INCOMING_MAX_SIZE,
|
||||||
max_queue=_TRANSPORT_MAX_QUEUE,
|
max_queue=_TRANSPORT_MAX_QUEUE,
|
||||||
|
**connect_kwargs,
|
||||||
) as websocket:
|
) as websocket:
|
||||||
connection = websocket
|
connection = websocket
|
||||||
self._connection = websocket
|
self._connection = websocket
|
||||||
connected_at = loop.time()
|
connected_at = loop.time()
|
||||||
self.connected = True
|
self.connected = True
|
||||||
logger.info("IM WebSocket connected")
|
logger.info(
|
||||||
|
"IM WebSocket connected: subprotocol=%s",
|
||||||
|
getattr(websocket, "subprotocol", None) or "none",
|
||||||
|
)
|
||||||
self._record_connection_system_event(
|
self._record_connection_system_event(
|
||||||
"connected",
|
"connected",
|
||||||
"实时接收通道已连接",
|
"实时接收通道已连接",
|
||||||
@@ -376,10 +511,23 @@ class DouyinImWsClient:
|
|||||||
level="success",
|
level="success",
|
||||||
)
|
)
|
||||||
|
|
||||||
async for raw in websocket:
|
if browser_frontier:
|
||||||
if not self._running:
|
heartbeat_task = asyncio.create_task(
|
||||||
break
|
self._run_browser_heartbeat(websocket),
|
||||||
await self._dispatch(raw)
|
name=f"im-ws-heartbeat-{self.account_id or 'na'}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async for raw in websocket:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
await self._dispatch(raw)
|
||||||
|
finally:
|
||||||
|
if heartbeat_task and not heartbeat_task.done():
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
try:
|
||||||
|
await heartbeat_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
if connected_at is not None:
|
if connected_at is not None:
|
||||||
self._last_connection_lifetime = max(0.0, loop.time() - connected_at)
|
self._last_connection_lifetime = max(0.0, loop.time() - connected_at)
|
||||||
@@ -405,6 +553,11 @@ class DouyinImWsClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _dispatch(self, raw):
|
async def _dispatch(self, raw):
|
||||||
|
if raw == "hi":
|
||||||
|
if not self._heartbeat_ack_logged:
|
||||||
|
logger.info("IM WebSocket application heartbeat acknowledged")
|
||||||
|
self._heartbeat_ack_logged = True
|
||||||
|
return
|
||||||
self._ensure_dispatcher()
|
self._ensure_dispatcher()
|
||||||
queue = self._message_queue
|
queue = self._message_queue
|
||||||
if queue is None:
|
if queue is None:
|
||||||
@@ -414,6 +567,17 @@ class DouyinImWsClient:
|
|||||||
else:
|
else:
|
||||||
payload = raw
|
payload = raw
|
||||||
items = parse_ws_payload(payload)
|
items = parse_ws_payload(payload)
|
||||||
|
self._received_frame_count += 1
|
||||||
|
if self._received_frame_count <= 3:
|
||||||
|
metadata = _safe_frame_metadata(payload) if not items else "parsed-message"
|
||||||
|
logger.info(
|
||||||
|
"IM WebSocket frame received: seq=%d kind=%s bytes=%d parsed=%d %s",
|
||||||
|
self._received_frame_count,
|
||||||
|
"text" if isinstance(raw, str) else "binary",
|
||||||
|
len(payload),
|
||||||
|
len(items),
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
for item in items:
|
for item in items:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"""Discover and select server egress channels for account-bound IM traffic.
|
||||||
|
|
||||||
|
One public address may be reached through a private address on the host (for
|
||||||
|
example, an ECS secondary private IP mapped to an EIP). A channel therefore
|
||||||
|
keeps both values: ``source_ip`` is bound on the socket and ``public_ip`` is
|
||||||
|
what the remote service observes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
|
||||||
|
logger = logging.getLogger("rpa_engine.egress")
|
||||||
|
|
||||||
|
_DISCOVERY_TTL_SECONDS = 300.0
|
||||||
|
_PROBE_TIMEOUT_SECONDS = 6.0
|
||||||
|
_MAX_CHANNEL_ATTEMPTS = 8
|
||||||
|
_PROBE_URLS = (
|
||||||
|
"https://www.cloudflare.com/cdn-cgi/trace",
|
||||||
|
"https://api64.ipify.org?format=json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LocalAddress:
|
||||||
|
source_ip: str | None
|
||||||
|
interface: str
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EgressChannel:
|
||||||
|
public_ip: str
|
||||||
|
source_ip: str | None
|
||||||
|
interface: str = ""
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
return self.public_ip
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EgressSnapshot:
|
||||||
|
channels: tuple[EgressChannel, ...]
|
||||||
|
errors: tuple[str, ...]
|
||||||
|
detected_at: float
|
||||||
|
|
||||||
|
|
||||||
|
class EgressChannelUnavailable(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
_cached_snapshot: EgressSnapshot | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_attempts(value: int | None) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed = 1
|
||||||
|
return max(1, min(_MAX_CHANNEL_ATTEMPTS, parsed))
|
||||||
|
|
||||||
|
|
||||||
|
def _usable_source_ip(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(str(value or "").strip())
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
addr.version == 4
|
||||||
|
and not addr.is_loopback
|
||||||
|
and not addr.is_link_local
|
||||||
|
and not addr.is_multicast
|
||||||
|
and not addr.is_unspecified
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _linux_local_addresses() -> list[LocalAddress]:
|
||||||
|
if os.name != "posix":
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["ip", "-j", "-4", "addr", "show", "scope", "global"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=3,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
payload = json.loads(proc.stdout or "[]") if proc.returncode == 0 else []
|
||||||
|
except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
found: list[LocalAddress] = []
|
||||||
|
for item in payload if isinstance(payload, list) else []:
|
||||||
|
interface = str(item.get("ifname") or "")
|
||||||
|
for info in item.get("addr_info") or []:
|
||||||
|
source_ip = str(info.get("local") or "").strip()
|
||||||
|
if _usable_source_ip(source_ip):
|
||||||
|
found.append(LocalAddress(source_ip, interface))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _socket_local_addresses() -> list[LocalAddress]:
|
||||||
|
found: list[LocalAddress] = []
|
||||||
|
names = {socket.gethostname(), socket.getfqdn()}
|
||||||
|
for name in names:
|
||||||
|
try:
|
||||||
|
records = socket.getaddrinfo(name, None, socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for record in records:
|
||||||
|
source_ip = str(record[4][0] or "").strip()
|
||||||
|
if _usable_source_ip(source_ip):
|
||||||
|
found.append(LocalAddress(source_ip, name))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def local_address_candidates() -> list[LocalAddress]:
|
||||||
|
"""Return the default route plus each bindable global/private IPv4."""
|
||||||
|
|
||||||
|
candidates = [LocalAddress(None, "default", True)]
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in [*_linux_local_addresses(), *_socket_local_addresses()]:
|
||||||
|
source_ip = str(item.source_ip or "")
|
||||||
|
if not source_ip or source_ip in seen:
|
||||||
|
continue
|
||||||
|
seen.add(source_ip)
|
||||||
|
candidates.append(item)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_public_ip(response: httpx.Response) -> str:
|
||||||
|
text = response.text.strip()
|
||||||
|
content_type = response.headers.get("content-type", "").lower()
|
||||||
|
candidate = ""
|
||||||
|
if "json" in content_type or text.startswith("{"):
|
||||||
|
try:
|
||||||
|
candidate = str(response.json().get("ip") or "").strip()
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
candidate = ""
|
||||||
|
if not candidate:
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("ip="):
|
||||||
|
candidate = line.partition("=")[2].strip()
|
||||||
|
break
|
||||||
|
if not candidate and "\n" not in text and len(text) <= 64:
|
||||||
|
candidate = text
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(candidate)
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
return str(addr) if addr.version == 4 else ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _probe_local_address(candidate: LocalAddress) -> tuple[EgressChannel | None, str]:
|
||||||
|
transport = httpx.AsyncHTTPTransport(
|
||||||
|
local_address=candidate.source_ip,
|
||||||
|
retries=0,
|
||||||
|
)
|
||||||
|
last_error = ""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport,
|
||||||
|
timeout=httpx.Timeout(_PROBE_TIMEOUT_SECONDS),
|
||||||
|
follow_redirects=True,
|
||||||
|
trust_env=False,
|
||||||
|
) as client:
|
||||||
|
for url in _PROBE_URLS:
|
||||||
|
try:
|
||||||
|
response = await client.get(url, headers={"Accept": "text/plain, application/json"})
|
||||||
|
response.raise_for_status()
|
||||||
|
public_ip = _extract_public_ip(response)
|
||||||
|
if public_ip:
|
||||||
|
return (
|
||||||
|
EgressChannel(
|
||||||
|
public_ip=public_ip,
|
||||||
|
source_ip=candidate.source_ip,
|
||||||
|
interface=candidate.interface,
|
||||||
|
is_default=candidate.is_default,
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
last_error = "探测响应中没有 IPv4"
|
||||||
|
except Exception as exc: # one endpoint may be unavailable
|
||||||
|
last_error = str(exc) or type(exc).__name__
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
label = candidate.source_ip or "默认路由"
|
||||||
|
return None, f"{label}: {last_error or '无法访问公网探测服务'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_channels(channels: Iterable[EgressChannel]) -> tuple[EgressChannel, ...]:
|
||||||
|
by_public_ip: dict[str, EgressChannel] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for channel in channels:
|
||||||
|
existing = by_public_ip.get(channel.public_ip)
|
||||||
|
if existing is None:
|
||||||
|
by_public_ip[channel.public_ip] = channel
|
||||||
|
order.append(channel.public_ip)
|
||||||
|
continue
|
||||||
|
# Keep an explicit bindable source when possible, while preserving the
|
||||||
|
# fact that this is also the host's default public route.
|
||||||
|
if existing.source_ip is None and channel.source_ip:
|
||||||
|
by_public_ip[channel.public_ip] = EgressChannel(
|
||||||
|
public_ip=channel.public_ip,
|
||||||
|
source_ip=channel.source_ip,
|
||||||
|
interface=channel.interface,
|
||||||
|
is_default=existing.is_default or channel.is_default,
|
||||||
|
)
|
||||||
|
elif channel.is_default and not existing.is_default:
|
||||||
|
by_public_ip[channel.public_ip] = EgressChannel(
|
||||||
|
public_ip=existing.public_ip,
|
||||||
|
source_ip=existing.source_ip,
|
||||||
|
interface=existing.interface,
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
return tuple(by_public_ip[key] for key in order)
|
||||||
|
|
||||||
|
|
||||||
|
async def discover_egress_channels(*, force: bool = False) -> EgressSnapshot:
|
||||||
|
global _cached_snapshot
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
with _cache_lock:
|
||||||
|
cached = _cached_snapshot
|
||||||
|
if not force and cached and now - cached.detected_at < _DISCOVERY_TTL_SECONDS:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
candidates = await asyncio.to_thread(local_address_candidates)
|
||||||
|
results = await asyncio.gather(*(_probe_local_address(item) for item in candidates))
|
||||||
|
channels = _dedupe_channels(item[0] for item in results if item[0] is not None)
|
||||||
|
errors = tuple(item[1] for item in results if item[1])
|
||||||
|
snapshot = EgressSnapshot(channels=channels, errors=errors, detected_at=time.time())
|
||||||
|
with _cache_lock:
|
||||||
|
_cached_snapshot = snapshot
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_fixed_channel(public_ip: str) -> EgressChannel:
|
||||||
|
selected = str(public_ip or "").strip()
|
||||||
|
if not selected:
|
||||||
|
return EgressChannel(public_ip="", source_ip=None, interface="default", is_default=True)
|
||||||
|
snapshot = await discover_egress_channels()
|
||||||
|
for channel in snapshot.channels:
|
||||||
|
if channel.public_ip == selected:
|
||||||
|
return channel
|
||||||
|
raise EgressChannelUnavailable(
|
||||||
|
f"指定公网通道 {selected} 当前不可用;请在账号编辑中重新检测并选择可用通道"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_send_channels(
|
||||||
|
preferred_public_ip: str = "",
|
||||||
|
max_attempts: int = 1,
|
||||||
|
) -> list[EgressChannel]:
|
||||||
|
"""Order channels for one serial send operation.
|
||||||
|
|
||||||
|
The ordinary one-channel automatic mode deliberately avoids discovery so
|
||||||
|
a temporary outage of the probe service never blocks existing sends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
preferred = str(preferred_public_ip or "").strip()
|
||||||
|
attempts = clamp_attempts(max_attempts)
|
||||||
|
if not preferred and attempts == 1:
|
||||||
|
return [EgressChannel(public_ip="", source_ip=None, interface="default", is_default=True)]
|
||||||
|
|
||||||
|
snapshot = await discover_egress_channels()
|
||||||
|
channels = list(snapshot.channels)
|
||||||
|
if not channels:
|
||||||
|
if preferred:
|
||||||
|
raise EgressChannelUnavailable(
|
||||||
|
f"指定公网通道 {preferred} 无法探测;请检查服务器网卡、路由或公网访问"
|
||||||
|
)
|
||||||
|
return [EgressChannel(public_ip="", source_ip=None, interface="default", is_default=True)]
|
||||||
|
|
||||||
|
ordered: list[EgressChannel] = []
|
||||||
|
if preferred:
|
||||||
|
selected = next((item for item in channels if item.public_ip == preferred), None)
|
||||||
|
if selected is None:
|
||||||
|
raise EgressChannelUnavailable(
|
||||||
|
f"指定公网通道 {preferred} 当前不可用;请在账号编辑中重新检测"
|
||||||
|
)
|
||||||
|
ordered.append(selected)
|
||||||
|
else:
|
||||||
|
default = next((item for item in channels if item.is_default), None)
|
||||||
|
if default is not None:
|
||||||
|
ordered.append(default)
|
||||||
|
|
||||||
|
ordered.extend(item for item in channels if item not in ordered)
|
||||||
|
return ordered[:attempts]
|
||||||
|
|
||||||
|
|
||||||
|
class _SourceAddressAdapter(HTTPAdapter):
|
||||||
|
"""Requests adapter that binds outgoing sockets to one local IPv4."""
|
||||||
|
|
||||||
|
def __init__(self, source_ip: str, *args, **kwargs):
|
||||||
|
self._source_address = (source_ip, 0)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
|
||||||
|
pool_kwargs["source_address"] = self._source_address
|
||||||
|
return super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
|
||||||
|
|
||||||
|
def proxy_manager_for(self, proxy, **proxy_kwargs):
|
||||||
|
proxy_kwargs["source_address"] = self._source_address
|
||||||
|
return super().proxy_manager_for(proxy, **proxy_kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def source_bound_requests_session(source_ip: str | None = None) -> requests.Session:
|
||||||
|
client = requests.Session()
|
||||||
|
source = str(source_ip or "").strip()
|
||||||
|
if source:
|
||||||
|
client.trust_env = False
|
||||||
|
adapter = _SourceAddressAdapter(source)
|
||||||
|
client.mount("http://", adapter)
|
||||||
|
client.mount("https://", adapter)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def reset_egress_cache_for_tests() -> None:
|
||||||
|
global _cached_snapshot
|
||||||
|
with _cache_lock:
|
||||||
|
_cached_snapshot = None
|
||||||
@@ -1,129 +1,145 @@
|
|||||||
"""运行时环境配置:住宅代理 + 浏览器显示。
|
"""运行时环境配置:住宅代理 + 浏览器显示。
|
||||||
|
|
||||||
用于解决「部署到云服务器后」两类常见问题:
|
用于解决「部署到云服务器后」两类常见问题:
|
||||||
1. 机房 IP 触发抖音风控(7911)—— 通过 KEFU_DOUYIN_PROXY 让抖音请求走住宅代理。
|
1. 机房 IP 触发抖音风控(7911)—— 通过 KEFU_DOUYIN_PROXY 让抖音请求走住宅代理。
|
||||||
2. 无图形界面的 Linux 起不来有头浏览器 —— 自动拉起 Xvfb 虚拟显示。
|
2. 无图形界面的 Linux 起不来有头浏览器 —— 自动拉起 Xvfb 虚拟显示。
|
||||||
|
|
||||||
全部通过环境变量控制,无需改代码:
|
全部通过环境变量控制,无需改代码:
|
||||||
|
|
||||||
KEFU_DOUYIN_PROXY 抖音 IM HTTP 请求与浏览器登录走的代理,绕开机房 IP 风控。
|
KEFU_DOUYIN_PROXY 抖音 IM HTTP 请求与浏览器登录走的代理,绕开机房 IP 风控。
|
||||||
形如 http://user:pass@host:port 或 socks5://host:port
|
形如 http://user:pass@host:port 或 socks5://host:port
|
||||||
KEFU_BROWSER_HEADLESS 是否使用无头浏览器(1/true 开启)。默认 false:抖音安全 SDK
|
KEFU_BROWSER_HEADLESS 是否使用无头浏览器(1/true 开启)。默认 false:抖音安全 SDK
|
||||||
对 headless 判定严格,无头易生成无效 ts_sign,反而刷新无效。
|
对 headless 判定严格,无头易生成无效 ts_sign,反而刷新无效。
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
logger = logging.getLogger("rpa_engine.runtime")
|
logger = logging.getLogger("rpa_engine.runtime")
|
||||||
|
|
||||||
_TRUE = {"1", "true", "yes", "on"}
|
_TRUE = {"1", "true", "yes", "on"}
|
||||||
_FALSE = {"0", "false", "no", "off"}
|
_FALSE = {"0", "false", "no", "off"}
|
||||||
|
|
||||||
_NO_DISPLAY_HINT = (
|
_NO_DISPLAY_HINT = (
|
||||||
"当前是无图形界面的 Linux 服务器,且无法启动虚拟显示来运行有头浏览器。"
|
"当前是无图形界面的 Linux 服务器,且无法启动虚拟显示来运行有头浏览器。"
|
||||||
"抖音安全 SDK 对 headless 判定严格,扫码登录 / 刷新凭证需要有头 Chromium。请任选其一:\n"
|
"抖音安全 SDK 对 headless 判定严格,扫码登录 / 刷新凭证需要有头 Chromium。请任选其一:\n"
|
||||||
" 1) 安装 Xvfb + pyvirtualdisplay,让程序自动拉起虚拟显示:\n"
|
" 1) 安装 Xvfb + pyvirtualdisplay,让程序自动拉起虚拟显示:\n"
|
||||||
" Debian/Ubuntu: apt install -y xvfb && pip install pyvirtualdisplay\n"
|
" Debian/Ubuntu: apt install -y xvfb && pip install pyvirtualdisplay\n"
|
||||||
" CentOS/Rocky : yum install -y xorg-x11-server-Xvfb && pip install pyvirtualdisplay\n"
|
" CentOS/Rocky : yum install -y xorg-x11-server-Xvfb && pip install pyvirtualdisplay\n"
|
||||||
" 2) 或用 xvfb-run 启动后端:xvfb-run -a ./start_web.sh\n"
|
" 2) 或用 xvfb-run 启动后端:xvfb-run -a ./start_web.sh\n"
|
||||||
" 3) 或设置 KEFU_BROWSER_HEADLESS=1 强制无头(更易触发抖音风控,不推荐)。"
|
" 3) 或设置 KEFU_BROWSER_HEADLESS=1 强制无头(更易触发抖音风控,不推荐)。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_douyin_proxy() -> Optional[str]:
|
def get_douyin_proxy() -> Optional[str]:
|
||||||
"""读取抖音请求代理 URL(未配置返回 None)。"""
|
"""读取抖音请求代理 URL(未配置返回 None)。"""
|
||||||
val = (os.getenv("KEFU_DOUYIN_PROXY") or "").strip()
|
val = (os.getenv("KEFU_DOUYIN_PROXY") or "").strip()
|
||||||
return val or None
|
return val or None
|
||||||
|
|
||||||
|
|
||||||
def httpx_proxy() -> Optional[str]:
|
def httpx_proxy() -> Optional[str]:
|
||||||
"""供 httpx.AsyncClient(proxy=...) 使用的代理 URL。"""
|
"""供 httpx.AsyncClient(proxy=...) 使用的代理 URL。"""
|
||||||
return get_douyin_proxy()
|
return get_douyin_proxy()
|
||||||
|
|
||||||
|
|
||||||
def requests_proxies() -> Optional[dict]:
|
def requests_proxies() -> Optional[dict]:
|
||||||
"""供 requests.get(proxies=...) 使用的代理字典。"""
|
"""供 requests.get(proxies=...) 使用的代理字典。"""
|
||||||
url = get_douyin_proxy()
|
url = get_douyin_proxy()
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
return {"http": url, "https": url}
|
return {"http": url, "https": url}
|
||||||
|
|
||||||
|
|
||||||
def playwright_proxy() -> Optional[dict]:
|
def playwright_proxy() -> Optional[dict]:
|
||||||
"""转成 Playwright launch(proxy=...) 所需结构(未配置或无法解析返回 None)。"""
|
"""转成 Playwright launch(proxy=...) 所需结构(未配置或无法解析返回 None)。"""
|
||||||
url = get_douyin_proxy()
|
url = get_douyin_proxy()
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
if not parsed.hostname:
|
if not parsed.hostname:
|
||||||
logger.warning("KEFU_DOUYIN_PROXY 格式无法解析,已忽略:%s", url)
|
logger.warning("KEFU_DOUYIN_PROXY 格式无法解析,已忽略:%s", url)
|
||||||
return None
|
return None
|
||||||
server = f"{parsed.scheme or 'http'}://{parsed.hostname}"
|
server = f"{parsed.scheme or 'http'}://{parsed.hostname}"
|
||||||
if parsed.port:
|
if parsed.port:
|
||||||
server += f":{parsed.port}"
|
server += f":{parsed.port}"
|
||||||
proxy: dict[str, str] = {"server": server}
|
proxy: dict[str, str] = {"server": server}
|
||||||
if parsed.username:
|
if parsed.username:
|
||||||
proxy["username"] = parsed.username
|
proxy["username"] = parsed.username
|
||||||
if parsed.password:
|
if parsed.password:
|
||||||
proxy["password"] = parsed.password
|
proxy["password"] = parsed.password
|
||||||
return proxy
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
def resolve_headless(default: bool = False) -> bool:
|
def resolve_headless(default: bool = False) -> bool:
|
||||||
"""根据 KEFU_BROWSER_HEADLESS 决定是否无头;未设置时用 default。"""
|
"""根据 KEFU_BROWSER_HEADLESS 决定是否无头;未设置时用 default。"""
|
||||||
val = (os.getenv("KEFU_BROWSER_HEADLESS") or "").strip().lower()
|
val = (os.getenv("KEFU_BROWSER_HEADLESS") or "").strip().lower()
|
||||||
if val in _TRUE:
|
if val in _TRUE:
|
||||||
return True
|
return True
|
||||||
if val in _FALSE:
|
if val in _FALSE:
|
||||||
return False
|
return False
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
# 进程内仅启动一次的虚拟显示(Xvfb)句柄
|
def ui_conversation_page_budget(default: int = 3) -> int:
|
||||||
_virtual_display = None
|
"""用户点开会话列表时允许翻的收件箱页数(KEFU_UI_CONVERSATION_PAGES)。
|
||||||
_virtual_display_failed = False
|
|
||||||
|
抖音收件箱按游标分页,一次请求只给一页(实测每页约 100-500KB);某账号翻
|
||||||
|
6 页拿到 35 个会话仍未翻完。所以必须有预算:只拿一页会把「其中一页」当成
|
||||||
def _start_virtual_display_sync() -> Optional[str]:
|
完整会话列表,不设上限又可能为一次点击拉下好几 MB。默认 3 页只是折中,
|
||||||
"""在无 DISPLAY 的 Linux 上启动一次 Xvfb 虚拟显示(阻塞,需放线程执行)。"""
|
花多少流量换多完整的列表属于业务取舍,用环境变量调整即可。
|
||||||
global _virtual_display, _virtual_display_failed
|
无论调到多少,翻不完时都会并入本地历史,不会把残缺列表伪装成完整列表。
|
||||||
|
"""
|
||||||
# 仅 Linux 且无 DISPLAY 时才需要虚拟显示;Windows/macOS 有桌面,直接返回。
|
try:
|
||||||
if os.name != "posix":
|
value = int(os.getenv("KEFU_UI_CONVERSATION_PAGES", str(default)) or default)
|
||||||
return os.environ.get("DISPLAY")
|
except (TypeError, ValueError):
|
||||||
if os.environ.get("DISPLAY"):
|
value = default
|
||||||
return os.environ["DISPLAY"]
|
return max(1, min(20, value))
|
||||||
if _virtual_display is not None:
|
|
||||||
return os.environ.get("DISPLAY")
|
|
||||||
if _virtual_display_failed:
|
# 进程内仅启动一次的虚拟显示(Xvfb)句柄
|
||||||
raise RuntimeError(_NO_DISPLAY_HINT)
|
_virtual_display = None
|
||||||
|
_virtual_display_failed = False
|
||||||
try:
|
|
||||||
from pyvirtualdisplay import Display
|
|
||||||
except ImportError as e:
|
def _start_virtual_display_sync() -> Optional[str]:
|
||||||
_virtual_display_failed = True
|
"""在无 DISPLAY 的 Linux 上启动一次 Xvfb 虚拟显示(阻塞,需放线程执行)。"""
|
||||||
raise RuntimeError(_NO_DISPLAY_HINT) from e
|
global _virtual_display, _virtual_display_failed
|
||||||
|
|
||||||
try:
|
# 仅 Linux 且无 DISPLAY 时才需要虚拟显示;Windows/macOS 有桌面,直接返回。
|
||||||
disp = Display(visible=False, size=(1280, 800))
|
if os.name != "posix":
|
||||||
disp.start() # 设置 os.environ['DISPLAY']
|
return os.environ.get("DISPLAY")
|
||||||
except Exception as e:
|
if os.environ.get("DISPLAY"):
|
||||||
_virtual_display_failed = True
|
return os.environ["DISPLAY"]
|
||||||
raise RuntimeError(_NO_DISPLAY_HINT) from e
|
if _virtual_display is not None:
|
||||||
|
return os.environ.get("DISPLAY")
|
||||||
_virtual_display = disp
|
if _virtual_display_failed:
|
||||||
logger.info("已启动 Xvfb 虚拟显示 DISPLAY=%s 供有头浏览器使用", os.environ.get("DISPLAY"))
|
raise RuntimeError(_NO_DISPLAY_HINT)
|
||||||
return os.environ.get("DISPLAY")
|
|
||||||
|
try:
|
||||||
|
from pyvirtualdisplay import Display
|
||||||
async def ensure_browser_display(headless: bool) -> None:
|
except ImportError as e:
|
||||||
"""有头模式在无 DISPLAY 的 Linux 上自动拉起 Xvfb 虚拟显示。
|
_virtual_display_failed = True
|
||||||
|
raise RuntimeError(_NO_DISPLAY_HINT) from e
|
||||||
headless=True 时无需显示,直接返回;启动失败抛出带操作指引的 RuntimeError。
|
|
||||||
"""
|
try:
|
||||||
if headless:
|
disp = Display(visible=False, size=(1280, 800))
|
||||||
return
|
disp.start() # 设置 os.environ['DISPLAY']
|
||||||
await asyncio.to_thread(_start_virtual_display_sync)
|
except Exception as e:
|
||||||
|
_virtual_display_failed = True
|
||||||
|
raise RuntimeError(_NO_DISPLAY_HINT) from e
|
||||||
|
|
||||||
|
_virtual_display = disp
|
||||||
|
logger.info("已启动 Xvfb 虚拟显示 DISPLAY=%s 供有头浏览器使用", os.environ.get("DISPLAY"))
|
||||||
|
return os.environ.get("DISPLAY")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_browser_display(headless: bool) -> None:
|
||||||
|
"""有头模式在无 DISPLAY 的 Linux 上自动拉起 Xvfb 虚拟显示。
|
||||||
|
|
||||||
|
headless=True 时无需显示,直接返回;启动失败抛出带操作指引的 RuntimeError。
|
||||||
|
"""
|
||||||
|
if headless:
|
||||||
|
return
|
||||||
|
await asyncio.to_thread(_start_virtual_display_sync)
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""Loopback HTTP proxy whose outbound sockets bind to one local IPv4.
|
||||||
|
|
||||||
|
Playwright does not expose a ``local_address`` option. Accounts that select a
|
||||||
|
specific server egress channel therefore use this tiny process-local proxy so
|
||||||
|
their browser login/refresh traffic leaves through the same interface as IM
|
||||||
|
HTTP and WebSocket traffic. The listener is loopback-only and does not rotate
|
||||||
|
or retry public addresses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import weakref
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
logger = logging.getLogger("rpa_engine.source_proxy")
|
||||||
|
|
||||||
|
_MAX_HEADER_BYTES = 64 * 1024
|
||||||
|
_HEADER_TIMEOUT_SECONDS = 20.0
|
||||||
|
|
||||||
|
|
||||||
|
class SourceBoundProxy:
|
||||||
|
"""Minimal HTTP/HTTPS CONNECT proxy bound to a fixed source address."""
|
||||||
|
|
||||||
|
def __init__(self, source_ip: str):
|
||||||
|
address = ipaddress.ip_address(str(source_ip or "").strip())
|
||||||
|
if address.version != 4 or address.is_unspecified or address.is_multicast:
|
||||||
|
raise ValueError(f"invalid IPv4 source address: {source_ip!r}")
|
||||||
|
self.source_ip = str(address)
|
||||||
|
self._server: asyncio.AbstractServer | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def server_url(self) -> str:
|
||||||
|
if self._server is None or not self._server.sockets:
|
||||||
|
raise RuntimeError("source-bound proxy has not started")
|
||||||
|
port = int(self._server.sockets[0].getsockname()[1])
|
||||||
|
return f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
async def start(self) -> "SourceBoundProxy":
|
||||||
|
if self._server is None:
|
||||||
|
self._server = await asyncio.start_server(
|
||||||
|
self._handle_client,
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=0,
|
||||||
|
family=socket.AF_INET,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"source-bound browser proxy ready: %s -> source %s",
|
||||||
|
self.server_url,
|
||||||
|
self.source_ip,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
server = self._server
|
||||||
|
self._server = None
|
||||||
|
if server is not None:
|
||||||
|
server.close()
|
||||||
|
await server.wait_closed()
|
||||||
|
|
||||||
|
async def _open_upstream(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
||||||
|
return await asyncio.open_connection(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
family=socket.AF_INET,
|
||||||
|
local_addr=(self.source_ip, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _relay(
|
||||||
|
source: asyncio.StreamReader,
|
||||||
|
destination: asyncio.StreamWriter,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = await source.read(64 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
destination.write(chunk)
|
||||||
|
await destination.drain()
|
||||||
|
except (ConnectionError, asyncio.CancelledError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
destination.write_eof()
|
||||||
|
except (AttributeError, OSError, RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _bridge(
|
||||||
|
cls,
|
||||||
|
client_reader: asyncio.StreamReader,
|
||||||
|
client_writer: asyncio.StreamWriter,
|
||||||
|
upstream_reader: asyncio.StreamReader,
|
||||||
|
upstream_writer: asyncio.StreamWriter,
|
||||||
|
) -> None:
|
||||||
|
tasks = (
|
||||||
|
asyncio.create_task(cls._relay(client_reader, upstream_writer)),
|
||||||
|
asyncio.create_task(cls._relay(upstream_reader, client_writer)),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
finally:
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_authority(authority: str, default_port: int) -> tuple[str, int]:
|
||||||
|
parsed = urlsplit(f"//{authority}")
|
||||||
|
host = str(parsed.hostname or "").strip()
|
||||||
|
if not host:
|
||||||
|
raise ValueError("proxy request is missing a host")
|
||||||
|
return host, int(parsed.port or default_port)
|
||||||
|
|
||||||
|
async def _handle_client(
|
||||||
|
self,
|
||||||
|
client_reader: asyncio.StreamReader,
|
||||||
|
client_writer: asyncio.StreamWriter,
|
||||||
|
) -> None:
|
||||||
|
upstream_writer: asyncio.StreamWriter | None = None
|
||||||
|
try:
|
||||||
|
header = await asyncio.wait_for(
|
||||||
|
client_reader.readuntil(b"\r\n\r\n"),
|
||||||
|
timeout=_HEADER_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if len(header) > _MAX_HEADER_BYTES:
|
||||||
|
raise ValueError("proxy request headers are too large")
|
||||||
|
lines = header.decode("latin-1").split("\r\n")
|
||||||
|
request_line = lines[0].split(" ", 2)
|
||||||
|
if len(request_line) != 3:
|
||||||
|
raise ValueError("malformed proxy request line")
|
||||||
|
method, target, version = request_line
|
||||||
|
|
||||||
|
if method.upper() == "CONNECT":
|
||||||
|
host, port = self._parse_authority(target, 443)
|
||||||
|
upstream_reader, upstream_writer = await self._open_upstream(host, port)
|
||||||
|
client_writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||||
|
await client_writer.drain()
|
||||||
|
else:
|
||||||
|
parsed = urlsplit(target)
|
||||||
|
host_header = next(
|
||||||
|
(
|
||||||
|
line.partition(":")[2].strip()
|
||||||
|
for line in lines[1:]
|
||||||
|
if line.lower().startswith("host:")
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
authority = parsed.netloc or host_header
|
||||||
|
host, port = self._parse_authority(
|
||||||
|
authority,
|
||||||
|
443 if parsed.scheme.lower() == "https" else 80,
|
||||||
|
)
|
||||||
|
upstream_reader, upstream_writer = await self._open_upstream(host, port)
|
||||||
|
origin_target = parsed.path or "/"
|
||||||
|
if parsed.query:
|
||||||
|
origin_target += f"?{parsed.query}"
|
||||||
|
forwarded = [f"{method} {origin_target} {version}"]
|
||||||
|
forwarded.extend(
|
||||||
|
line for line in lines[1:]
|
||||||
|
if line and not line.lower().startswith("proxy-connection:")
|
||||||
|
)
|
||||||
|
upstream_writer.write(("\r\n".join(forwarded) + "\r\n\r\n").encode("latin-1"))
|
||||||
|
await upstream_writer.drain()
|
||||||
|
|
||||||
|
await self._bridge(
|
||||||
|
client_reader,
|
||||||
|
client_writer,
|
||||||
|
upstream_reader,
|
||||||
|
upstream_writer,
|
||||||
|
)
|
||||||
|
except asyncio.IncompleteReadError:
|
||||||
|
pass
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Event-loop shutdown may cancel an in-flight browser tunnel.
|
||||||
|
# Closing both writers below is sufficient; do not leak a noisy
|
||||||
|
# cancelled handler callback into the server log.
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("source-bound browser proxy request failed: %s", exc)
|
||||||
|
try:
|
||||||
|
client_writer.write(
|
||||||
|
b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
await client_writer.drain()
|
||||||
|
except (ConnectionError, RuntimeError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
for writer in (upstream_writer, client_writer):
|
||||||
|
if writer is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except (ConnectionError, RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _LoopProxyState:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
self.proxies: dict[str, SourceBoundProxy] = {}
|
||||||
|
|
||||||
|
|
||||||
|
_loop_states: weakref.WeakKeyDictionary[
|
||||||
|
asyncio.AbstractEventLoop, _LoopProxyState
|
||||||
|
] = weakref.WeakKeyDictionary()
|
||||||
|
|
||||||
|
|
||||||
|
async def playwright_proxy_for_source(source_ip: str) -> dict[str, str]:
|
||||||
|
"""Return a Playwright proxy config fixed to ``source_ip``."""
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
state = _loop_states.get(loop)
|
||||||
|
if state is None:
|
||||||
|
state = _LoopProxyState()
|
||||||
|
_loop_states[loop] = state
|
||||||
|
normalized = str(ipaddress.ip_address(str(source_ip or "").strip()))
|
||||||
|
async with state.lock:
|
||||||
|
proxy = state.proxies.get(normalized)
|
||||||
|
if proxy is None:
|
||||||
|
proxy = await SourceBoundProxy(normalized).start()
|
||||||
|
state.proxies[normalized] = proxy
|
||||||
|
return {"server": proxy.server_url}
|
||||||
@@ -112,6 +112,40 @@ class AccountPaginationTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
finally:
|
finally:
|
||||||
main.manager.workers = original_workers
|
main.manager.workers = original_workers
|
||||||
|
|
||||||
|
async def test_account_channel_change_stops_running_worker(self):
|
||||||
|
account = SimpleNamespace(
|
||||||
|
id=2202,
|
||||||
|
egress_public_ip="116.62.23.103",
|
||||||
|
status="online",
|
||||||
|
)
|
||||||
|
db = SimpleNamespace(
|
||||||
|
commit=AsyncMock(),
|
||||||
|
refresh=AsyncMock(),
|
||||||
|
execute=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(main, "get_owned_account", AsyncMock(return_value=account)),
|
||||||
|
patch.object(main.manager, "is_running", return_value=True),
|
||||||
|
patch.object(main.manager, "stop_worker", AsyncMock(return_value=True)) as stop,
|
||||||
|
patch.object(main, "_build_account_response", return_value={"id": 2202}),
|
||||||
|
):
|
||||||
|
response = await main.update_account(
|
||||||
|
account_id=2202,
|
||||||
|
body=main.AccountUpdate(egress_public_ip="47.96.154.74"),
|
||||||
|
db=db,
|
||||||
|
user=SimpleNamespace(id=7, role="operator"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response, {"id": 2202})
|
||||||
|
self.assertEqual(account.egress_public_ip, "47.96.154.74")
|
||||||
|
stop.assert_awaited_once_with(2202)
|
||||||
|
self.assertEqual(db.commit.await_count, 2)
|
||||||
|
values = db.execute.await_args.args[0].compile().params
|
||||||
|
self.assertIn("已保留登录凭证", values["error_message"])
|
||||||
|
self.assertNotIn("cookie_data", values)
|
||||||
|
self.assertNotIn("im_session_data", values)
|
||||||
|
|
||||||
async def test_log_stats_uses_one_aggregate_and_respects_ownership(self):
|
async def test_log_stats_uses_one_aggregate_and_respects_ownership(self):
|
||||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
async with engine.begin() as connection:
|
async with engine.begin() as connection:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -262,6 +263,95 @@ class BatchStartApiTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
# return the connection: one before validation, one before the wait.
|
# return the connection: one before validation, one before the wait.
|
||||||
self.assertEqual(events, ["release", "assess", "release", "start-worker"])
|
self.assertEqual(events, ["release", "assess", "release", "start-worker"])
|
||||||
|
|
||||||
|
async def test_changed_egress_preserves_valid_credentials(self):
|
||||||
|
ready_assessment = {
|
||||||
|
"login_mode": "im_direct",
|
||||||
|
"should_reset": False,
|
||||||
|
"can_skip_browser": True,
|
||||||
|
"message": "ready",
|
||||||
|
"cookie_valid": True,
|
||||||
|
"im_ready": True,
|
||||||
|
}
|
||||||
|
scenarios = (
|
||||||
|
({"cookies": []}, "47.96.154.74"),
|
||||||
|
({"cookies": [], "credential_egress_public_ip": "116.62.23.103"}, "47.96.154.74"),
|
||||||
|
({"cookies": [], "credential_egress_public_ip": "47.96.154.74"}, ""),
|
||||||
|
)
|
||||||
|
modes = (("im_direct", False), (None, False), (None, True))
|
||||||
|
for storage, selected_ip in scenarios:
|
||||||
|
for requested_mode, wait_for_ready in modes:
|
||||||
|
with self.subTest(storage=storage, mode=requested_mode, batch=wait_for_ready):
|
||||||
|
cookie_data = json.dumps(storage)
|
||||||
|
account = SimpleNamespace(
|
||||||
|
id=506,
|
||||||
|
status="offline",
|
||||||
|
qr_code_base64=None,
|
||||||
|
error_message="old channel warning",
|
||||||
|
cookie_data=cookie_data,
|
||||||
|
im_session_data="saved-session",
|
||||||
|
egress_public_ip=selected_ip,
|
||||||
|
)
|
||||||
|
db = SimpleNamespace(commit=AsyncMock())
|
||||||
|
with (
|
||||||
|
patch.object(main.manager, "is_running", return_value=False),
|
||||||
|
patch.object(main.manager, "start_worker", AsyncMock(return_value=True)) as start,
|
||||||
|
patch.object(main, "_get_account_cookie_data", return_value=cookie_data),
|
||||||
|
patch.object(main, "_reset_account_credentials", AsyncMock()) as reset,
|
||||||
|
patch.object(main, "assess_account_credential", AsyncMock(return_value=ready_assessment)) as assess,
|
||||||
|
):
|
||||||
|
result = await main._start_account_rpa_impl(
|
||||||
|
account, db, requested_mode, wait_for_ready=wait_for_ready
|
||||||
|
)
|
||||||
|
|
||||||
|
reset.assert_not_awaited()
|
||||||
|
assess.assert_awaited_once_with(
|
||||||
|
cookie_data, "saved-session",
|
||||||
|
startup_priority=True, egress_public_ip=selected_ip,
|
||||||
|
)
|
||||||
|
start.assert_awaited_once_with(
|
||||||
|
506, login_mode="im_direct",
|
||||||
|
wait_until_ready=wait_for_ready, credential_prevalidated=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(account.cookie_data, cookie_data)
|
||||||
|
self.assertEqual(account.im_session_data, "saved-session")
|
||||||
|
self.assertIsNone(account.error_message)
|
||||||
|
self.assertTrue(result["skip_qr"])
|
||||||
|
self.assertTrue(result["skip_browser"])
|
||||||
|
|
||||||
|
async def test_changed_egress_still_rejects_invalid_im_credentials(self):
|
||||||
|
account = SimpleNamespace(
|
||||||
|
id=506,
|
||||||
|
status="offline",
|
||||||
|
qr_code_base64=None,
|
||||||
|
error_message=None,
|
||||||
|
im_session_data="saved-session",
|
||||||
|
egress_public_ip="47.96.154.74",
|
||||||
|
)
|
||||||
|
db = SimpleNamespace(commit=AsyncMock())
|
||||||
|
invalid_assessment = {
|
||||||
|
"login_mode": "browser",
|
||||||
|
"should_reset": False,
|
||||||
|
"can_skip_browser": False,
|
||||||
|
"message": "缺少 IM 签名密钥(web_protect/keys),请用浏览器登录补全",
|
||||||
|
"cookie_valid": True,
|
||||||
|
"im_ready": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(main.manager, "is_running", return_value=False),
|
||||||
|
patch.object(main.manager, "start_worker", AsyncMock()) as start,
|
||||||
|
patch.object(main, "_get_account_cookie_data", return_value='{"cookies": []}'),
|
||||||
|
patch.object(main, "_reset_account_credentials", AsyncMock()) as reset,
|
||||||
|
patch.object(main, "assess_account_credential", AsyncMock(return_value=invalid_assessment)),
|
||||||
|
):
|
||||||
|
with self.assertRaises(main.HTTPException) as error:
|
||||||
|
await main._start_account_rpa_impl(account, db, "im_direct")
|
||||||
|
|
||||||
|
self.assertEqual(error.exception.status_code, 400)
|
||||||
|
self.assertEqual(error.exception.detail, invalid_assessment["message"])
|
||||||
|
reset.assert_not_awaited()
|
||||||
|
start.assert_not_awaited()
|
||||||
|
|
||||||
async def test_batch_start_does_not_launch_interactive_browser_login(self):
|
async def test_batch_start_does_not_launch_interactive_browser_login(self):
|
||||||
account = SimpleNamespace(
|
account = SimpleNamespace(
|
||||||
id=504,
|
id=504,
|
||||||
|
|||||||
@@ -30,67 +30,69 @@ class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
account_id=9,
|
account_id=9,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_terminal_token_error_does_not_probe_other_payloads(self):
|
async def test_inbox_is_fetched_with_exactly_one_protobuf_request(self):
|
||||||
|
"""imapi 只认 protobuf;轮询一次就只该发一个请求。"""
|
||||||
client = self._make_client()
|
client = self._make_client()
|
||||||
client._request = AsyncMock(
|
client.fetch_inbox_messages = AsyncMock(return_value=[])
|
||||||
return_value={
|
client._request = AsyncMock()
|
||||||
"status_code": 500,
|
|
||||||
"error_desc": "empty token",
|
|
||||||
"body": {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(await client.get_conversations(), [])
|
self.assertEqual(await client.get_conversations(), [])
|
||||||
client._request.assert_awaited_once()
|
client.fetch_inbox_messages.assert_awaited_once()
|
||||||
self.assertEqual(client._request.await_args.args[0], "POST")
|
# 不能再退回 JSON 的 /v1/conversation/list:那个请求恒被抖音拒绝。
|
||||||
|
client._request.assert_not_awaited()
|
||||||
|
|
||||||
async def test_successful_empty_response_stops_after_first_payload(self):
|
async def test_transport_failure_is_recorded_and_returns_empty(self):
|
||||||
client = self._make_client()
|
client = self._make_client()
|
||||||
client._request = AsyncMock(
|
client.fetch_inbox_messages = AsyncMock(side_effect=RuntimeError("boom"))
|
||||||
return_value={"status_code": 0, "body": {"conversation_list": []}}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(await client.get_conversations(), [])
|
|
||||||
client._request.assert_awaited_once()
|
|
||||||
|
|
||||||
async def test_parameter_error_can_fall_through_to_compatible_payload(self):
|
|
||||||
client = self._make_client()
|
|
||||||
client._request = AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{"status_code": 400, "error_desc": "invalid parameter"},
|
|
||||||
{"status_code": 0, "body": {"conversation_list": []}},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(await client.get_conversations(), [])
|
|
||||||
self.assertEqual(client._request.await_count, 2)
|
|
||||||
self.assertTrue(
|
|
||||||
all(call.args[0] == "POST" for call in client._request.await_args_list)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def test_get_fallback_only_runs_after_transport_failure(self):
|
|
||||||
client = self._make_client()
|
|
||||||
client._request = AsyncMock(
|
|
||||||
side_effect=[None, {"status_code": 0, "body": {}}]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(await client.get_conversations(), [])
|
|
||||||
self.assertEqual(
|
|
||||||
[call.args[0] for call in client._request.await_args_list],
|
|
||||||
["POST", "GET"],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def test_transport_outage_stops_after_one_post_and_get_pair(self):
|
|
||||||
client = self._make_client()
|
|
||||||
client._request = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
with self.assertLogs("douyin_im.http", level="WARNING"):
|
with self.assertLogs("douyin_im.http", level="WARNING"):
|
||||||
self.assertEqual(await client.get_conversations(), [])
|
self.assertEqual(await client.get_conversations(), [])
|
||||||
|
|
||||||
self.assertEqual(client._request.await_count, 2)
|
self.assertIn("boom", client.last_error)
|
||||||
|
|
||||||
|
async def test_inbox_messages_group_into_one_row_per_conversation(self):
|
||||||
|
client = self._make_client()
|
||||||
|
client.fetch_inbox_messages = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"conversation_id": "0:1:10001:20001",
|
||||||
|
"server_message_id": "700",
|
||||||
|
"conversation_short_id": "555",
|
||||||
|
"message_type": 7,
|
||||||
|
"sender": "20001",
|
||||||
|
"content": '{"text":"旧"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_id": "0:1:10001:20001",
|
||||||
|
"server_message_id": "900",
|
||||||
|
"conversation_short_id": "555",
|
||||||
|
"message_type": 7,
|
||||||
|
"sender": "20001",
|
||||||
|
"content": '{"text":"新"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_id": "0:1:10001:20002",
|
||||||
|
"server_message_id": "800",
|
||||||
|
"message_type": 7,
|
||||||
|
"sender": "20002",
|
||||||
|
"content": '{"text":"另一个"}',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = await client.get_conversations(enrich_profiles=False)
|
||||||
|
|
||||||
|
by_id = {r["conversation_id"]: r for r in rows}
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
# 同一会话只保留 server_message_id 最大的那条
|
||||||
|
self.assertEqual(by_id["0:1:10001:20001"]["server_message_id"], "900")
|
||||||
|
self.assertIn("新", by_id["0:1:10001:20001"]["content"])
|
||||||
|
# peer_uid 由 conversation_id 推导,不能直接取 sender(可能是自己)
|
||||||
|
self.assertEqual(by_id["0:1:10001:20002"]["peer_uid"], "20002")
|
||||||
|
# 顺手缓存 short_id,发送时就不必再 create 一次会话
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[call.args[0] for call in client._request.await_args_list],
|
client.session.conv_meta["0:1:10001:20001"]["conversation_short_id"],
|
||||||
["POST", "GET"],
|
"555",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_websocket_reconciliation_is_slow_and_http_fallback_stays_fast(self):
|
def test_websocket_reconciliation_is_slow_and_http_fallback_stays_fast(self):
|
||||||
@@ -156,6 +158,7 @@ class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
class _HttpClient:
|
class _HttpClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.get_conversations = AsyncMock(return_value=[])
|
self.get_conversations = AsyncMock(return_value=[])
|
||||||
|
self.conversation_list_unsupported = False
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
@@ -254,6 +257,7 @@ class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
class _HttpClient:
|
class _HttpClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.get_conversations = AsyncMock(side_effect=snapshots)
|
self.get_conversations = AsyncMock(side_effect=snapshots)
|
||||||
|
self.conversation_list_unsupported = False
|
||||||
self.enter_count = 0
|
self.enter_count = 0
|
||||||
self.exit_count = 0
|
self.exit_count = 0
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,26 @@ import unittest
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from rpa_engine.credential import validate_im_session
|
from rpa_engine.credential import credential_egress_mismatch, validate_im_session
|
||||||
|
|
||||||
|
|
||||||
class CredentialResponsivenessTests(unittest.IsolatedAsyncioTestCase):
|
class CredentialResponsivenessTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def test_legacy_egress_marker_comparison_is_diagnostic(self):
|
||||||
|
legacy = '{"cookies": []}'
|
||||||
|
|
||||||
|
self.assertFalse(credential_egress_mismatch(legacy, ""))
|
||||||
|
self.assertTrue(credential_egress_mismatch(legacy, "47.96.154.74"))
|
||||||
|
|
||||||
|
def test_stamped_egress_marker_comparison(self):
|
||||||
|
stamped = (
|
||||||
|
'{"cookies": [], '
|
||||||
|
'"credential_egress_public_ip": "47.96.154.74"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(credential_egress_mismatch(stamped, "47.96.154.74"))
|
||||||
|
self.assertTrue(credential_egress_mismatch(stamped, "116.62.23.103"))
|
||||||
|
self.assertTrue(credential_egress_mismatch(stamped, ""))
|
||||||
|
|
||||||
async def test_uid_lookup_does_not_block_event_loop(self):
|
async def test_uid_lookup_does_not_block_event_loop(self):
|
||||||
event_loop_thread_id = threading.get_ident()
|
event_loop_thread_id = threading.get_ident()
|
||||||
lookup_thread_ids = []
|
lookup_thread_ids = []
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""托管多个账号时的会话归属隔离回归测试。
|
||||||
|
|
||||||
|
复现的缺陷:账号 A 的处理链路收到属于账号 B 的会话(0:1:B:B的好友)后,
|
||||||
|
resolve_peer_uid 把末段当成「对方」、normalize_conversation_id 再拼成
|
||||||
|
0:1:A:B的好友,于是账号 A 用自己的凭证把自动回复发给了账号 B 的好友。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
os.environ.setdefault("KEFU_DB_TYPE", "sqlite")
|
||||||
|
os.environ.setdefault("KEFU_DATABASE_URL", "")
|
||||||
|
os.environ.setdefault("KEFU_DB_PATH", str(BACKEND_DIR / "kefu.db"))
|
||||||
|
if str(BACKEND_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BACKEND_DIR))
|
||||||
|
|
||||||
|
from rpa_engine.douyin_im import hosted_registry
|
||||||
|
from rpa_engine.douyin_im import ws_client as ws_module
|
||||||
|
from rpa_engine.douyin_im.auth import DouyinAuth
|
||||||
|
from rpa_engine.douyin_im.conv_util import conversation_belongs_to
|
||||||
|
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
||||||
|
from rpa_engine.douyin_im.service import DouyinImService
|
||||||
|
from rpa_engine.douyin_im.session import DouyinImSession
|
||||||
|
from rpa_engine.douyin_im.ws_client import DouyinImWsClient
|
||||||
|
|
||||||
|
ACCOUNT_A_UID = 7670159096859706425
|
||||||
|
ACCOUNT_B_UID = 7670157997767050299
|
||||||
|
PEER_OF_B = 66578464308
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationOwnershipTests(unittest.TestCase):
|
||||||
|
def test_foreign_single_chat_is_rejected(self):
|
||||||
|
self.assertFalse(
|
||||||
|
conversation_belongs_to(
|
||||||
|
f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}", ACCOUNT_A_UID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_own_conversation_in_either_position(self):
|
||||||
|
self.assertTrue(
|
||||||
|
conversation_belongs_to(f"0:1:{ACCOUNT_A_UID}:{PEER_OF_B}", ACCOUNT_A_UID)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
conversation_belongs_to(f"0:1:{PEER_OF_B}:{ACCOUNT_A_UID}", ACCOUNT_A_UID)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_undecidable_shapes_pass_through(self):
|
||||||
|
# 缺 my_uid / 群聊 / 裸 UID:本来就判不了归属,保守放行
|
||||||
|
self.assertTrue(conversation_belongs_to(f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}", 0))
|
||||||
|
self.assertTrue(conversation_belongs_to("0:2:123:456", ACCOUNT_A_UID))
|
||||||
|
self.assertTrue(conversation_belongs_to(str(PEER_OF_B), ACCOUNT_A_UID))
|
||||||
|
self.assertTrue(conversation_belongs_to("", ACCOUNT_A_UID))
|
||||||
|
|
||||||
|
|
||||||
|
class ForeignMessageDropTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def _service(self) -> DouyinImService:
|
||||||
|
service = DouyinImService(
|
||||||
|
session=DouyinImSession(cookies={"sessionid": "a"}, my_uid=ACCOUNT_A_UID),
|
||||||
|
match_reply=AsyncMock(return_value=["自动回复"]),
|
||||||
|
log_fn=AsyncMock(),
|
||||||
|
account_id=1,
|
||||||
|
)
|
||||||
|
service._running = True
|
||||||
|
return service
|
||||||
|
|
||||||
|
async def test_message_from_another_account_never_schedules_a_reply(self):
|
||||||
|
service = self._service()
|
||||||
|
service._resolve_peer_profile = AsyncMock(
|
||||||
|
return_value=("B 的好友", "", str(PEER_OF_B))
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"rpa_engine.douyin_im.service.system_logger.record", Mock()
|
||||||
|
) as record:
|
||||||
|
result = await service._prepare_incoming(
|
||||||
|
{
|
||||||
|
"conversation_id": f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}",
|
||||||
|
"sender_uid": str(PEER_OF_B),
|
||||||
|
"content": "在吗",
|
||||||
|
"server_message_id": "7665317099296081465",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
service.match_reply.assert_not_awaited()
|
||||||
|
service.log_fn.assert_not_awaited()
|
||||||
|
self.assertEqual(service._conv_meta, {})
|
||||||
|
self.assertTrue(record.called)
|
||||||
|
|
||||||
|
async def test_own_message_is_still_processed(self):
|
||||||
|
service = self._service()
|
||||||
|
conv_id = f"0:1:{ACCOUNT_A_UID}:{PEER_OF_B}"
|
||||||
|
service._resolve_peer_profile = AsyncMock(
|
||||||
|
return_value=("我的好友", "", str(PEER_OF_B))
|
||||||
|
)
|
||||||
|
service._resolve_cooldown_seconds = AsyncMock(return_value=0)
|
||||||
|
service._resolve_reply_delay_seconds = AsyncMock(return_value=0)
|
||||||
|
service._send_auto_reply = AsyncMock()
|
||||||
|
|
||||||
|
with patch("rpa_engine.douyin_im.service.system_logger.record", Mock()):
|
||||||
|
send_reply = await service._prepare_incoming(
|
||||||
|
{
|
||||||
|
"conversation_id": conv_id,
|
||||||
|
"sender_uid": str(PEER_OF_B),
|
||||||
|
"content": "在吗",
|
||||||
|
"server_message_id": "7665317099296081466",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(send_reply)
|
||||||
|
service.match_reply.assert_awaited()
|
||||||
|
self.assertIn(conv_id, service._conv_meta)
|
||||||
|
|
||||||
|
|
||||||
|
class ForeignSendRefusalTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_send_refuses_a_conversation_owned_by_another_account(self):
|
||||||
|
client = DouyinImHttpClient(
|
||||||
|
DouyinImSession(cookies={"sessionid": "a"}, my_uid=ACCOUNT_A_UID),
|
||||||
|
account_id=1,
|
||||||
|
)
|
||||||
|
resolve_meta = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
DouyinImHttpClient,
|
||||||
|
"_resolve_authoritative_uid",
|
||||||
|
return_value=ACCOUNT_A_UID,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
DouyinImHttpClient, "resolve_conversation_meta", resolve_meta
|
||||||
|
),
|
||||||
|
patch("rpa_engine.douyin_im.http_client.system_logger.record", Mock()),
|
||||||
|
):
|
||||||
|
sent = await client.send_text_message(
|
||||||
|
f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}",
|
||||||
|
"你好",
|
||||||
|
_bypass_global_queue=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(sent)
|
||||||
|
# 关键断言:拒发必须发生在解析 ticket / 真正写出去之前
|
||||||
|
resolve_meta.assert_not_awaited()
|
||||||
|
self.assertIn("不是本账号", client.last_error)
|
||||||
|
self.assertFalse(client.last_send_channel_retryable)
|
||||||
|
|
||||||
|
|
||||||
|
class ExpectedRecipientTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""手动发送必须打给调用方点选的那个人(昵称重复时会话可能匹配错)。"""
|
||||||
|
|
||||||
|
OTHER_PEER = 975976494279630
|
||||||
|
|
||||||
|
def _client(self) -> DouyinImHttpClient:
|
||||||
|
return DouyinImHttpClient(
|
||||||
|
DouyinImSession(cookies={"sessionid": "a"}, my_uid=ACCOUNT_A_UID),
|
||||||
|
account_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send(self, client, conversation_id, expected_peer_uid):
|
||||||
|
resolve_meta = AsyncMock(return_value=("", "", ""))
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
DouyinImHttpClient,
|
||||||
|
"_resolve_authoritative_uid",
|
||||||
|
return_value=ACCOUNT_A_UID,
|
||||||
|
),
|
||||||
|
# 本组用例只验收件人闸门,凭证是否齐全与它无关
|
||||||
|
patch.object(DouyinAuth, "is_sign_ready", return_value=True),
|
||||||
|
patch.object(
|
||||||
|
DouyinImHttpClient, "resolve_conversation_meta", resolve_meta
|
||||||
|
),
|
||||||
|
patch("rpa_engine.douyin_im.http_client.system_logger.record", Mock()),
|
||||||
|
):
|
||||||
|
sent = await client.send_text_message(
|
||||||
|
conversation_id,
|
||||||
|
"你好",
|
||||||
|
expected_peer_uid=expected_peer_uid,
|
||||||
|
_bypass_global_queue=True,
|
||||||
|
)
|
||||||
|
return sent, resolve_meta
|
||||||
|
|
||||||
|
async def test_refuses_when_the_conversation_points_at_someone_else(self):
|
||||||
|
client = self._client()
|
||||||
|
|
||||||
|
sent, resolve_meta = await self._send(
|
||||||
|
client,
|
||||||
|
f"0:1:{ACCOUNT_A_UID}:{self.OTHER_PEER}",
|
||||||
|
str(PEER_OF_B),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(sent)
|
||||||
|
# 必须在解析 ticket / 发包之前就拒绝
|
||||||
|
resolve_meta.assert_not_awaited()
|
||||||
|
self.assertIn("发送目标与预期不一致", client.last_error)
|
||||||
|
self.assertFalse(client.last_send_channel_retryable)
|
||||||
|
|
||||||
|
async def test_allows_the_intended_recipient(self):
|
||||||
|
client = self._client()
|
||||||
|
|
||||||
|
sent, resolve_meta = await self._send(
|
||||||
|
client,
|
||||||
|
f"0:1:{ACCOUNT_A_UID}:{PEER_OF_B}",
|
||||||
|
str(PEER_OF_B),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ticket 解析被 mock 成空 -> 发送仍会失败,但必须是「拿不到票据」而不是被闸门拦下
|
||||||
|
self.assertFalse(sent)
|
||||||
|
resolve_meta.assert_awaited()
|
||||||
|
self.assertNotIn("发送目标与预期不一致", client.last_error)
|
||||||
|
|
||||||
|
async def test_no_expectation_keeps_the_old_behaviour(self):
|
||||||
|
client = self._client()
|
||||||
|
|
||||||
|
_, resolve_meta = await self._send(
|
||||||
|
client, f"0:1:{ACCOUNT_A_UID}:{self.OTHER_PEER}", ""
|
||||||
|
)
|
||||||
|
|
||||||
|
resolve_meta.assert_awaited()
|
||||||
|
self.assertNotIn("发送目标与预期不一致", client.last_error)
|
||||||
|
|
||||||
|
|
||||||
|
class HostedPeerLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""两个本系统托管的账号之间不得互相自动回复(无限回环 → 抖音风控)。"""
|
||||||
|
|
||||||
|
def _service(self) -> DouyinImService:
|
||||||
|
service = DouyinImService(
|
||||||
|
session=DouyinImSession(cookies={"sessionid": "a"}, my_uid=ACCOUNT_A_UID),
|
||||||
|
match_reply=AsyncMock(return_value=["自动回复"]),
|
||||||
|
log_fn=AsyncMock(),
|
||||||
|
account_id=1,
|
||||||
|
)
|
||||||
|
service._running = True
|
||||||
|
service._resolve_cooldown_seconds = AsyncMock(return_value=0)
|
||||||
|
service._resolve_reply_delay_seconds = AsyncMock(return_value=0)
|
||||||
|
return service
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
hosted_registry.unregister(ACCOUNT_B_UID)
|
||||||
|
|
||||||
|
async def _incoming_from(self, service, peer_uid: int, message_id: str):
|
||||||
|
service._resolve_peer_profile = AsyncMock(
|
||||||
|
return_value=("对方", "", str(peer_uid))
|
||||||
|
)
|
||||||
|
with patch("rpa_engine.douyin_im.service.system_logger.record", Mock()):
|
||||||
|
return await service._prepare_incoming(
|
||||||
|
{
|
||||||
|
"conversation_id": f"0:1:{ACCOUNT_A_UID}:{peer_uid}",
|
||||||
|
"sender_uid": str(peer_uid),
|
||||||
|
"content": "在吗",
|
||||||
|
"server_message_id": message_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_no_auto_reply_to_another_hosted_account(self):
|
||||||
|
hosted_registry.register(ACCOUNT_B_UID)
|
||||||
|
service = self._service()
|
||||||
|
|
||||||
|
result = await self._incoming_from(service, ACCOUNT_B_UID, "1")
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
service.match_reply.assert_not_awaited()
|
||||||
|
# 消息本身照常入库,只是标记为未回复
|
||||||
|
statuses = [
|
||||||
|
call.kwargs.get("status") for call in service.log_fn.await_args_list
|
||||||
|
]
|
||||||
|
self.assertIn("received", statuses)
|
||||||
|
self.assertIn("ignored", statuses)
|
||||||
|
|
||||||
|
async def test_ordinary_follower_still_gets_a_reply(self):
|
||||||
|
hosted_registry.register(ACCOUNT_B_UID)
|
||||||
|
service = self._service()
|
||||||
|
service._send_auto_reply = AsyncMock()
|
||||||
|
|
||||||
|
result = await self._incoming_from(service, PEER_OF_B, "2")
|
||||||
|
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
service.match_reply.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
class FrontierDeviceExclusivityTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""同一个 frontier 设备号同时只允许一个账号建连。"""
|
||||||
|
|
||||||
|
WS_URL = (
|
||||||
|
"wss://frontier-im.douyin.com/ws/v2?fpid=9&device_id=987654321&"
|
||||||
|
"token=shared-token"
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
ws_module._FRONTIER_DEVICE_OWNERS.clear()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
ws_module._FRONTIER_DEVICE_OWNERS.clear()
|
||||||
|
|
||||||
|
def _client(self, account_id: int) -> DouyinImWsClient:
|
||||||
|
client = DouyinImWsClient(
|
||||||
|
DouyinImSession(cookies={"sessionid": "s"}, ws_urls=[self.WS_URL]),
|
||||||
|
AsyncMock(),
|
||||||
|
account_id=account_id,
|
||||||
|
)
|
||||||
|
client._running = True
|
||||||
|
client._task = SimpleNamespace(done=lambda: False)
|
||||||
|
return client
|
||||||
|
|
||||||
|
def test_second_account_is_denied_while_the_first_holds_the_device(self):
|
||||||
|
first = self._client(11)
|
||||||
|
second = self._client(12)
|
||||||
|
|
||||||
|
self.assertTrue(first._claim_frontier_device(self.WS_URL))
|
||||||
|
self.assertFalse(second._claim_frontier_device(self.WS_URL))
|
||||||
|
self.assertEqual(second._blocked_device_owner_id, 11)
|
||||||
|
# 让出方不会被误标为已占用,重连时仍是 HTTP 轮询兜底
|
||||||
|
self.assertFalse(second.connected)
|
||||||
|
|
||||||
|
def test_device_is_taken_over_after_the_owner_stops(self):
|
||||||
|
first = self._client(11)
|
||||||
|
second = self._client(12)
|
||||||
|
self.assertTrue(first._claim_frontier_device(self.WS_URL))
|
||||||
|
|
||||||
|
first._running = False
|
||||||
|
first._release_frontier_device()
|
||||||
|
|
||||||
|
self.assertTrue(second._claim_frontier_device(self.WS_URL))
|
||||||
|
|
||||||
|
def test_same_account_reconnect_keeps_its_own_device(self):
|
||||||
|
client = self._client(11)
|
||||||
|
self.assertTrue(client._claim_frontier_device(self.WS_URL))
|
||||||
|
self.assertTrue(client._claim_frontier_device(self.WS_URL))
|
||||||
|
|
||||||
|
async def test_run_loop_does_not_open_a_second_connection(self):
|
||||||
|
owner = self._client(11)
|
||||||
|
self.assertTrue(owner._claim_frontier_device(self.WS_URL))
|
||||||
|
|
||||||
|
blocked = self._client(12)
|
||||||
|
blocked._prepare_url = AsyncMock(return_value=self.WS_URL)
|
||||||
|
run_connection = AsyncMock()
|
||||||
|
blocked._run_connection = run_connection
|
||||||
|
|
||||||
|
async def stop_after_first_backoff(_seconds):
|
||||||
|
blocked._running = False
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(ws_module, "_reconnect_delay", return_value=0.0),
|
||||||
|
patch.object(ws_module.system_logger, "record") as record,
|
||||||
|
patch.object(ws_module.asyncio, "sleep", stop_after_first_backoff),
|
||||||
|
):
|
||||||
|
await asyncio.wait_for(blocked._run_loop(self.WS_URL), timeout=1.0)
|
||||||
|
|
||||||
|
run_connection.assert_not_awaited()
|
||||||
|
self.assertFalse(blocked.connected)
|
||||||
|
self.assertTrue(record.called)
|
||||||
|
|
||||||
|
def test_url_without_device_id_is_not_blocked(self):
|
||||||
|
first = self._client(11)
|
||||||
|
second = self._client(12)
|
||||||
|
url = "wss://frontier-im.douyin.com/ws/v2?fpid=9&token=t"
|
||||||
|
|
||||||
|
self.assertTrue(first._claim_frontier_device(url))
|
||||||
|
self.assertTrue(second._claim_frontier_device(url))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
from sqlalchemy.schema import CreateTable
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(BACKEND_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BACKEND_DIR))
|
||||||
|
|
||||||
|
from rpa_engine.egress_channels import (
|
||||||
|
EgressChannel,
|
||||||
|
EgressChannelUnavailable,
|
||||||
|
EgressSnapshot,
|
||||||
|
LocalAddress,
|
||||||
|
discover_egress_channels,
|
||||||
|
reset_egress_cache_for_tests,
|
||||||
|
resolve_send_channels,
|
||||||
|
)
|
||||||
|
from rpa_engine.source_bound_proxy import SourceBoundProxy
|
||||||
|
from models.db_migrate import migrate_accounts_table
|
||||||
|
from models.models import Account
|
||||||
|
|
||||||
|
|
||||||
|
class EgressChannelTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
reset_egress_cache_for_tests()
|
||||||
|
|
||||||
|
async def test_discovery_deduplicates_public_ip_and_keeps_bindable_source(self):
|
||||||
|
candidates = [
|
||||||
|
LocalAddress(None, "default", True),
|
||||||
|
LocalAddress("10.0.0.5", "eth0"),
|
||||||
|
LocalAddress("10.0.0.6", "eth0:1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def probe(candidate):
|
||||||
|
public_ip = "203.0.113.10" if candidate.source_ip != "10.0.0.6" else "203.0.113.11"
|
||||||
|
return (
|
||||||
|
EgressChannel(
|
||||||
|
public_ip=public_ip,
|
||||||
|
source_ip=candidate.source_ip,
|
||||||
|
interface=candidate.interface,
|
||||||
|
is_default=candidate.is_default,
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"rpa_engine.egress_channels.local_address_candidates",
|
||||||
|
return_value=candidates,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"rpa_engine.egress_channels._probe_local_address",
|
||||||
|
AsyncMock(side_effect=probe),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
snapshot = await discover_egress_channels(force=True)
|
||||||
|
|
||||||
|
self.assertEqual([item.public_ip for item in snapshot.channels], ["203.0.113.10", "203.0.113.11"])
|
||||||
|
self.assertEqual(snapshot.channels[0].source_ip, "10.0.0.5")
|
||||||
|
self.assertTrue(snapshot.channels[0].is_default)
|
||||||
|
|
||||||
|
async def test_selected_channel_is_first_and_attempt_count_is_bounded(self):
|
||||||
|
snapshot = EgressSnapshot(
|
||||||
|
channels=(
|
||||||
|
EgressChannel("198.51.100.1", "10.0.0.1", "eth0", True),
|
||||||
|
EgressChannel("198.51.100.2", "10.0.0.2", "eth0:1"),
|
||||||
|
EgressChannel("198.51.100.3", "10.0.0.3", "eth0:2"),
|
||||||
|
),
|
||||||
|
errors=(),
|
||||||
|
detected_at=time.time(),
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"rpa_engine.egress_channels.discover_egress_channels",
|
||||||
|
AsyncMock(return_value=snapshot),
|
||||||
|
):
|
||||||
|
routes = await resolve_send_channels("198.51.100.2", 2)
|
||||||
|
|
||||||
|
self.assertEqual([item.public_ip for item in routes], ["198.51.100.2", "198.51.100.1"])
|
||||||
|
|
||||||
|
async def test_missing_selected_channel_fails_closed(self):
|
||||||
|
snapshot = EgressSnapshot(
|
||||||
|
channels=(EgressChannel("198.51.100.1", None, "default", True),),
|
||||||
|
errors=(),
|
||||||
|
detected_at=time.time(),
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"rpa_engine.egress_channels.discover_egress_channels",
|
||||||
|
AsyncMock(return_value=snapshot),
|
||||||
|
):
|
||||||
|
with self.assertRaises(EgressChannelUnavailable):
|
||||||
|
await resolve_send_channels("198.51.100.99", 2)
|
||||||
|
|
||||||
|
async def test_browser_proxy_binds_selected_source_address(self):
|
||||||
|
observed_peer = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
async def target_handler(reader, writer):
|
||||||
|
if not observed_peer.done():
|
||||||
|
observed_peer.set_result(writer.get_extra_info("peername")[0])
|
||||||
|
payload = await reader.readexactly(4)
|
||||||
|
writer.write(payload)
|
||||||
|
await writer.drain()
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
target = await asyncio.start_server(target_handler, "127.0.0.1", 0)
|
||||||
|
target_port = target.sockets[0].getsockname()[1]
|
||||||
|
proxy = await SourceBoundProxy("127.0.0.2").start()
|
||||||
|
writer = None
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.open_connection(
|
||||||
|
"127.0.0.1",
|
||||||
|
int(proxy.server_url.rpartition(":")[2]),
|
||||||
|
)
|
||||||
|
writer.write(
|
||||||
|
(
|
||||||
|
f"CONNECT 127.0.0.1:{target_port} HTTP/1.1\r\n"
|
||||||
|
f"Host: 127.0.0.1:{target_port}\r\n\r\n"
|
||||||
|
).encode("ascii")
|
||||||
|
)
|
||||||
|
await writer.drain()
|
||||||
|
response = await reader.readuntil(b"\r\n\r\n")
|
||||||
|
self.assertIn(b"200 Connection Established", response)
|
||||||
|
|
||||||
|
writer.write(b"ping")
|
||||||
|
await writer.drain()
|
||||||
|
self.assertEqual(await reader.readexactly(4), b"ping")
|
||||||
|
self.assertEqual(await asyncio.wait_for(observed_peer, 1), "127.0.0.2")
|
||||||
|
finally:
|
||||||
|
if writer is not None:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
await proxy.close()
|
||||||
|
target.close()
|
||||||
|
await target.wait_closed()
|
||||||
|
|
||||||
|
|
||||||
|
class EgressMigrationTests(unittest.TestCase):
|
||||||
|
def test_mysql_accounts_uses_longtext_for_browser_payloads(self):
|
||||||
|
ddl = str(CreateTable(Account.__table__).compile(dialect=mysql.dialect()))
|
||||||
|
|
||||||
|
self.assertIn("cookie_data LONGTEXT", ddl)
|
||||||
|
self.assertIn("im_session_data LONGTEXT", ddl)
|
||||||
|
self.assertIn("qr_code_base64 LONGTEXT", ddl)
|
||||||
|
|
||||||
|
def test_old_accounts_table_receives_egress_columns(self):
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(text("CREATE TABLE accounts (id INTEGER PRIMARY KEY)"))
|
||||||
|
migrate_accounts_table(connection)
|
||||||
|
columns = {item["name"] for item in inspect(connection).get_columns("accounts")}
|
||||||
|
|
||||||
|
self.assertIn("egress_public_ip", columns)
|
||||||
|
self.assertIn("egress_auto_attempts", columns)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(BACKEND_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BACKEND_DIR))
|
||||||
|
|
||||||
|
from rpa_engine.douyin_im.pb_decode import analyze_send_response
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeSendResponseTests(unittest.TestCase):
|
||||||
|
def test_standalone_kick_json_is_not_decoded_as_protobuf(self):
|
||||||
|
result = analyze_send_response(b'{"decision": "KICK"}')
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertEqual(result["decision"], "KICK")
|
||||||
|
self.assertEqual(result["summary"], "JSON响应 decision=KICK")
|
||||||
|
self.assertNotIn("unsupported wire type", result["summary"])
|
||||||
|
|
||||||
|
def test_malformed_json_still_returns_controlled_decode_summary(self):
|
||||||
|
result = analyze_send_response(b'{"decision":')
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertIn("解码失败", result["summary"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
"""接收私信链路的回归测试。
|
||||||
|
|
||||||
|
覆盖三个曾让「托管中收不到抖音下发的私信」的缺陷:
|
||||||
|
1. frontier 长连接地址用了账号 UID 而不是设备号,握手成功却订阅错地址;
|
||||||
|
2. 浏览器本次登录抓到的真实 frontier 地址被 DB 里的旧地址挤掉;
|
||||||
|
3. PushFrame 负载是 gzip / payloadType 不是 'pb' 时整帧被丢弃。
|
||||||
|
另外覆盖会话列表接口被抖音拒绝时不能再伪装成「收件箱为空」。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
os.environ.setdefault("KEFU_DB_TYPE", "sqlite")
|
||||||
|
os.environ.setdefault("KEFU_DATABASE_URL", "")
|
||||||
|
os.environ.setdefault("KEFU_DB_PATH", str(BACKEND_DIR / "kefu.db"))
|
||||||
|
if str(BACKEND_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BACKEND_DIR))
|
||||||
|
|
||||||
|
from rpa_engine.douyin_im import frontier as frontier_module
|
||||||
|
from rpa_engine.douyin_im.auth import DouyinAuth
|
||||||
|
from rpa_engine.douyin_im.frontier import ensure_frontier_ws
|
||||||
|
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
||||||
|
from rpa_engine.douyin_im.protocol import parse_ws_payload
|
||||||
|
from rpa_engine.douyin_im.session import DouyinImSession
|
||||||
|
from rpa_engine.douyin_im.static import Live_pb2, Response_pb2
|
||||||
|
from rpa_engine.playwright_worker import DouyinWorker
|
||||||
|
|
||||||
|
|
||||||
|
def _build_push_frame(
|
||||||
|
*,
|
||||||
|
conversation_id: str,
|
||||||
|
sender: int,
|
||||||
|
content: str,
|
||||||
|
message_type: int = 7,
|
||||||
|
server_message_id: int = 7665317099296081465,
|
||||||
|
encoding: str = "",
|
||||||
|
payload_type: str = "pb",
|
||||||
|
) -> bytes:
|
||||||
|
response = Response_pb2.Response()
|
||||||
|
notify = response.body.new_message_notify
|
||||||
|
notify.conversation_id = conversation_id
|
||||||
|
message = notify.message
|
||||||
|
message.conversation_id = conversation_id
|
||||||
|
message.conversation_type = 1
|
||||||
|
message.server_message_id = server_message_id
|
||||||
|
message.message_type = message_type
|
||||||
|
message.sender = sender
|
||||||
|
message.content = content
|
||||||
|
|
||||||
|
body = response.SerializeToString()
|
||||||
|
if encoding == "gzip":
|
||||||
|
body = gzip.compress(body)
|
||||||
|
|
||||||
|
frame = Live_pb2.PushFrame()
|
||||||
|
frame.seqId = 1
|
||||||
|
frame.service = 6
|
||||||
|
frame.method = 2
|
||||||
|
frame.payloadEncoding = encoding
|
||||||
|
frame.payloadType = payload_type
|
||||||
|
frame.payload = body
|
||||||
|
return frame.SerializeToString()
|
||||||
|
|
||||||
|
|
||||||
|
class FrontierAddressTests(unittest.TestCase):
|
||||||
|
"""frontier 按 device_id 寻址,不能用账号 UID 顶替。"""
|
||||||
|
|
||||||
|
def _session(self) -> DouyinImSession:
|
||||||
|
return DouyinImSession(
|
||||||
|
cookies={"sessionid": "6313fec013ec0000000000000000abcd"},
|
||||||
|
# query/user 返回的 id:本浏览器的设备注册号
|
||||||
|
device_id="7678285795559818786",
|
||||||
|
web_id="7678286623234475535",
|
||||||
|
my_uid=2609567359568155,
|
||||||
|
uid_verified=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_built_url_uses_device_id_not_account_uid(self):
|
||||||
|
session = self._session()
|
||||||
|
|
||||||
|
url = ensure_frontier_ws(session)
|
||||||
|
|
||||||
|
self.assertIsNotNone(url)
|
||||||
|
self.assertIn("device_id=7678285795559818786", url)
|
||||||
|
self.assertNotIn("device_id=2609567359568155", url)
|
||||||
|
|
||||||
|
def test_missing_device_id_falls_back_to_query_user_lookup(self):
|
||||||
|
session = self._session()
|
||||||
|
session.device_id = ""
|
||||||
|
session.web_id = ""
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
frontier_module,
|
||||||
|
"fetch_device_id",
|
||||||
|
return_value="7678285795559818786",
|
||||||
|
) as fetch:
|
||||||
|
url = ensure_frontier_ws(session)
|
||||||
|
|
||||||
|
fetch.assert_called_once()
|
||||||
|
self.assertIn("device_id=7678285795559818786", url)
|
||||||
|
|
||||||
|
def test_proto_auth_keeps_device_id_for_verified_uid(self):
|
||||||
|
session = self._session()
|
||||||
|
|
||||||
|
auth = DouyinAuth.from_im_session(session)
|
||||||
|
|
||||||
|
self.assertEqual(auth.device_id, "7678285795559818786")
|
||||||
|
|
||||||
|
|
||||||
|
class CapturedFrontierUrlTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""浏览器本次抓到的真实地址必须压过 DB 里的旧地址。"""
|
||||||
|
|
||||||
|
async def test_fresh_browser_ws_url_wins_over_cached_url(self):
|
||||||
|
cached = (
|
||||||
|
"wss://frontier-im.douyin.com/ws/v2?aid=6383&device_platform=douyin_pc"
|
||||||
|
"&fpid=9&device_id=7678285795559818786&token=stale&access_key=stale"
|
||||||
|
)
|
||||||
|
captured = (
|
||||||
|
"wss://frontier31-normal.zijieapi.com/ws/v2?aid=6383&fpid=9"
|
||||||
|
"&device_id=7678285795559818786&access_key=realkey&token=realtoken"
|
||||||
|
)
|
||||||
|
saved = DouyinImSession(
|
||||||
|
cookies={"sessionid": "6313fec013ec0000000000000000abcd"},
|
||||||
|
ws_urls=[cached],
|
||||||
|
device_id="7678285795559818786",
|
||||||
|
my_uid=2609567359568155,
|
||||||
|
)
|
||||||
|
row = SimpleNamespace(
|
||||||
|
im_session_data=json.dumps(saved.to_dict()),
|
||||||
|
cookie_updated_at=datetime.utcnow(),
|
||||||
|
uid=None,
|
||||||
|
profile_updated_at=None,
|
||||||
|
)
|
||||||
|
result = MagicMock()
|
||||||
|
result.one_or_none.return_value = row
|
||||||
|
db = SimpleNamespace(execute=AsyncMock(return_value=result), close=AsyncMock())
|
||||||
|
|
||||||
|
worker = DouyinWorker(account_id=400, login_mode="browser")
|
||||||
|
worker.get_db = AsyncMock(return_value=db)
|
||||||
|
worker._load_raw_user_agent = AsyncMock(return_value="test-agent")
|
||||||
|
|
||||||
|
session = await worker._build_im_session_from_storage(
|
||||||
|
{"cookies": [{"name": "sessionid", "value": "6313fec013ec0000000000000000abcd"}]},
|
||||||
|
{"ws_urls": [captured]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(session.ws_urls[0], captured)
|
||||||
|
self.assertEqual(ensure_frontier_ws(session), captured)
|
||||||
|
|
||||||
|
|
||||||
|
class PushFramePayloadTests(unittest.TestCase):
|
||||||
|
"""PushFrame 负载的编码/类型不能再决定整帧被不被丢弃。"""
|
||||||
|
|
||||||
|
def test_plain_protobuf_frame_is_parsed(self):
|
||||||
|
raw = _build_push_frame(
|
||||||
|
conversation_id="0:1:869032150442612:2609567359568155",
|
||||||
|
sender=869032150442612,
|
||||||
|
content=json.dumps({"text": "你好", "aweType": 700}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = parse_ws_payload(raw)
|
||||||
|
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
self.assertEqual(messages[0]["sender_uid"], "869032150442612")
|
||||||
|
self.assertEqual(
|
||||||
|
messages[0]["conversation_id"], "0:1:869032150442612:2609567359568155"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_gzip_encoded_frame_is_parsed(self):
|
||||||
|
raw = _build_push_frame(
|
||||||
|
conversation_id="0:1:869032150442612:2609567359568155",
|
||||||
|
sender=869032150442612,
|
||||||
|
content=json.dumps({"text": "在吗", "aweType": 700}, ensure_ascii=False),
|
||||||
|
encoding="gzip",
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = parse_ws_payload(raw)
|
||||||
|
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
self.assertEqual(messages[0]["sender_uid"], "869032150442612")
|
||||||
|
|
||||||
|
def test_non_pb_payload_type_is_still_parsed(self):
|
||||||
|
# 现网 frontier 帧会带 payloadType='text/json';只认 'pb' 会整帧丢弃。
|
||||||
|
raw = _build_push_frame(
|
||||||
|
conversation_id="0:1:869032150442612:2609567359568155",
|
||||||
|
sender=869032150442612,
|
||||||
|
content=json.dumps({"text": "在吗", "aweType": 700}, ensure_ascii=False),
|
||||||
|
payload_type="text/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = parse_ws_payload(raw)
|
||||||
|
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
self.assertEqual(messages[0]["sender_uid"], "869032150442612")
|
||||||
|
|
||||||
|
def test_empty_payload_control_frame_yields_no_message(self):
|
||||||
|
frame = Live_pb2.PushFrame()
|
||||||
|
frame.service = 6
|
||||||
|
frame.method = 2
|
||||||
|
frame.payloadEncoding = "utf-8"
|
||||||
|
frame.payloadType = "text/json"
|
||||||
|
|
||||||
|
self.assertEqual(parse_ws_payload(frame.SerializeToString()), [])
|
||||||
|
|
||||||
|
|
||||||
|
class InboxProtobufTests(unittest.TestCase):
|
||||||
|
"""imapi 只认 protobuf:解析真实响应形状,而不是 JSON。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _response(*, cmd=200, status=0, message="OK", messages=()):
|
||||||
|
from rpa_engine.douyin_im.http_client import _pb_int, _pb_msg, _pb_str
|
||||||
|
|
||||||
|
entries = b""
|
||||||
|
for m in messages:
|
||||||
|
entries += _pb_msg(
|
||||||
|
1,
|
||||||
|
_pb_str(1, m["conversation_id"])
|
||||||
|
+ _pb_int(3, m["server_message_id"])
|
||||||
|
+ _pb_int(5, m.get("conversation_short_id", 0))
|
||||||
|
+ _pb_int(6, m.get("message_type", 7))
|
||||||
|
+ _pb_int(7, m["sender"])
|
||||||
|
+ _pb_str(8, m.get("content", "")),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
_pb_int(1, cmd)
|
||||||
|
+ _pb_int(3, status)
|
||||||
|
+ _pb_str(4, message)
|
||||||
|
+ _pb_msg(6, _pb_msg(cmd, entries))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_is_read_from_the_protobuf_envelope(self):
|
||||||
|
from rpa_engine.douyin_im.http_client import _pb_response_status
|
||||||
|
|
||||||
|
ok = self._response()
|
||||||
|
self.assertEqual(_pb_response_status(ok), (0, "OK"))
|
||||||
|
|
||||||
|
rejected = self._response(status=1, message="unexepcted session length")
|
||||||
|
self.assertEqual(
|
||||||
|
_pb_response_status(rejected),
|
||||||
|
(1, "unexepcted session length"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_message_bodies_are_extracted_from_the_inbox_response(self):
|
||||||
|
from rpa_engine.douyin_im.http_client import _pb_parse_inbox_messages
|
||||||
|
|
||||||
|
raw = self._response(
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"conversation_id": "0:1:2609567359568155:869032150442612",
|
||||||
|
"server_message_id": 7678140298052355621,
|
||||||
|
"conversation_short_id": 7654765893796266545,
|
||||||
|
"message_type": 7,
|
||||||
|
"sender": 869032150442612,
|
||||||
|
"content": '{"text":"你好"}',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = _pb_parse_inbox_messages(raw, 200)
|
||||||
|
|
||||||
|
self.assertEqual(len(parsed), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
parsed[0]["conversation_id"],
|
||||||
|
"0:1:2609567359568155:869032150442612",
|
||||||
|
)
|
||||||
|
self.assertEqual(parsed[0]["server_message_id"], "7678140298052355621")
|
||||||
|
self.assertEqual(parsed[0]["sender"], "869032150442612")
|
||||||
|
self.assertIn("你好", parsed[0]["content"])
|
||||||
|
|
||||||
|
def test_unrelated_protobuf_is_not_mistaken_for_a_message(self):
|
||||||
|
from rpa_engine.douyin_im.http_client import (
|
||||||
|
_pb_int, _pb_msg, _pb_parse_inbox_messages, _pb_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 一段带字符串字段 1 但不是 conversation_id 的子消息
|
||||||
|
noise = _pb_msg(6, _pb_msg(200, _pb_msg(1, _pb_str(1, "not-a-conv") + _pb_int(3, 5))))
|
||||||
|
self.assertEqual(_pb_parse_inbox_messages(noise, 200), [])
|
||||||
|
|
||||||
|
def test_empty_inbox_yields_no_messages(self):
|
||||||
|
from rpa_engine.douyin_im.http_client import _pb_parse_inbox_messages
|
||||||
|
|
||||||
|
self.assertEqual(_pb_parse_inbox_messages(self._response(), 200), [])
|
||||||
|
|
||||||
|
|
||||||
|
class InboxCursorAndListTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""轮询用小窗口,用户点开列表用全量——同一个 cmd,只是游标不同。"""
|
||||||
|
|
||||||
|
def _client(self) -> DouyinImHttpClient:
|
||||||
|
session = DouyinImSession(
|
||||||
|
cookies={"sessionid": "s", "x_tt_token": "00" + "a" * 353},
|
||||||
|
device_id="7678285795559818786",
|
||||||
|
my_uid=2609567359568155,
|
||||||
|
)
|
||||||
|
return DouyinImHttpClient(session, account_id=405)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cursor_from_payload(payload: bytes) -> int:
|
||||||
|
from rpa_engine.douyin_im.http_client import _pb_parse_fields
|
||||||
|
|
||||||
|
for fn, wt, val in _pb_parse_fields(payload):
|
||||||
|
if fn != 8 or wt != 2:
|
||||||
|
continue
|
||||||
|
for bfn, bwt, bval in _pb_parse_fields(val):
|
||||||
|
if bfn != 200 or bwt != 2:
|
||||||
|
continue
|
||||||
|
for cfn, cwt, cval in _pb_parse_fields(bval):
|
||||||
|
if cfn == 1 and cwt == 0:
|
||||||
|
return int(cval)
|
||||||
|
return -1
|
||||||
|
|
||||||
|
async def _capture_cursor(self, **kwargs) -> int:
|
||||||
|
from rpa_engine.douyin_im.http_client import _pb_int, _pb_str
|
||||||
|
|
||||||
|
client = self._client()
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
async def fake_post(url, auth, payload, **_kw):
|
||||||
|
captured["payload"] = payload
|
||||||
|
return SimpleNamespace(
|
||||||
|
content=_pb_int(1, 200) + _pb_int(3, 0) + _pb_str(4, "OK"),
|
||||||
|
raise_for_status=lambda: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(client, "_post_protobuf", fake_post):
|
||||||
|
await client.fetch_inbox_messages(**kwargs)
|
||||||
|
return self._cursor_from_payload(captured["payload"])
|
||||||
|
|
||||||
|
async def test_poll_window_sends_a_recent_microsecond_cursor(self):
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
cursor = await self._capture_cursor(lookback_seconds=1800)
|
||||||
|
now_us = int(_time.time() * 1_000_000)
|
||||||
|
|
||||||
|
self.assertGreater(cursor, 0)
|
||||||
|
# 游标应落在「大约半小时前」,允许几秒误差
|
||||||
|
self.assertLess(now_us - cursor, int(1810 * 1_000_000))
|
||||||
|
self.assertGreater(now_us - cursor, int(1790 * 1_000_000))
|
||||||
|
|
||||||
|
async def test_zero_lookback_means_no_cursor_not_now(self):
|
||||||
|
# lookback=0 若被算成 now,就只要「比此刻更新」的消息,永远是空列表。
|
||||||
|
self.assertEqual(await self._capture_cursor(lookback_seconds=0), 0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _page(*, entries=(), next_cursor=0, has_more=False, cmd=200):
|
||||||
|
from rpa_engine.douyin_im.http_client import _pb_int, _pb_msg, _pb_str
|
||||||
|
|
||||||
|
inner = b""
|
||||||
|
for short_id, conv_id in entries:
|
||||||
|
inner += _pb_msg(6, _pb_int(1, short_id) + _pb_str(4, conv_id))
|
||||||
|
inner += _pb_int(2, next_cursor) + _pb_int(3, 1 if has_more else 0)
|
||||||
|
return (
|
||||||
|
_pb_int(1, cmd)
|
||||||
|
+ _pb_int(3, 0)
|
||||||
|
+ _pb_str(4, "OK")
|
||||||
|
+ _pb_msg(6, _pb_msg(cmd, inner))
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_paging_follows_the_cursor_and_dedupes_conversations(self):
|
||||||
|
client = self._client()
|
||||||
|
pages = [
|
||||||
|
self._page(
|
||||||
|
entries=[(1, "0:1:10001:20001"), (2, "0:1:10001:20002")],
|
||||||
|
next_cursor=111,
|
||||||
|
has_more=True,
|
||||||
|
),
|
||||||
|
self._page(
|
||||||
|
# 第二页重复一个、新增一个
|
||||||
|
entries=[(2, "0:1:10001:20002"), (3, "0:1:10001:20003")],
|
||||||
|
next_cursor=222,
|
||||||
|
has_more=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
cursors: list[int] = []
|
||||||
|
|
||||||
|
async def fake_post(url, auth, payload, **_kw):
|
||||||
|
cursors.append(self._cursor_from_payload(payload))
|
||||||
|
return SimpleNamespace(
|
||||||
|
content=pages[len(cursors) - 1], raise_for_status=lambda: None
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(client, "_post_protobuf", fake_post):
|
||||||
|
await client.fetch_inbox_messages(lookback_seconds=0, max_pages=2)
|
||||||
|
|
||||||
|
self.assertEqual(cursors, [0, 111])
|
||||||
|
self.assertEqual(
|
||||||
|
[c["conversation_id"] for c in client._last_inbox_conversations],
|
||||||
|
["0:1:10001:20001", "0:1:10001:20002", "0:1:10001:20003"],
|
||||||
|
)
|
||||||
|
# 预算用完但抖音还说 has_more:必须承认列表不完整
|
||||||
|
self.assertTrue(client.inbox_truncated)
|
||||||
|
|
||||||
|
async def test_last_page_is_not_reported_as_truncated(self):
|
||||||
|
client = self._client()
|
||||||
|
page = self._page(entries=[(1, "0:1:10001:20001")], has_more=False)
|
||||||
|
|
||||||
|
async def fake_post(url, auth, payload, **_kw):
|
||||||
|
return SimpleNamespace(content=page, raise_for_status=lambda: None)
|
||||||
|
|
||||||
|
with patch.object(client, "_post_protobuf", fake_post):
|
||||||
|
await client.fetch_inbox_messages(lookback_seconds=0, max_pages=5)
|
||||||
|
|
||||||
|
self.assertFalse(client.inbox_truncated)
|
||||||
|
|
||||||
|
async def test_a_stalled_cursor_stops_paging(self):
|
||||||
|
client = self._client()
|
||||||
|
# 抖音回 has_more=1 但游标不前进:不能无限翻同一页
|
||||||
|
page = self._page(
|
||||||
|
entries=[(1, "0:1:10001:20001")], next_cursor=0, has_more=True
|
||||||
|
)
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
async def fake_post(url, auth, payload, **_kw):
|
||||||
|
calls["n"] += 1
|
||||||
|
return SimpleNamespace(content=page, raise_for_status=lambda: None)
|
||||||
|
|
||||||
|
with patch.object(client, "_post_protobuf", fake_post):
|
||||||
|
await client.fetch_inbox_messages(lookback_seconds=0, max_pages=10)
|
||||||
|
|
||||||
|
self.assertEqual(calls["n"], 1)
|
||||||
|
|
||||||
|
async def test_conversations_without_recent_messages_still_listed(self):
|
||||||
|
client = self._client()
|
||||||
|
client.fetch_inbox_messages = AsyncMock(return_value=[])
|
||||||
|
client._last_inbox_conversations = [
|
||||||
|
{"conversation_id": "0:1:10001:20001", "conversation_short_id": "555"},
|
||||||
|
{"conversation_id": "0:1:10001:20002", "conversation_short_id": "666"},
|
||||||
|
]
|
||||||
|
|
||||||
|
rows = await client.get_conversations(
|
||||||
|
enrich_profiles=False, lookback_seconds=0
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{r["conversation_id"] for r in rows},
|
||||||
|
{"0:1:10001:20001", "0:1:10001:20002"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
client.session.conv_meta["0:1:10001:20002"]["conversation_short_id"],
|
||||||
|
"666",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_control_frames_never_become_a_conversation_preview(self):
|
||||||
|
client = self._client()
|
||||||
|
client.fetch_inbox_messages = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"conversation_id": "0:1:10001:20001",
|
||||||
|
"server_message_id": "100",
|
||||||
|
"message_type": 7,
|
||||||
|
"sender": "20001",
|
||||||
|
"content": '{"text":"真实消息"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_id": "0:1:10001:20001",
|
||||||
|
"server_message_id": "200",
|
||||||
|
"message_type": 50001,
|
||||||
|
"sender": "20001",
|
||||||
|
"content": '{"command_type":6,"conversation_id":"0:1:10001:20001"}',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = await client.get_conversations(enrich_profiles=False)
|
||||||
|
|
||||||
|
# 控制帧 server_message_id 更大,但不能顶掉真实消息成为预览,
|
||||||
|
# 否则 _handle_incoming 会拿它去匹配自动回复。
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertIn("真实消息", rows[0]["content"])
|
||||||
|
self.assertEqual(rows[0]["server_message_id"], "100")
|
||||||
|
|
||||||
|
|
||||||
|
class ReadRequestTokenTests(unittest.TestCase):
|
||||||
|
"""读接口的 Request.token 必须是 x_tt_token。
|
||||||
|
|
||||||
|
带 auth.ticket 时抖音照样回 status_code=0 "OK",但把调用方当匿名用户,
|
||||||
|
正文恒为空——和「收件箱没有消息」完全无法区分,是最难发现的那类故障。
|
||||||
|
实测同一请求只换 token:auth.ticket 73 字节 0 条,x_tt_token 113KB 47 条。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _auth(self):
|
||||||
|
session = DouyinImSession(
|
||||||
|
cookies={
|
||||||
|
"sessionid": "6313fec013ec0000000000000000abcd",
|
||||||
|
"x_tt_token": "00" + "a" * 353,
|
||||||
|
},
|
||||||
|
device_id="7678285795559818786",
|
||||||
|
my_uid=2609567359568155,
|
||||||
|
)
|
||||||
|
return DouyinAuth.from_im_session(session)
|
||||||
|
|
||||||
|
def test_read_request_uses_x_tt_token(self):
|
||||||
|
from rpa_engine.douyin_im.proto_builder import ProtoBuilder
|
||||||
|
|
||||||
|
auth = self._auth()
|
||||||
|
request = ProtoBuilder.build_read_request(auth, 200)
|
||||||
|
|
||||||
|
self.assertEqual(request.token, "00" + "a" * 353)
|
||||||
|
self.assertNotEqual(request.token, auth.ticket)
|
||||||
|
|
||||||
|
def test_read_request_keeps_ticket_when_cookie_missing(self):
|
||||||
|
from rpa_engine.douyin_im.proto_builder import ProtoBuilder
|
||||||
|
|
||||||
|
session = DouyinImSession(
|
||||||
|
cookies={"sessionid": "6313fec013ec0000000000000000abcd"},
|
||||||
|
device_id="7678285795559818786",
|
||||||
|
my_uid=2609567359568155,
|
||||||
|
)
|
||||||
|
auth = DouyinAuth.from_im_session(session)
|
||||||
|
|
||||||
|
request = ProtoBuilder.build_read_request(auth, 200)
|
||||||
|
|
||||||
|
self.assertEqual(request.token, auth.ticket or "")
|
||||||
|
|
||||||
|
def test_send_request_is_left_on_the_normal_envelope(self):
|
||||||
|
from rpa_engine.douyin_im.proto_builder import ProtoBuilder
|
||||||
|
|
||||||
|
# 发送接口另有 bd-ticket-guard 签名且线上可用,不能顺手改掉它的 token。
|
||||||
|
auth = self._auth()
|
||||||
|
request = ProtoBuilder.build_normal_request(auth, 100)
|
||||||
|
|
||||||
|
self.assertEqual(request.token, auth.ticket or "")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthoritativeUidTests(unittest.TestCase):
|
||||||
|
"""imapi 响应字段 13 是抖音认定的本账号 IM uid。"""
|
||||||
|
|
||||||
|
def test_response_uid_corrects_a_wrong_my_uid(self):
|
||||||
|
from rpa_engine.douyin_im.http_client import _pb_int, _pb_str
|
||||||
|
|
||||||
|
session = DouyinImSession(
|
||||||
|
cookies={"sessionid": "s"},
|
||||||
|
my_uid=7678285795559818786, # 误把 frontier 设备号当成了 IM uid
|
||||||
|
)
|
||||||
|
client = DouyinImHttpClient(session, account_id=404)
|
||||||
|
content = (
|
||||||
|
_pb_int(1, 200)
|
||||||
|
+ _pb_int(3, 0)
|
||||||
|
+ _pb_str(4, "OK")
|
||||||
|
+ _pb_int(13, 2609567359568155)
|
||||||
|
)
|
||||||
|
|
||||||
|
client._adopt_authoritative_uid(content)
|
||||||
|
|
||||||
|
self.assertEqual(session.my_uid, 2609567359568155)
|
||||||
|
self.assertTrue(session.uid_verified)
|
||||||
|
|
||||||
|
def test_matching_uid_is_left_alone(self):
|
||||||
|
from rpa_engine.douyin_im.http_client import _pb_int, _pb_str
|
||||||
|
|
||||||
|
session = DouyinImSession(cookies={"sessionid": "s"}, my_uid=2609567359568155)
|
||||||
|
client = DouyinImHttpClient(session, account_id=404)
|
||||||
|
content = _pb_int(1, 200) + _pb_int(3, 0) + _pb_str(4, "OK") + _pb_int(
|
||||||
|
13, 2609567359568155
|
||||||
|
)
|
||||||
|
|
||||||
|
client._adopt_authoritative_uid(content)
|
||||||
|
|
||||||
|
self.assertEqual(session.my_uid, 2609567359568155)
|
||||||
|
self.assertFalse(session.uid_verified)
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationListRejectionTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""接口被拒不能再伪装成「收件箱为空」。"""
|
||||||
|
|
||||||
|
def _client(self) -> DouyinImHttpClient:
|
||||||
|
session = DouyinImSession(
|
||||||
|
cookies={"sessionid": "6313fec013ec0000000000000000abcd"},
|
||||||
|
device_id="7678285795559818786",
|
||||||
|
my_uid=2609567359568155,
|
||||||
|
)
|
||||||
|
return DouyinImHttpClient(session, account_id=401)
|
||||||
|
|
||||||
|
async def test_rejected_protobuf_response_is_reported(self):
|
||||||
|
client = self._client()
|
||||||
|
raw = InboxProtobufTests._response(
|
||||||
|
status=1, message="unexepcted session length"
|
||||||
|
)
|
||||||
|
post = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
content=raw, raise_for_status=lambda: None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(client, "_post_protobuf", post),
|
||||||
|
patch(
|
||||||
|
"rpa_engine.douyin_im.auth.DouyinAuth.from_im_session",
|
||||||
|
return_value=SimpleNamespace(source_ip=""),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"rpa_engine.douyin_im.proto_builder.ProtoBuilder.build_normal_request",
|
||||||
|
return_value=SimpleNamespace(SerializeToString=lambda: b""),
|
||||||
|
),
|
||||||
|
patch.object(client, "_report_conversation_list_rejected") as report,
|
||||||
|
):
|
||||||
|
self.assertEqual(await client.fetch_inbox_messages(), [])
|
||||||
|
|
||||||
|
post.assert_awaited_once()
|
||||||
|
report.assert_called_once_with("unexepcted session length")
|
||||||
|
|
||||||
|
async def test_rejection_marks_the_endpoint_unsupported(self):
|
||||||
|
client = self._client()
|
||||||
|
client._report_conversation_list_rejected("unexepcted session length")
|
||||||
|
|
||||||
|
self.assertTrue(client.conversation_list_unsupported)
|
||||||
|
self.assertIn("unexepcted session length", client.last_error)
|
||||||
|
|
||||||
|
async def test_empty_but_successful_inbox_is_not_reported_as_failure(self):
|
||||||
|
client = self._client()
|
||||||
|
client.fetch_inbox_messages = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
client, "_report_conversation_list_rejected"
|
||||||
|
) as report:
|
||||||
|
self.assertEqual(
|
||||||
|
await client.get_conversations(enrich_profiles=False), []
|
||||||
|
)
|
||||||
|
|
||||||
|
report.assert_not_called()
|
||||||
|
self.assertEqual(client.last_error, "")
|
||||||
|
self.assertFalse(client.conversation_list_unsupported)
|
||||||
|
|
||||||
|
|
||||||
|
class LoggedOutDetectionTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""抖音回「用户未登录」时必须明确报出来,不能当成资料接口抖动。"""
|
||||||
|
|
||||||
|
def test_status_code_8_is_reported_as_logged_out(self):
|
||||||
|
from rpa_engine import account_profile as ap
|
||||||
|
|
||||||
|
auth = SimpleNamespace(cookie={}, msToken="t", get_uid=lambda: "938334054809296")
|
||||||
|
payloads = [
|
||||||
|
{"status_code": 0, "user_uid": "938334054809296"},
|
||||||
|
{"status_code": 8, "status_msg": "用户未登录", "user": None},
|
||||||
|
{"status_code": 8, "status_msg": "用户未登录", "user": None},
|
||||||
|
]
|
||||||
|
responses = [SimpleNamespace(json=lambda v=v: v) for v in payloads]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(ap, "_build_auth", return_value=(auth, "ua")),
|
||||||
|
patch.object(ap.requests, "get", side_effect=responses),
|
||||||
|
patch.object(ap, "generate_a_bogus", return_value="a-bogus"),
|
||||||
|
patch.object(ap, "generate_webid", return_value="web-id"),
|
||||||
|
patch.object(ap, "_requests_proxies", return_value=None),
|
||||||
|
):
|
||||||
|
detail = ap.fetch_douyin_profile_detail_sync("cookie-json", "ua")
|
||||||
|
|
||||||
|
self.assertTrue(detail["logged_out"])
|
||||||
|
self.assertFalse(detail["fetched"])
|
||||||
|
self.assertIn("用户未登录", detail["message"])
|
||||||
|
|
||||||
|
async def test_hosting_reports_logged_out_once(self):
|
||||||
|
worker = DouyinWorker(account_id=403, login_mode="im_direct")
|
||||||
|
with patch(
|
||||||
|
"rpa_engine.playwright_worker.system_logger.record"
|
||||||
|
) as record:
|
||||||
|
await worker._report_douyin_logged_out("抖音返回「用户未登录」")
|
||||||
|
await worker._report_douyin_logged_out("抖音返回「用户未登录」")
|
||||||
|
|
||||||
|
record.assert_called_once()
|
||||||
|
self.assertEqual(record.call_args.kwargs["level"], "error")
|
||||||
|
|
||||||
|
|
||||||
|
class ReconciliationBackoffTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""被抖音拒绝过的接口不能每 120 秒再白打一次。"""
|
||||||
|
|
||||||
|
def _service(self):
|
||||||
|
from rpa_engine.douyin_im.service import DouyinImService
|
||||||
|
|
||||||
|
return DouyinImService(
|
||||||
|
session=DouyinImSession(
|
||||||
|
cookies={"sessionid": "6313fec013ec0000000000000000abcd"},
|
||||||
|
device_id="7678285795559818786",
|
||||||
|
my_uid=2609567359568155,
|
||||||
|
),
|
||||||
|
match_reply=AsyncMock(return_value=[]),
|
||||||
|
log_fn=AsyncMock(),
|
||||||
|
account_id=402,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_second_poll_skips_a_rejected_endpoint(self):
|
||||||
|
service = self._service()
|
||||||
|
|
||||||
|
client = MagicMock()
|
||||||
|
client.get_conversations = AsyncMock(return_value=[])
|
||||||
|
client.conversation_list_unsupported = True
|
||||||
|
client.__aenter__ = AsyncMock(return_value=client)
|
||||||
|
client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"rpa_engine.douyin_im.service.DouyinImHttpClient",
|
||||||
|
return_value=client,
|
||||||
|
):
|
||||||
|
self.assertEqual(await service._poll_conversations(), [])
|
||||||
|
self.assertTrue(service._conversation_list_unsupported)
|
||||||
|
# 第二轮完全不再构造 HTTP 客户端 / 发请求
|
||||||
|
self.assertEqual(await service._poll_conversations(), [])
|
||||||
|
|
||||||
|
client.get_conversations.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -18,6 +18,8 @@ if str(BACKEND_DIR) not in sys.path:
|
|||||||
|
|
||||||
from auth.system_settings import SystemSettingsData, set_cached_settings
|
from auth.system_settings import SystemSettingsData, set_cached_settings
|
||||||
from rpa_engine.douyin_im.service import DouyinImService
|
from rpa_engine.douyin_im.service import DouyinImService
|
||||||
|
from rpa_engine.douyin_im.session import DouyinImSession
|
||||||
|
from rpa_engine.douyin_im import service as service_module
|
||||||
from rpa_engine.playwright_worker import DouyinWorker
|
from rpa_engine.playwright_worker import DouyinWorker
|
||||||
|
|
||||||
|
|
||||||
@@ -114,6 +116,107 @@ def _build_service(delay_seconds: int = 60):
|
|||||||
|
|
||||||
|
|
||||||
class ReplyQueueIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
class ReplyQueueIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_kick_does_not_replay_through_browser_fallback(self):
|
||||||
|
callback = AsyncMock()
|
||||||
|
fallback = AsyncMock(return_value=(True, "must not run"))
|
||||||
|
session = DouyinImSession(cookies={"sessionid": "test"}, my_uid=999)
|
||||||
|
service = DouyinImService(
|
||||||
|
session=session,
|
||||||
|
match_reply=AsyncMock(),
|
||||||
|
log_fn=AsyncMock(),
|
||||||
|
account_id=1,
|
||||||
|
send_fallback=fallback,
|
||||||
|
on_session_invalid=callback,
|
||||||
|
)
|
||||||
|
service._running = True
|
||||||
|
kicked_http = SimpleNamespace(
|
||||||
|
send_text_message=AsyncMock(return_value=False),
|
||||||
|
last_error="decision=KICK",
|
||||||
|
last_send_needs_refresh=False,
|
||||||
|
)
|
||||||
|
context = AsyncMock()
|
||||||
|
context.__aenter__.return_value = kicked_http
|
||||||
|
context.__aexit__.return_value = None
|
||||||
|
|
||||||
|
with (
|
||||||
|
unittest.mock.patch.object(
|
||||||
|
service_module, "DouyinImHttpClient", return_value=context
|
||||||
|
),
|
||||||
|
unittest.mock.patch(
|
||||||
|
"rpa_engine.douyin_im.service.system_logger.record", Mock()
|
||||||
|
),
|
||||||
|
):
|
||||||
|
sent, _ = await service._send_text("0:1:999:123", "hello")
|
||||||
|
|
||||||
|
self.assertFalse(sent)
|
||||||
|
fallback.assert_not_awaited()
|
||||||
|
callback.assert_awaited_once()
|
||||||
|
|
||||||
|
async def test_fresh_session_replaces_send_and_ws_state_atomically(self):
|
||||||
|
current = DouyinImSession(
|
||||||
|
cookies={"sessionid": "old"},
|
||||||
|
my_uid=999,
|
||||||
|
conv_meta={"old": {"ticket": "one"}},
|
||||||
|
)
|
||||||
|
current.egress_public_ip = "203.0.113.10"
|
||||||
|
current.egress_source_ip = "10.0.0.10"
|
||||||
|
current.egress_auto_attempts = 2
|
||||||
|
service = DouyinImService(
|
||||||
|
session=current,
|
||||||
|
match_reply=AsyncMock(),
|
||||||
|
log_fn=AsyncMock(),
|
||||||
|
account_id=1,
|
||||||
|
)
|
||||||
|
service._ws_client = SimpleNamespace(session=current)
|
||||||
|
fresh = DouyinImSession(
|
||||||
|
cookies={"sessionid": "fresh"},
|
||||||
|
my_uid=999,
|
||||||
|
conv_meta={"new": {"ticket": "two"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.replace_session(fresh)
|
||||||
|
|
||||||
|
self.assertIs(service.session, fresh)
|
||||||
|
self.assertIs(service._ws_client.session, fresh)
|
||||||
|
self.assertEqual(service.session.cookies["sessionid"], "fresh")
|
||||||
|
self.assertEqual(set(service.session.conv_meta), {"old", "new"})
|
||||||
|
self.assertEqual(service.session.egress_public_ip, "203.0.113.10")
|
||||||
|
self.assertEqual(service.session.egress_source_ip, "10.0.0.10")
|
||||||
|
self.assertEqual(service.session.egress_auto_attempts, 2)
|
||||||
|
|
||||||
|
async def test_kick_response_takes_account_offline_immediately(self):
|
||||||
|
callback = AsyncMock()
|
||||||
|
service, _, _ = _build_service()
|
||||||
|
service.on_session_invalid = callback
|
||||||
|
|
||||||
|
with unittest.mock.patch(
|
||||||
|
"rpa_engine.douyin_im.service.system_logger.record", Mock()
|
||||||
|
):
|
||||||
|
await service._note_session_invalid(
|
||||||
|
"抖音安全网关返回 decision=KICK,当前登录态已失效"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(service._running)
|
||||||
|
self.assertTrue(service._session_invalid_fired)
|
||||||
|
callback.assert_awaited_once()
|
||||||
|
self.assertIn("decision=KICK", callback.await_args.args[0])
|
||||||
|
|
||||||
|
async def test_invalid_request_still_requires_two_consecutive_failures(self):
|
||||||
|
callback = AsyncMock()
|
||||||
|
service, _, _ = _build_service()
|
||||||
|
service.on_session_invalid = callback
|
||||||
|
|
||||||
|
with unittest.mock.patch(
|
||||||
|
"rpa_engine.douyin_im.service.system_logger.record", Mock()
|
||||||
|
):
|
||||||
|
await service._note_session_invalid("INVALID_REQUEST")
|
||||||
|
self.assertTrue(service._running)
|
||||||
|
callback.assert_not_awaited()
|
||||||
|
await service._note_session_invalid("INVALID_REQUEST")
|
||||||
|
|
||||||
|
self.assertFalse(service._running)
|
||||||
|
callback.assert_awaited_once()
|
||||||
|
|
||||||
async def test_same_message_from_ws_and_poll_is_queued_once(self):
|
async def test_same_message_from_ws_and_poll_is_queued_once(self):
|
||||||
service, match_reply, _ = _build_service()
|
service, match_reply, _ = _build_service()
|
||||||
message = {
|
message = {
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
os.environ["KEFU_DB_TYPE"] = "sqlite"
|
||||||
|
os.environ["KEFU_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
if str(BACKEND_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BACKEND_DIR))
|
||||||
|
|
||||||
|
from models.models import Base, Role, User # noqa: E402
|
||||||
|
from auth.passwords import hash_password # noqa: E402
|
||||||
|
from auth.permissions import ( # noqa: E402
|
||||||
|
ACCOUNTS_WRITE,
|
||||||
|
ALL_PERMISSIONS,
|
||||||
|
MENU_USERS,
|
||||||
|
USERS_MANAGE,
|
||||||
|
expand_paired_permissions,
|
||||||
|
)
|
||||||
|
from auth.role_service import ( # noqa: E402
|
||||||
|
create_role,
|
||||||
|
delete_role,
|
||||||
|
guard_last_admin_change,
|
||||||
|
seed_builtin_roles,
|
||||||
|
update_role,
|
||||||
|
)
|
||||||
|
from auth.roles import has_permission, is_admin, permissions_for_role # noqa: E402
|
||||||
|
from auth import router as auth_router # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class RolesRbacTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self):
|
||||||
|
self.engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with self.engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
self.session_factory = sessionmaker(
|
||||||
|
self.engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
self.db = self.session_factory()
|
||||||
|
await seed_builtin_roles(self.db)
|
||||||
|
|
||||||
|
async def asyncTearDown(self):
|
||||||
|
await self.db.close()
|
||||||
|
await self.engine.dispose()
|
||||||
|
|
||||||
|
async def test_seed_creates_builtin_roles(self):
|
||||||
|
result = await self.db.execute(select(Role))
|
||||||
|
codes = {row.code for row in result.scalars().all()}
|
||||||
|
self.assertEqual(codes, {"admin", "operator", "viewer"})
|
||||||
|
self.assertTrue(is_admin("admin"))
|
||||||
|
self.assertFalse(is_admin("operator"))
|
||||||
|
self.assertIn(MENU_USERS, permissions_for_role("admin"))
|
||||||
|
self.assertNotIn(MENU_USERS, permissions_for_role("operator"))
|
||||||
|
self.assertFalse(has_permission("viewer", ACCOUNTS_WRITE))
|
||||||
|
|
||||||
|
async def test_custom_role_crud(self):
|
||||||
|
created = await create_role(
|
||||||
|
self.db,
|
||||||
|
code="ops_leader",
|
||||||
|
label="运营主管",
|
||||||
|
description="可管账号",
|
||||||
|
permissions=[MENU_USERS, ACCOUNTS_WRITE],
|
||||||
|
)
|
||||||
|
self.assertEqual(created.code, "ops_leader")
|
||||||
|
self.assertTrue(has_permission("ops_leader", ACCOUNTS_WRITE))
|
||||||
|
self.assertFalse(is_admin("ops_leader"))
|
||||||
|
|
||||||
|
updated = await update_role(
|
||||||
|
self.db,
|
||||||
|
"ops_leader",
|
||||||
|
label="主管",
|
||||||
|
permissions=[ACCOUNTS_WRITE],
|
||||||
|
)
|
||||||
|
self.assertEqual(updated.label, "主管")
|
||||||
|
self.assertFalse(has_permission("ops_leader", MENU_USERS))
|
||||||
|
|
||||||
|
await delete_role(self.db, "ops_leader")
|
||||||
|
self.assertFalse(has_permission("ops_leader", ACCOUNTS_WRITE))
|
||||||
|
|
||||||
|
async def test_cannot_delete_system_role(self):
|
||||||
|
with self.assertRaises(HTTPException) as caught:
|
||||||
|
await delete_role(self.db, "operator")
|
||||||
|
self.assertEqual(caught.exception.status_code, 400)
|
||||||
|
|
||||||
|
async def test_admin_permissions_always_full(self):
|
||||||
|
await update_role(
|
||||||
|
self.db,
|
||||||
|
"admin",
|
||||||
|
label="管理员",
|
||||||
|
permissions=[ACCOUNTS_WRITE],
|
||||||
|
)
|
||||||
|
self.assertEqual(permissions_for_role("admin"), list(ALL_PERMISSIONS))
|
||||||
|
|
||||||
|
async def test_me_payload_includes_permissions(self):
|
||||||
|
user = User(
|
||||||
|
username="u1",
|
||||||
|
password_hash=hash_password("password1"),
|
||||||
|
display_name="U1",
|
||||||
|
role="operator",
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
self.db.add(user)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(user)
|
||||||
|
payload = await auth_router._build_user_response(self.db, user)
|
||||||
|
self.assertEqual(payload.role_label, "运营")
|
||||||
|
self.assertFalse(payload.is_admin)
|
||||||
|
self.assertIn("accounts.create", payload.permissions)
|
||||||
|
self.assertNotIn(MENU_USERS, payload.permissions)
|
||||||
|
self.assertNotIn("data.scope_all", payload.permissions)
|
||||||
|
|
||||||
|
async def test_legacy_accounts_write_implies_granular(self):
|
||||||
|
created = await create_role(
|
||||||
|
self.db,
|
||||||
|
code="legacy_ops",
|
||||||
|
label="旧版运营",
|
||||||
|
description=None,
|
||||||
|
permissions=[ACCOUNTS_WRITE, "menu.accounts"],
|
||||||
|
)
|
||||||
|
self.assertTrue(has_permission("legacy_ops", "accounts.start"))
|
||||||
|
self.assertTrue(has_permission("legacy_ops", "accounts.cookie"))
|
||||||
|
self.assertIn(ACCOUNTS_WRITE, created.permissions)
|
||||||
|
|
||||||
|
async def test_data_scope_all_for_custom_role(self):
|
||||||
|
from auth.roles import has_global_scope
|
||||||
|
|
||||||
|
await create_role(
|
||||||
|
self.db,
|
||||||
|
code="auditor",
|
||||||
|
label="审计",
|
||||||
|
description=None,
|
||||||
|
permissions=["menu.accounts", "data.scope_all"],
|
||||||
|
)
|
||||||
|
self.assertTrue(has_global_scope("auditor"))
|
||||||
|
self.assertFalse(has_global_scope("operator"))
|
||||||
|
self.assertTrue(has_global_scope("admin"))
|
||||||
|
|
||||||
|
async def test_last_admin_cannot_be_demoted(self):
|
||||||
|
admin = User(
|
||||||
|
username="admin1",
|
||||||
|
password_hash=hash_password("password1"),
|
||||||
|
role="admin",
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
self.db.add(admin)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(admin)
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as caught:
|
||||||
|
await guard_last_admin_change(self.db, user=admin, new_role="operator")
|
||||||
|
self.assertEqual(caught.exception.status_code, 400)
|
||||||
|
|
||||||
|
async def test_menu_action_pairs_expand(self):
|
||||||
|
expanded = expand_paired_permissions([MENU_USERS])
|
||||||
|
self.assertIn(MENU_USERS, expanded)
|
||||||
|
self.assertIn(USERS_MANAGE, expanded)
|
||||||
|
created = await create_role(
|
||||||
|
self.db,
|
||||||
|
code="hr_desk",
|
||||||
|
label="人事台",
|
||||||
|
description=None,
|
||||||
|
permissions=[MENU_USERS],
|
||||||
|
)
|
||||||
|
self.assertIn(USERS_MANAGE, created.permissions)
|
||||||
|
self.assertNotIn("menu.roles", created.permissions)
|
||||||
|
self.assertNotIn("roles.manage", created.permissions)
|
||||||
|
|
||||||
|
roles_only = await create_role(
|
||||||
|
self.db,
|
||||||
|
code="role_editor",
|
||||||
|
label="角色编辑",
|
||||||
|
description=None,
|
||||||
|
permissions=["menu.roles"],
|
||||||
|
)
|
||||||
|
self.assertIn("roles.manage", roles_only.permissions)
|
||||||
|
self.assertNotIn(USERS_MANAGE, roles_only.permissions)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
@@ -18,13 +20,114 @@ if str(BACKEND_DIR) not in sys.path:
|
|||||||
|
|
||||||
from rpa_engine.douyin_im.session import DouyinImSession
|
from rpa_engine.douyin_im.session import DouyinImSession
|
||||||
from rpa_engine import account_profile as account_profile_module
|
from rpa_engine import account_profile as account_profile_module
|
||||||
|
from rpa_engine.credential import build_im_session_from_storage
|
||||||
from rpa_engine.playwright_worker import DouyinWorker
|
from rpa_engine.playwright_worker import DouyinWorker
|
||||||
|
|
||||||
|
|
||||||
class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_im_direct_missing_sec_user_id_exits_before_online_state(self):
|
async def test_current_profile_uid_overrides_browser_runtime_uid(self):
|
||||||
|
now = datetime.utcnow()
|
||||||
|
row = SimpleNamespace(
|
||||||
|
im_session_data=None,
|
||||||
|
cookie_updated_at=now,
|
||||||
|
uid="2609567359568155",
|
||||||
|
profile_updated_at=now,
|
||||||
|
)
|
||||||
|
result = MagicMock()
|
||||||
|
result.one_or_none.return_value = row
|
||||||
|
db = SimpleNamespace(
|
||||||
|
execute=AsyncMock(return_value=result),
|
||||||
|
close=AsyncMock(),
|
||||||
|
)
|
||||||
|
worker = DouyinWorker(account_id=300, login_mode="im_direct")
|
||||||
|
worker.get_db = AsyncMock(return_value=db)
|
||||||
|
worker._load_raw_user_agent = AsyncMock(return_value="test-agent")
|
||||||
|
storage = {
|
||||||
|
"cookies": [{"name": "sessionid", "value": "test-session"}],
|
||||||
|
"my_uid": 7678285795559818786,
|
||||||
|
"origins": [
|
||||||
|
{
|
||||||
|
"localStorage": [
|
||||||
|
{
|
||||||
|
"name": "web_runtime_security_uid",
|
||||||
|
"value": "7678285795559818786",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
session = await worker._build_im_session_from_storage(storage)
|
||||||
|
|
||||||
|
self.assertEqual(session.my_uid, 2609567359568155)
|
||||||
|
# device_id 必须与权威 UID 同步(protobuf 发送时 device_id 优先取
|
||||||
|
# session.device_id,残留的浏览器采集值会导致 device_id != my_uid -> KICK)
|
||||||
|
self.assertEqual(session.device_id, "2609567359568155")
|
||||||
|
self.assertTrue(session.uid_verified)
|
||||||
|
db.close.assert_awaited_once()
|
||||||
|
|
||||||
|
async def test_stale_profile_uid_does_not_override_new_cookie(self):
|
||||||
|
now = datetime.utcnow()
|
||||||
|
row = SimpleNamespace(
|
||||||
|
im_session_data=None,
|
||||||
|
cookie_updated_at=now,
|
||||||
|
uid="2609567359568155",
|
||||||
|
profile_updated_at=now - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
result = MagicMock()
|
||||||
|
result.one_or_none.return_value = row
|
||||||
|
db = SimpleNamespace(
|
||||||
|
execute=AsyncMock(return_value=result),
|
||||||
|
close=AsyncMock(),
|
||||||
|
)
|
||||||
|
worker = DouyinWorker(account_id=300, login_mode="im_direct")
|
||||||
|
worker.get_db = AsyncMock(return_value=db)
|
||||||
|
worker._load_raw_user_agent = AsyncMock(return_value="test-agent")
|
||||||
|
|
||||||
|
session = await worker._build_im_session_from_storage(
|
||||||
|
{
|
||||||
|
"cookies": [{"name": "sessionid", "value": "test-session"}],
|
||||||
|
"my_uid": 7678285795559818786,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(session.my_uid, 7678285795559818786)
|
||||||
|
self.assertFalse(session.uid_verified)
|
||||||
|
|
||||||
|
def test_persisted_verified_uid_wins_in_generic_session_builder(self):
|
||||||
|
saved = DouyinImSession(
|
||||||
|
cookies={"sessionid": "test-session"},
|
||||||
|
my_uid=2609567359568155,
|
||||||
|
device_id="7678285795559818786",
|
||||||
|
uid_verified=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
session = build_im_session_from_storage(
|
||||||
|
{
|
||||||
|
"cookies": [{"name": "sessionid", "value": "test-session"}],
|
||||||
|
"my_uid": 7678285795559818786,
|
||||||
|
"origins": [
|
||||||
|
{
|
||||||
|
"localStorage": [
|
||||||
|
{
|
||||||
|
"name": "web_runtime_security_uid",
|
||||||
|
"value": "7678285795559818786",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
json.dumps(saved.to_dict()),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(session.my_uid, 2609567359568155)
|
||||||
|
# device_id 同步为已核验 UID,避免凭证残留设备号导致 device_id != my_uid
|
||||||
|
self.assertEqual(session.device_id, "2609567359568155")
|
||||||
|
self.assertTrue(session.uid_verified)
|
||||||
|
|
||||||
|
async def test_im_direct_missing_sec_user_id_continues_im_hosting(self):
|
||||||
worker = DouyinWorker(account_id=301, login_mode="im_direct")
|
worker = DouyinWorker(account_id=301, login_mode="im_direct")
|
||||||
worker._require_sec_user_id = AsyncMock(return_value=False)
|
worker._best_effort_sec_user_id = AsyncMock(return_value="")
|
||||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||||
im_session = DouyinImSession(
|
im_session = DouyinImSession(
|
||||||
cookies={"sessionid": "test-session"},
|
cookies={"sessionid": "test-session"},
|
||||||
@@ -42,37 +145,34 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
{"cookies": [{"name": "sessionid", "value": "test-session"}]}
|
{"cookies": [{"name": "sessionid", "value": "test-session"}]}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertFalse(started)
|
self.assertTrue(started)
|
||||||
self.assertIn("sec_user_id", reason)
|
self.assertEqual(reason, "")
|
||||||
worker._require_sec_user_id.assert_awaited_once()
|
worker._best_effort_sec_user_id.assert_awaited_once_with(
|
||||||
require_call = worker._require_sec_user_id.await_args
|
refresh_if_missing=True,
|
||||||
self.assertTrue(require_call.kwargs["refresh_if_missing"])
|
refresh_if_stale=True,
|
||||||
self.assertTrue(require_call.kwargs["refresh_if_stale"])
|
)
|
||||||
worker._build_im_session_from_storage.assert_awaited_once()
|
worker._build_im_session_from_storage.assert_awaited_once()
|
||||||
validate.assert_awaited_once_with(im_session)
|
validate.assert_awaited_once_with(im_session)
|
||||||
worker._persist_im_session.assert_not_awaited()
|
worker._persist_im_session.assert_awaited_once()
|
||||||
worker._run_im_direct_service.assert_not_awaited()
|
worker._run_im_direct_service.assert_awaited_once_with(im_session)
|
||||||
|
|
||||||
async def test_running_guard_precedes_disabled_follow_welcome_setting(self):
|
async def test_disabled_follow_welcome_does_not_require_identity(self):
|
||||||
worker = DouyinWorker(account_id=302, login_mode="im_direct")
|
worker = DouyinWorker(account_id=302, login_mode="im_direct")
|
||||||
worker.is_running = True
|
worker.is_running = True
|
||||||
worker._im_service = SimpleNamespace(_running=True)
|
worker._im_service = SimpleNamespace(_running=True)
|
||||||
worker._require_sec_user_id = AsyncMock(return_value=False)
|
worker._best_effort_sec_user_id = AsyncMock()
|
||||||
worker._refresh_follow_welcome_config = AsyncMock()
|
worker._refresh_follow_welcome_config = AsyncMock(
|
||||||
|
return_value=(False, "", "")
|
||||||
|
)
|
||||||
worker.get_db = AsyncMock()
|
worker.get_db = AsyncMock()
|
||||||
|
|
||||||
await worker.follow_welcome_tick()
|
await worker.follow_welcome_tick()
|
||||||
|
|
||||||
worker._require_sec_user_id.assert_awaited_once()
|
worker._refresh_follow_welcome_config.assert_awaited_once_with()
|
||||||
require_call = worker._require_sec_user_id.await_args
|
worker._best_effort_sec_user_id.assert_not_awaited()
|
||||||
self.assertFalse(require_call.kwargs.get("refresh_if_missing", False))
|
|
||||||
# The identity guard must run before the account's follow-welcome flag
|
|
||||||
# is queried. Otherwise accounts with that feature disabled could stay
|
|
||||||
# hosted indefinitely without a sec_user_id.
|
|
||||||
worker._refresh_follow_welcome_config.assert_not_awaited()
|
|
||||||
worker.get_db.assert_not_awaited()
|
worker.get_db.assert_not_awaited()
|
||||||
|
|
||||||
async def test_cached_disabled_follow_setting_still_guards_missing_identity(self):
|
async def test_cached_disabled_follow_setting_skips_missing_identity(self):
|
||||||
worker = DouyinWorker(account_id=311, login_mode="im_direct")
|
worker = DouyinWorker(account_id=311, login_mode="im_direct")
|
||||||
worker.is_running = True
|
worker.is_running = True
|
||||||
worker._im_service = SimpleNamespace(_running=True)
|
worker._im_service = SimpleNamespace(_running=True)
|
||||||
@@ -80,12 +180,12 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
worker._refresh_follow_welcome_config = AsyncMock(
|
worker._refresh_follow_welcome_config = AsyncMock(
|
||||||
return_value=(False, "", "")
|
return_value=(False, "", "")
|
||||||
)
|
)
|
||||||
worker._require_sec_user_id = AsyncMock(return_value="")
|
worker._best_effort_sec_user_id = AsyncMock(return_value="")
|
||||||
|
|
||||||
await worker.follow_welcome_tick()
|
await worker.follow_welcome_tick()
|
||||||
|
|
||||||
worker._refresh_follow_welcome_config.assert_awaited_once_with()
|
worker._refresh_follow_welcome_config.assert_awaited_once_with()
|
||||||
worker._require_sec_user_id.assert_awaited_once_with("托管运行中")
|
worker._best_effort_sec_user_id.assert_not_awaited()
|
||||||
|
|
||||||
async def test_blank_sec_user_id_is_missing_and_stops_hosting(self):
|
async def test_blank_sec_user_id_is_missing_and_stops_hosting(self):
|
||||||
worker = DouyinWorker(account_id=303, login_mode="im_direct")
|
worker = DouyinWorker(account_id=303, login_mode="im_direct")
|
||||||
@@ -297,6 +397,141 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn("托管已自动退出", status_call.kwargs["error_msg"])
|
self.assertIn("托管已自动退出", status_call.kwargs["error_msg"])
|
||||||
record_system_log.assert_called_once()
|
record_system_log.assert_called_once()
|
||||||
|
|
||||||
|
def test_profile_payload_uid_outranks_query_user_and_cookie_uid(self):
|
||||||
|
"""资料接口的 UID 必须压过 query/user 的 user_uid 和 cookie 兜底。
|
||||||
|
|
||||||
|
实测同一个 Cookie:query/user 返回 user_uid=938334054809296,
|
||||||
|
而账号资料 UID 是 2609567359568155。以前前者先占位,导致账号卡片
|
||||||
|
一直显示「用户938334054809296」,反查 sec_user_id 也永远失败。
|
||||||
|
"""
|
||||||
|
auth = SimpleNamespace(
|
||||||
|
cookie={},
|
||||||
|
msToken="test-ms-token",
|
||||||
|
get_uid=lambda: "938334054809296",
|
||||||
|
)
|
||||||
|
payloads = [
|
||||||
|
# 1) query/user:只有 user_uid,属于弱兜底
|
||||||
|
{"status_code": 0, "user_uid": "938334054809296"},
|
||||||
|
# 2) user/profile/self:权威账号资料
|
||||||
|
{
|
||||||
|
"status_code": 0,
|
||||||
|
"user": {
|
||||||
|
"uid": "2609567359568155",
|
||||||
|
"nickname": "凤的心",
|
||||||
|
"sec_uid": "MS4wLjABAAAA-real-sec-user-id",
|
||||||
|
"unique_id": "39688250979",
|
||||||
|
"aweme_count": 50,
|
||||||
|
"follower_count": 154,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
responses = [SimpleNamespace(json=lambda value=value: value) for value in payloads]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
account_profile_module, "_build_auth",
|
||||||
|
return_value=(auth, "test-agent"),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
account_profile_module.requests, "get", side_effect=responses,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
account_profile_module, "generate_a_bogus", return_value="a-bogus",
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
account_profile_module, "generate_webid", return_value="web-id",
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
account_profile_module, "_requests_proxies", return_value=None,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
detail = account_profile_module.fetch_douyin_profile_detail_sync(
|
||||||
|
"cookie-json", "test-agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(detail["uid"], "2609567359568155")
|
||||||
|
self.assertEqual(detail["nickname"], "凤的心")
|
||||||
|
self.assertTrue(detail["fetched"])
|
||||||
|
|
||||||
|
async def test_sync_writes_one_identity_to_both_account_and_profile(self):
|
||||||
|
"""卡片与详细资料必须来自同一次抓取,不能各抓一次抓出两个身份。"""
|
||||||
|
detail = {
|
||||||
|
"uid": "2609567359568155",
|
||||||
|
"nickname": "凤的心",
|
||||||
|
"avatar_url": "https://example.test/avatar.png",
|
||||||
|
"unique_id": "39688250979",
|
||||||
|
"signature": "",
|
||||||
|
"sec_user_id": "MS4wLjABAAAA-real-sec-user-id",
|
||||||
|
"sec_user_id_status": "found",
|
||||||
|
"video_count": 50,
|
||||||
|
"follower_count": 154,
|
||||||
|
"following_count": 162,
|
||||||
|
"total_favorited": 612,
|
||||||
|
"favoriting_count": 0,
|
||||||
|
"fetched": True,
|
||||||
|
"message": "",
|
||||||
|
}
|
||||||
|
account = SimpleNamespace(
|
||||||
|
id=1,
|
||||||
|
username="用户938334054809296",
|
||||||
|
douyin_uid="938334054809296",
|
||||||
|
avatar_url=None,
|
||||||
|
user_agent="test-agent",
|
||||||
|
)
|
||||||
|
profile = SimpleNamespace(
|
||||||
|
account_id=1, uid=None, nickname=None, avatar_url=None,
|
||||||
|
unique_id=None, signature=None, sec_user_id=None,
|
||||||
|
follower_count=None, following_count=None, total_favorited=None,
|
||||||
|
favoriting_count=None, video_count=None, synced_at=None,
|
||||||
|
sync_message=None,
|
||||||
|
)
|
||||||
|
profile_result = MagicMock()
|
||||||
|
profile_result.scalar_one_or_none.return_value = profile
|
||||||
|
username_result = MagicMock()
|
||||||
|
username_result.scalar_one_or_none.return_value = None
|
||||||
|
db = SimpleNamespace(
|
||||||
|
execute=AsyncMock(side_effect=lambda stmt: (
|
||||||
|
username_result if "accounts.username" in str(stmt).lower()
|
||||||
|
or "username" in str(stmt).lower() else profile_result
|
||||||
|
)),
|
||||||
|
add=MagicMock(),
|
||||||
|
commit=AsyncMock(),
|
||||||
|
refresh=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
account_profile_module,
|
||||||
|
"fetch_douyin_profile_detail_with_sec_user_id",
|
||||||
|
new=AsyncMock(return_value=detail),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
account_profile_module,
|
||||||
|
"fetch_douyin_user_videos",
|
||||||
|
new=AsyncMock(return_value={"videos": [], "message": "无作品"}),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
account_profile_module,
|
||||||
|
"fetch_douyin_profile",
|
||||||
|
new=AsyncMock(side_effect=AssertionError("must not re-fetch")),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
account_profile_module,
|
||||||
|
"load_account_profile_from_db",
|
||||||
|
new=AsyncMock(return_value={}),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await account_profile_module.sync_account_profile_to_db(
|
||||||
|
db, account, "cookie-json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 同一个身份同时写进 accounts 行和 account_profile_details 行
|
||||||
|
self.assertEqual(account.douyin_uid, "2609567359568155")
|
||||||
|
self.assertEqual(account.username, "凤的心")
|
||||||
|
self.assertEqual(account.avatar_url, "https://example.test/avatar.png")
|
||||||
|
self.assertEqual(profile.uid, "2609567359568155")
|
||||||
|
self.assertEqual(profile.nickname, "凤的心")
|
||||||
|
|
||||||
async def test_cookie_uid_without_valid_profile_payload_stays_unknown(self):
|
async def test_cookie_uid_without_valid_profile_payload_stays_unknown(self):
|
||||||
auth = SimpleNamespace(
|
auth = SimpleNamespace(
|
||||||
cookie={},
|
cookie={},
|
||||||
@@ -490,7 +725,7 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
account_profile_module,
|
account_profile_module,
|
||||||
"apply_douyin_profile",
|
"apply_profile_to_account",
|
||||||
apply_profile,
|
apply_profile,
|
||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
@@ -518,19 +753,19 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"profile endpoint temporarily unavailable",
|
"profile endpoint temporarily unavailable",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_browser_login_missing_sec_user_id_closes_browser_and_skips_im(self):
|
async def test_browser_login_missing_sec_user_id_still_starts_im(self):
|
||||||
worker = DouyinWorker(account_id=305, login_mode="browser")
|
worker = DouyinWorker(account_id=305, login_mode="browser")
|
||||||
worker.is_running = True
|
worker.is_running = True
|
||||||
events: list[str] = []
|
events: list[str] = []
|
||||||
|
|
||||||
async def reject_identity(*_args, **_kwargs):
|
async def reject_identity(*_args, **_kwargs):
|
||||||
events.append("require-sec-user-id")
|
events.append("require-sec-user-id")
|
||||||
return False
|
return ""
|
||||||
|
|
||||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||||
worker._probe_existing_login = AsyncMock(return_value=True)
|
worker._probe_existing_login = AsyncMock(return_value=True)
|
||||||
worker._finalize_login_session = AsyncMock()
|
worker._finalize_login_session = AsyncMock()
|
||||||
worker._require_sec_user_id = AsyncMock(side_effect=reject_identity)
|
worker._best_effort_sec_user_id = AsyncMock(side_effect=reject_identity)
|
||||||
worker._setup_im_network_listener = AsyncMock()
|
worker._setup_im_network_listener = AsyncMock()
|
||||||
worker._navigate_to_message_center = AsyncMock()
|
worker._navigate_to_message_center = AsyncMock()
|
||||||
worker._harvest_im_credentials = AsyncMock()
|
worker._harvest_im_credentials = AsyncMock()
|
||||||
@@ -595,8 +830,8 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
worker._finalize_login_session.assert_awaited_once_with()
|
worker._finalize_login_session.assert_awaited_once_with()
|
||||||
worker._require_sec_user_id.assert_awaited_once()
|
worker._best_effort_sec_user_id.assert_awaited_once()
|
||||||
require_call = worker._require_sec_user_id.await_args
|
require_call = worker._best_effort_sec_user_id.await_args
|
||||||
self.assertTrue(require_call.kwargs["force_refresh"])
|
self.assertTrue(require_call.kwargs["force_refresh"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
events,
|
events,
|
||||||
@@ -607,8 +842,10 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
worker._harvest_im_credentials.assert_awaited_once_with(timeout=25)
|
worker._harvest_im_credentials.assert_awaited_once_with(timeout=25)
|
||||||
worker._persist_cookies.assert_awaited_once_with()
|
worker._persist_cookies.assert_awaited_once_with()
|
||||||
worker._build_im_session.assert_awaited_once_with()
|
worker._build_im_session.assert_awaited_once_with()
|
||||||
worker._persist_im_session.assert_not_awaited()
|
# sec_user_id 只服务关注欢迎语;缺它不能阻断私信托管,
|
||||||
worker._run_im_direct_service.assert_not_awaited()
|
# 否则浏览器登录后账号立刻下线、永远不会自动回复。
|
||||||
|
worker._persist_im_session.assert_awaited_once()
|
||||||
|
worker._run_im_direct_service.assert_awaited_once_with(im_session)
|
||||||
worker._close_browser_only.assert_awaited_once_with()
|
worker._close_browser_only.assert_awaited_once_with()
|
||||||
|
|
||||||
async def test_browser_login_with_sec_user_id_continues_to_im(self):
|
async def test_browser_login_with_sec_user_id_continues_to_im(self):
|
||||||
@@ -617,7 +854,7 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||||
worker._probe_existing_login = AsyncMock(return_value=True)
|
worker._probe_existing_login = AsyncMock(return_value=True)
|
||||||
worker._finalize_login_session = AsyncMock()
|
worker._finalize_login_session = AsyncMock()
|
||||||
worker._require_sec_user_id = AsyncMock(return_value=True)
|
worker._best_effort_sec_user_id = AsyncMock(return_value="sec-uid-306")
|
||||||
worker._setup_im_network_listener = AsyncMock()
|
worker._setup_im_network_listener = AsyncMock()
|
||||||
worker._navigate_to_message_center = AsyncMock()
|
worker._navigate_to_message_center = AsyncMock()
|
||||||
worker._harvest_im_credentials = AsyncMock()
|
worker._harvest_im_credentials = AsyncMock()
|
||||||
@@ -674,8 +911,10 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
worker._require_sec_user_id.assert_awaited_once()
|
worker._best_effort_sec_user_id.assert_awaited_once()
|
||||||
self.assertTrue(worker._require_sec_user_id.await_args.kwargs["force_refresh"])
|
self.assertTrue(
|
||||||
|
worker._best_effort_sec_user_id.await_args.kwargs["force_refresh"]
|
||||||
|
)
|
||||||
worker._setup_im_network_listener.assert_awaited_once_with()
|
worker._setup_im_network_listener.assert_awaited_once_with()
|
||||||
worker._navigate_to_message_center.assert_awaited_once_with()
|
worker._navigate_to_message_center.assert_awaited_once_with()
|
||||||
worker._harvest_im_credentials.assert_awaited_once_with(timeout=25)
|
worker._harvest_im_credentials.assert_awaited_once_with(timeout=25)
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ if str(BACKEND_DIR) not in sys.path:
|
|||||||
sys.path.insert(0, str(BACKEND_DIR))
|
sys.path.insert(0, str(BACKEND_DIR))
|
||||||
|
|
||||||
from rpa_engine.douyin_im import http_client as http_client_module
|
from rpa_engine.douyin_im import http_client as http_client_module
|
||||||
|
from rpa_engine.douyin_im.auth import DouyinAuth
|
||||||
|
from rpa_engine.douyin_im.frontier import ensure_frontier_ws
|
||||||
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
||||||
from rpa_engine.douyin_im.session import DouyinImSession
|
from rpa_engine.douyin_im.session import DouyinImSession
|
||||||
|
from rpa_engine.egress_channels import EgressChannel
|
||||||
|
from rpa_engine import playwright_worker as playwright_worker_module
|
||||||
from rpa_engine.playwright_worker import DouyinWorker
|
from rpa_engine.playwright_worker import DouyinWorker
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +36,75 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
return DouyinImHttpClient(session, account_id=account_id)
|
return DouyinImHttpClient(session, account_id=account_id)
|
||||||
|
|
||||||
|
def test_query_user_does_not_replace_existing_im_uid(self):
|
||||||
|
client = self._make_client()
|
||||||
|
auth = SimpleNamespace(
|
||||||
|
source_ip="",
|
||||||
|
get_uid=MagicMock(return_value=938334054809296),
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved = client._resolve_authoritative_uid(auth)
|
||||||
|
|
||||||
|
self.assertEqual(resolved, 10001)
|
||||||
|
self.assertEqual(client.session.my_uid, 10001)
|
||||||
|
auth.get_uid.assert_not_called()
|
||||||
|
|
||||||
|
def test_verified_uid_does_not_replace_the_runtime_device_id(self):
|
||||||
|
# device_id 是 query/user 返回的设备注册号,my_uid 是账号 UID。
|
||||||
|
# 拿 my_uid 顶替 device_id 会让 frontier 订阅到另一个地址:握手照样
|
||||||
|
# 成功,却永远收不到这个账号的私信。
|
||||||
|
session = DouyinImSession(
|
||||||
|
cookies={"sessionid": "test-session"},
|
||||||
|
my_uid=2609567359568155,
|
||||||
|
device_id="7678285795559818786",
|
||||||
|
web_id="7678286623234475535",
|
||||||
|
uid_verified=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth = DouyinAuth.from_im_session(session)
|
||||||
|
|
||||||
|
self.assertEqual(auth.device_id, "7678285795559818786")
|
||||||
|
self.assertEqual(session.device_id, "7678285795559818786")
|
||||||
|
|
||||||
|
def test_captured_tokenless_browser_frontier_url_is_kept(self):
|
||||||
|
url = (
|
||||||
|
"wss://frontier100-normal.zijieapi.com/ws/v2?aid=6383&"
|
||||||
|
"device_platform=web&fpid=9&device_id=7678285795559818786&"
|
||||||
|
"access_key=0123456789abcdef0123456789abcdef&version_code=fws_1.0.0"
|
||||||
|
)
|
||||||
|
session = DouyinImSession(
|
||||||
|
cookies={"sessionid": "test-session"},
|
||||||
|
ws_urls=[url],
|
||||||
|
my_uid=2609567359568155,
|
||||||
|
device_id="7678285795559818786",
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved = ensure_frontier_ws(session)
|
||||||
|
|
||||||
|
self.assertEqual(resolved, url)
|
||||||
|
self.assertEqual(session.ws_urls, [url])
|
||||||
|
|
||||||
|
def test_non_im_frontier_product_is_rejected(self):
|
||||||
|
url = (
|
||||||
|
"wss://frontier100-normal.zijieapi.com/ws/v2?aid=6383&"
|
||||||
|
"device_platform=web&fpid=971&device_id=7678285795559818786&"
|
||||||
|
"access_key=0123456789abcdef0123456789abcdef"
|
||||||
|
)
|
||||||
|
session = DouyinImSession(
|
||||||
|
cookies={"sessionid": "test-session"},
|
||||||
|
ws_urls=[url],
|
||||||
|
my_uid=2609567359568155,
|
||||||
|
device_id="7678285795559818786",
|
||||||
|
uid_verified=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved = ensure_frontier_ws(session)
|
||||||
|
|
||||||
|
self.assertIn("frontier-im.douyin.com", resolved)
|
||||||
|
self.assertIn("fpid=9", resolved)
|
||||||
|
self.assertIn("device_id=7678285795559818786", resolved)
|
||||||
|
self.assertNotIn("fpid=971", resolved)
|
||||||
|
|
||||||
async def test_public_entry_submits_the_whole_send_to_outbound_queue(self):
|
async def test_public_entry_submits_the_whole_send_to_outbound_queue(self):
|
||||||
client = self._make_client(account_id=73)
|
client = self._make_client(account_id=73)
|
||||||
submit = AsyncMock(return_value=True)
|
submit = AsyncMock(return_value=True)
|
||||||
@@ -85,6 +158,7 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
last_send_meta=queued_meta,
|
last_send_meta=queued_meta,
|
||||||
last_error="credential expired",
|
last_error="credential expired",
|
||||||
last_send_needs_refresh=True,
|
last_send_needs_refresh=True,
|
||||||
|
last_send_channel_retryable=False,
|
||||||
last_request_debug="response status=401",
|
last_request_debug="response status=401",
|
||||||
)
|
)
|
||||||
queued_context = MagicMock()
|
queued_context = MagicMock()
|
||||||
@@ -114,15 +188,18 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"0:1:10001:20002",
|
"0:1:10001:20002",
|
||||||
"queued hello",
|
"queued hello",
|
||||||
conversation_short_id="short-before-send",
|
conversation_short_id="short-before-send",
|
||||||
|
expected_peer_uid="20002",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertFalse(sent)
|
self.assertFalse(sent)
|
||||||
submit.assert_awaited_once()
|
submit.assert_awaited_once()
|
||||||
queued_factory.assert_called_once_with(client.session, account_id=88)
|
queued_factory.assert_called_once_with(client.session, account_id=88)
|
||||||
|
# 收件人期望必须原样传给真正写出去的那个 client:排队调度层不能把它吃掉
|
||||||
queued_client.send_text_message.assert_awaited_once_with(
|
queued_client.send_text_message.assert_awaited_once_with(
|
||||||
"0:1:10001:20002",
|
"0:1:10001:20002",
|
||||||
"queued hello",
|
"queued hello",
|
||||||
conversation_short_id="short-before-send",
|
conversation_short_id="short-before-send",
|
||||||
|
expected_peer_uid="20002",
|
||||||
_bypass_global_queue=True,
|
_bypass_global_queue=True,
|
||||||
)
|
)
|
||||||
self.assertEqual(client.last_send_meta, queued_meta)
|
self.assertEqual(client.last_send_meta, queued_meta)
|
||||||
@@ -131,8 +208,194 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(client.last_send_needs_refresh)
|
self.assertTrue(client.last_send_needs_refresh)
|
||||||
self.assertEqual(client.last_request_debug, "response status=401")
|
self.assertEqual(client.last_request_debug, "response status=401")
|
||||||
|
|
||||||
|
async def test_retryable_network_failure_switches_channels_serially(self):
|
||||||
|
client = self._make_client(account_id=89)
|
||||||
|
client.session.egress_auto_attempts = 2
|
||||||
|
routes = [
|
||||||
|
EgressChannel("198.51.100.10", "10.0.0.10", "eth0", True),
|
||||||
|
EgressChannel("198.51.100.11", "10.0.0.11", "eth0:1", False),
|
||||||
|
]
|
||||||
|
|
||||||
|
first = SimpleNamespace(
|
||||||
|
send_text_message=AsyncMock(return_value=False),
|
||||||
|
last_send_meta={},
|
||||||
|
last_error="connect timeout",
|
||||||
|
last_send_needs_refresh=False,
|
||||||
|
last_send_channel_retryable=True,
|
||||||
|
last_request_debug="first route",
|
||||||
|
)
|
||||||
|
second = SimpleNamespace(
|
||||||
|
send_text_message=AsyncMock(return_value=True),
|
||||||
|
last_send_meta={"conv": {"ticket": "ok"}},
|
||||||
|
last_error="",
|
||||||
|
last_send_needs_refresh=False,
|
||||||
|
last_send_channel_retryable=False,
|
||||||
|
last_request_debug="second route",
|
||||||
|
)
|
||||||
|
|
||||||
|
def context_for(value):
|
||||||
|
context = MagicMock()
|
||||||
|
context.__aenter__ = AsyncMock(return_value=value)
|
||||||
|
context.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
return context
|
||||||
|
|
||||||
|
queued_factory = MagicMock(side_effect=[context_for(first), context_for(second)])
|
||||||
|
|
||||||
|
async def execute_submission(account_id, operation, description=""):
|
||||||
|
self.assertEqual(account_id, 89)
|
||||||
|
return await operation()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"rpa_engine.douyin_im.traffic_control.submit_outbound",
|
||||||
|
AsyncMock(side_effect=execute_submission),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"rpa_engine.douyin_im.http_client.resolve_send_channels",
|
||||||
|
AsyncMock(return_value=routes),
|
||||||
|
),
|
||||||
|
patch.object(http_client_module, "DouyinImHttpClient", queued_factory),
|
||||||
|
patch.object(http_client_module.system_logger, "record"),
|
||||||
|
):
|
||||||
|
sent = await client.send_text_message("0:1:10001:20002", "hello")
|
||||||
|
|
||||||
|
self.assertTrue(sent)
|
||||||
|
self.assertEqual(queued_factory.call_count, 2)
|
||||||
|
self.assertEqual(queued_factory.call_args_list[0].kwargs["source_ip"], "10.0.0.10")
|
||||||
|
self.assertEqual(queued_factory.call_args_list[1].kwargs["source_ip"], "10.0.0.11")
|
||||||
|
first.send_text_message.assert_awaited_once()
|
||||||
|
second.send_text_message.assert_awaited_once()
|
||||||
|
self.assertEqual(client.last_request_debug, "second route")
|
||||||
|
|
||||||
|
async def test_kick_never_switches_public_channels(self):
|
||||||
|
client = self._make_client(account_id=90)
|
||||||
|
client.session.egress_auto_attempts = 2
|
||||||
|
routes = [
|
||||||
|
EgressChannel("198.51.100.10", "10.0.0.10", "eth0", True),
|
||||||
|
EgressChannel("198.51.100.11", "10.0.0.11", "eth1", False),
|
||||||
|
]
|
||||||
|
kicked = SimpleNamespace(
|
||||||
|
send_text_message=AsyncMock(return_value=False),
|
||||||
|
last_send_meta={},
|
||||||
|
last_error="decision=KICK",
|
||||||
|
last_send_needs_refresh=False,
|
||||||
|
last_send_channel_retryable=False,
|
||||||
|
last_request_debug="terminal kick",
|
||||||
|
)
|
||||||
|
context = MagicMock()
|
||||||
|
context.__aenter__ = AsyncMock(return_value=kicked)
|
||||||
|
context.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
queued_factory = MagicMock(return_value=context)
|
||||||
|
|
||||||
|
async def execute_submission(account_id, operation, description=""):
|
||||||
|
return await operation()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"rpa_engine.douyin_im.traffic_control.submit_outbound",
|
||||||
|
AsyncMock(side_effect=execute_submission),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"rpa_engine.douyin_im.http_client.resolve_send_channels",
|
||||||
|
AsyncMock(return_value=routes),
|
||||||
|
),
|
||||||
|
patch.object(http_client_module, "DouyinImHttpClient", queued_factory),
|
||||||
|
):
|
||||||
|
sent = await client.send_text_message("0:1:10001:20002", "hello")
|
||||||
|
|
||||||
|
self.assertFalse(sent)
|
||||||
|
queued_factory.assert_called_once()
|
||||||
|
kicked.send_text_message.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
class WorkerLifecycleTests(unittest.IsolatedAsyncioTestCase):
|
class WorkerLifecycleTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_browser_launch_uses_selected_source_proxy(self):
|
||||||
|
launch = AsyncMock(return_value="browser")
|
||||||
|
pw = SimpleNamespace(chromium=SimpleNamespace(launch=launch))
|
||||||
|
source_proxy = AsyncMock(return_value={"server": "http://127.0.0.1:43210"})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
playwright_worker_module,
|
||||||
|
"ensure_browser_display",
|
||||||
|
AsyncMock(),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
playwright_worker_module,
|
||||||
|
"playwright_proxy_for_source",
|
||||||
|
source_proxy,
|
||||||
|
),
|
||||||
|
patch.object(playwright_worker_module, "playwright_proxy") as global_proxy,
|
||||||
|
):
|
||||||
|
browser = await playwright_worker_module._launch_chromium(
|
||||||
|
pw,
|
||||||
|
["--no-sandbox"],
|
||||||
|
headless=True,
|
||||||
|
source_ip="10.0.0.6",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(browser, "browser")
|
||||||
|
source_proxy.assert_awaited_once_with("10.0.0.6")
|
||||||
|
global_proxy.assert_not_called()
|
||||||
|
launch.assert_awaited_once_with(
|
||||||
|
headless=True,
|
||||||
|
args=["--no-sandbox"],
|
||||||
|
proxy={"server": "http://127.0.0.1:43210"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_virtual_display_starts_before_playwright_driver(self):
|
||||||
|
ensure_display = AsyncMock()
|
||||||
|
|
||||||
|
async def start_driver():
|
||||||
|
ensure_display.assert_awaited_once_with(False)
|
||||||
|
return "playwright-driver"
|
||||||
|
|
||||||
|
manager = SimpleNamespace(start=AsyncMock(side_effect=start_driver))
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
playwright_worker_module,
|
||||||
|
"resolve_headless",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
playwright_worker_module,
|
||||||
|
"ensure_browser_display",
|
||||||
|
ensure_display,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
playwright_worker_module,
|
||||||
|
"async_playwright",
|
||||||
|
return_value=manager,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
driver, headless = await playwright_worker_module._start_playwright_for_browser()
|
||||||
|
|
||||||
|
self.assertEqual(driver, "playwright-driver")
|
||||||
|
self.assertFalse(headless)
|
||||||
|
manager.start.assert_awaited_once_with()
|
||||||
|
|
||||||
|
async def test_visible_login_prompt_overrides_stale_sessionid(self):
|
||||||
|
worker = DouyinWorker(account_id=917, login_mode="browser")
|
||||||
|
worker._has_visible_login_prompt = AsyncMock(return_value=True)
|
||||||
|
worker.check_homepage_login_status = AsyncMock(return_value=True)
|
||||||
|
worker.check_logged_in_by_cookie = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
logged_in = await worker._verify_login_state()
|
||||||
|
|
||||||
|
self.assertFalse(logged_in)
|
||||||
|
worker.check_homepage_login_status.assert_not_awaited()
|
||||||
|
worker.check_logged_in_by_cookie.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_homepage_message_entry_is_not_login_evidence(self):
|
||||||
|
worker = DouyinWorker(account_id=918, login_mode="browser")
|
||||||
|
worker._has_visible_login_prompt = AsyncMock(return_value=False)
|
||||||
|
worker.page = SimpleNamespace(query_selector=AsyncMock(return_value=None))
|
||||||
|
|
||||||
|
logged_in = await worker.check_homepage_login_status()
|
||||||
|
|
||||||
|
self.assertFalse(logged_in)
|
||||||
|
worker.page.query_selector.assert_awaited_once()
|
||||||
|
|
||||||
async def test_start_saves_task_and_stop_waits_until_it_is_done(self):
|
async def test_start_saves_task_and_stop_waits_until_it_is_done(self):
|
||||||
worker = DouyinWorker(account_id=919, login_mode="im_direct")
|
worker = DouyinWorker(account_id=919, login_mode="im_direct")
|
||||||
loop_started = asyncio.Event()
|
loop_started = asyncio.Event()
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class WorkerScaleControlTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||||
worker._build_im_session_from_storage = AsyncMock(return_value=session)
|
worker._build_im_session_from_storage = AsyncMock(return_value=session)
|
||||||
worker._require_sec_user_id = AsyncMock(return_value="sec-user")
|
worker._best_effort_sec_user_id = AsyncMock(return_value="sec-user")
|
||||||
worker._persist_im_session = AsyncMock()
|
worker._persist_im_session = AsyncMock()
|
||||||
worker._run_im_direct_service = AsyncMock()
|
worker._run_im_direct_service = AsyncMock()
|
||||||
|
|
||||||
@@ -124,7 +124,10 @@ class WorkerScaleControlTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(started)
|
self.assertTrue(started)
|
||||||
self.assertEqual(reason, "")
|
self.assertEqual(reason, "")
|
||||||
validate.assert_not_awaited()
|
validate.assert_not_awaited()
|
||||||
worker._require_sec_user_id.assert_awaited_once()
|
worker._best_effort_sec_user_id.assert_awaited_once_with(
|
||||||
|
refresh_if_missing=True,
|
||||||
|
refresh_if_stale=True,
|
||||||
|
)
|
||||||
worker._run_im_direct_service.assert_awaited_once_with(session)
|
worker._run_im_direct_service.assert_awaited_once_with(session)
|
||||||
|
|
||||||
async def test_disabled_follow_welcome_uses_cached_lightweight_config(self):
|
async def test_disabled_follow_welcome_uses_cached_lightweight_config(self):
|
||||||
@@ -171,20 +174,18 @@ class WorkerScaleControlTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
worker.get_db.assert_not_awaited()
|
worker.get_db.assert_not_awaited()
|
||||||
worker._require_sec_user_id.assert_not_awaited()
|
worker._require_sec_user_id.assert_not_awaited()
|
||||||
|
|
||||||
async def test_missing_cached_sec_user_id_stops_hosting(self):
|
async def test_disabled_follow_welcome_ignores_missing_cached_sec_user_id(self):
|
||||||
worker = DouyinWorker(account_id=505)
|
worker = DouyinWorker(account_id=505)
|
||||||
worker._im_service = SimpleNamespace(session=object())
|
worker._im_service = SimpleNamespace(session=object())
|
||||||
worker._follow_config_loaded = True
|
worker._follow_config_loaded = True
|
||||||
worker._refresh_follow_welcome_config = AsyncMock(
|
worker._refresh_follow_welcome_config = AsyncMock(
|
||||||
return_value=(False, "", "")
|
return_value=(False, "", "")
|
||||||
)
|
)
|
||||||
worker._require_sec_user_id = AsyncMock(return_value="")
|
worker._best_effort_sec_user_id = AsyncMock(return_value="")
|
||||||
|
|
||||||
await worker.follow_welcome_tick()
|
await worker.follow_welcome_tick()
|
||||||
|
|
||||||
worker._require_sec_user_id.assert_awaited_once_with(
|
worker._best_effort_sec_user_id.assert_not_awaited()
|
||||||
"托管运行中"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ from rpa_engine.douyin_im.session import DouyinImSession
|
|||||||
from rpa_engine.douyin_im.ws_client import DouyinImWsClient, _reconnect_delay
|
from rpa_engine.douyin_im.ws_client import DouyinImWsClient, _reconnect_delay
|
||||||
|
|
||||||
|
|
||||||
TEST_WS_URL = "wss://frontier-im.douyin.com/ws/v2?token=test-token-value"
|
TEST_WS_URL = (
|
||||||
|
"wss://frontier-im.douyin.com/ws/v2?fpid=9&device_id=10001&"
|
||||||
|
"token=test-token-value"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _FakeWebSocket:
|
class _FakeWebSocket:
|
||||||
@@ -108,6 +111,25 @@ class WebSocketScalingTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertFalse(client.connected)
|
self.assertFalse(client.connected)
|
||||||
self.assertIsNone(client._connection)
|
self.assertIsNone(client._connection)
|
||||||
|
|
||||||
|
async def test_browser_frontier_uses_text_heartbeat_and_filters_ack(self):
|
||||||
|
client = self._make_client()
|
||||||
|
client._running = True
|
||||||
|
websocket = AsyncMock()
|
||||||
|
|
||||||
|
heartbeat = asyncio.create_task(client._run_browser_heartbeat(websocket))
|
||||||
|
for _ in range(20):
|
||||||
|
if websocket.send.await_count:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
heartbeat.cancel()
|
||||||
|
with self.assertRaises(asyncio.CancelledError):
|
||||||
|
await heartbeat
|
||||||
|
|
||||||
|
websocket.send.assert_awaited_once_with("hi")
|
||||||
|
with patch.object(ws_module, "parse_ws_payload") as parse:
|
||||||
|
await client._dispatch("hi")
|
||||||
|
parse.assert_not_called()
|
||||||
|
|
||||||
async def test_starting_500_clients_does_not_create_os_threads(self):
|
async def test_starting_500_clients_does_not_create_os_threads(self):
|
||||||
parked = asyncio.Event()
|
parked = asyncio.Event()
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ def _parse_tea_from_ls(origins: list) -> tuple[str, str]:
|
|||||||
parsed = json.loads(entry.get("value") or "{}")
|
parsed = json.loads(entry.get("value") or "{}")
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
# __tea_cache_first_6383 等条目的值是纯数字(如 1),json.loads 返回 int,
|
||||||
|
# 不是 dict,必须跳过,否则 parsed.get() 抛 AttributeError 导致保存凭证 500。
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
continue
|
||||||
uid = str(parsed.get("user_unique_id") or "").strip()
|
uid = str(parsed.get("user_unique_id") or "").strip()
|
||||||
wid = str(parsed.get("web_id") or "").strip()
|
wid = str(parsed.get("web_id") or "").strip()
|
||||||
if uid and wid and uid == wid and len(uid) > 12:
|
if uid and wid and uid == wid and len(uid) > 12:
|
||||||
@@ -233,6 +237,25 @@ def validate_cookie_json(cookie_data: str) -> dict:
|
|||||||
return normalize_storage_state_for_im(data)
|
return normalize_storage_state_for_im(data)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_user_agent_from_cookie_data(cookie_data: Optional[str]) -> str:
|
||||||
|
"""从已保存的 Cookie JSON 提取顶层 user_agent(登录头)。
|
||||||
|
|
||||||
|
Playwright storage_state 导出时顶层带 user_agent(采集浏览器当前 UA);
|
||||||
|
DYCRED 凭证经 convert_dycred_to_storage_state 后也统一落到 user_agent。
|
||||||
|
返回完整 UA 字符串;缺失/无效时返回空串,由调用方决定是否回填账号。
|
||||||
|
"""
|
||||||
|
if not cookie_data:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
data = json.loads(cookie_data)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return ""
|
||||||
|
ua = str(data.get("user_agent") or data.get("ua") or "").strip()
|
||||||
|
return ua
|
||||||
|
|
||||||
|
|
||||||
def write_cookie_file(account_id: int, cookie_data: str) -> str:
|
def write_cookie_file(account_id: int, cookie_data: str) -> str:
|
||||||
data = validate_cookie_json(cookie_data)
|
data = validate_cookie_json(cookie_data)
|
||||||
path = get_cookie_path(account_id)
|
path = get_cookie_path(account_id)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
BugOutlined,
|
BugOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
ControlOutlined,
|
ControlOutlined,
|
||||||
PayCircleOutlined,
|
PayCircleOutlined,
|
||||||
@@ -22,6 +23,25 @@ import {
|
|||||||
InboxOutlined
|
InboxOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { HEADER_TITLES } from './config/menus'
|
||||||
|
|
||||||
|
const ICON_MAP = {
|
||||||
|
DashboardOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
BugOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
ControlOutlined,
|
||||||
|
PayCircleOutlined,
|
||||||
|
UnorderedListOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
CloudDownloadOutlined,
|
||||||
|
InboxOutlined
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -29,6 +49,9 @@ const auth = useAuthStore()
|
|||||||
|
|
||||||
const selectedKeys = computed(() => [route.path])
|
const selectedKeys = computed(() => [route.path])
|
||||||
const isLoginPage = computed(() => route.path === '/login')
|
const isLoginPage = computed(() => route.path === '/login')
|
||||||
|
const headerTitle = computed(
|
||||||
|
() => HEADER_TITLES[route.name] || route.name || '工作台'
|
||||||
|
)
|
||||||
|
|
||||||
const navigate = ({ key }) => {
|
const navigate = ({ key }) => {
|
||||||
router.push(key)
|
router.push(key)
|
||||||
@@ -57,61 +80,11 @@ const handleLogout = () => {
|
|||||||
@click="navigate"
|
@click="navigate"
|
||||||
class="custom-menu"
|
class="custom-menu"
|
||||||
>
|
>
|
||||||
<a-menu-item key="/">
|
<a-menu-item v-for="item in auth.visibleMenus" :key="item.path">
|
||||||
<template #icon><DashboardOutlined /></template>
|
<template #icon>
|
||||||
<span>数据概览</span>
|
<component :is="ICON_MAP[item.icon]" />
|
||||||
</a-menu-item>
|
</template>
|
||||||
<a-menu-item key="/accounts">
|
<span>{{ item.title }}</span>
|
||||||
<template #icon><UserOutlined /></template>
|
|
||||||
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="/messages">
|
|
||||||
<template #icon><MessageOutlined /></template>
|
|
||||||
<span>私信收发</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="/rules">
|
|
||||||
<template #icon><SettingOutlined /></template>
|
|
||||||
<span>自动回复规则</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="/logs">
|
|
||||||
<template #icon><FileTextOutlined /></template>
|
|
||||||
<span>回复日志面板</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="/received-messages">
|
|
||||||
<template #icon><InboxOutlined /></template>
|
|
||||||
<span>接收消息日志</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="/system-logs">
|
|
||||||
<template #icon><BugOutlined /></template>
|
|
||||||
<span>系统诊断日志</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="/download">
|
|
||||||
<template #icon><CloudDownloadOutlined /></template>
|
|
||||||
<span>软件下载</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="/help">
|
|
||||||
<template #icon><QuestionCircleOutlined /></template>
|
|
||||||
<span>帮助中心</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item v-if="auth.isAdmin" key="/users">
|
|
||||||
<template #icon><TeamOutlined /></template>
|
|
||||||
<span>用户与角色</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item v-if="auth.isAdmin" key="/settings">
|
|
||||||
<template #icon><ControlOutlined /></template>
|
|
||||||
<span>系统设置</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item v-if="auth.isAdmin" key="/desktop-update">
|
|
||||||
<template #icon><RocketOutlined /></template>
|
|
||||||
<span>桌面端升级</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item v-if="auth.isAdmin" key="/payment-settings">
|
|
||||||
<template #icon><PayCircleOutlined /></template>
|
|
||||||
<span>支付配置</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item v-if="!auth.isAdmin" key="/payment-orders">
|
|
||||||
<template #icon><UnorderedListOutlined /></template>
|
|
||||||
<span>我的订单</span>
|
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
</a-layout-sider>
|
</a-layout-sider>
|
||||||
@@ -119,9 +92,7 @@ const handleLogout = () => {
|
|||||||
<a-layout>
|
<a-layout>
|
||||||
<a-layout-header class="app-header">
|
<a-layout-header class="app-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h2 class="header-title">
|
<h2 class="header-title">{{ headerTitle }}</h2>
|
||||||
{{ route.name === 'Dashboard' ? '数据中心' : route.name === 'Accounts' ? '账号中心' : route.name === 'Messages' ? '私信中心' : route.name === 'Rules' ? '策略中心' : route.name === 'ReceivedMessages' ? '接收消息日志' : route.name === 'SystemLogs' ? '诊断中心' : route.name === 'Users' ? '权限中心' : route.name === 'Settings' ? '系统设置' : route.name === 'DesktopUpdate' ? '桌面端升级' : route.name === 'PaymentSettings' ? '支付配置' : route.name === 'MyPaymentOrders' ? '我的订单' : route.name === 'Download' ? '软件下载' : route.name === 'Help' ? '帮助中心' : '日志中心' }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<a-space size="middle">
|
<a-space size="middle">
|
||||||
@@ -180,7 +151,6 @@ const handleLogout = () => {
|
|||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 侧边栏折叠时只保留图标,避免标题文字竖排变形 */
|
|
||||||
.app-sider.ant-layout-sider-collapsed .logo-container {
|
.app-sider.ant-layout-sider-collapsed .logo-container {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ const props = defineProps({
|
|||||||
showHeader: {
|
showHeader: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
canUploadCards: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,10 +47,12 @@ const typeOptions = replyTypeOptions.map((opt) => ({
|
|||||||
const cardUploading = ref({})
|
const cardUploading = ref({})
|
||||||
|
|
||||||
const addReplyItem = () => {
|
const addReplyItem = () => {
|
||||||
|
if (props.readonly) return
|
||||||
replyItems.value = [...replyItems.value, emptyReplyForm()]
|
replyItems.value = [...replyItems.value, emptyReplyForm()]
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeReplyItem = (index) => {
|
const removeReplyItem = (index) => {
|
||||||
|
if (props.readonly) return
|
||||||
if (replyItems.value.length <= 1) {
|
if (replyItems.value.length <= 1) {
|
||||||
message.warning('至少保留一条回复消息')
|
message.warning('至少保留一条回复消息')
|
||||||
return
|
return
|
||||||
@@ -53,6 +63,7 @@ const removeReplyItem = (index) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateReplyField = (index, field, value) => {
|
const updateReplyField = (index, field, value) => {
|
||||||
|
if (props.readonly) return
|
||||||
const next = replyItems.value.map((item, i) =>
|
const next = replyItems.value.map((item, i) =>
|
||||||
i === index ? { ...item, [field]: value } : item
|
i === index ? { ...item, [field]: value } : item
|
||||||
)
|
)
|
||||||
@@ -60,6 +71,7 @@ const updateReplyField = (index, field, value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateReplyFields = (index, fields) => {
|
const updateReplyFields = (index, fields) => {
|
||||||
|
if (props.readonly) return
|
||||||
const next = replyItems.value.map((item, i) =>
|
const next = replyItems.value.map((item, i) =>
|
||||||
i === index ? { ...item, ...fields } : item
|
i === index ? { ...item, ...fields } : item
|
||||||
)
|
)
|
||||||
@@ -67,6 +79,11 @@ const updateReplyFields = (index, fields) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uploadCardImage = async (index, options) => {
|
const uploadCardImage = async (index, options) => {
|
||||||
|
if (props.readonly || !props.canUploadCards) {
|
||||||
|
message.warning('当前账号无卡片上传权限')
|
||||||
|
options?.onError?.(new Error('no permission'))
|
||||||
|
return
|
||||||
|
}
|
||||||
const { file, onSuccess, onError } = options
|
const { file, onSuccess, onError } = options
|
||||||
cardUploading.value = { ...cardUploading.value, [index]: true }
|
cardUploading.value = { ...cardUploading.value, [index]: true }
|
||||||
try {
|
try {
|
||||||
@@ -104,7 +121,13 @@ const copyPageUrl = async (url) => {
|
|||||||
<div class="reply-rule-editor">
|
<div class="reply-rule-editor">
|
||||||
<div v-if="showHeader" class="reply-list-header">
|
<div v-if="showHeader" class="reply-list-header">
|
||||||
<span class="reply-list-title">自动回复消息</span>
|
<span class="reply-list-title">自动回复消息</span>
|
||||||
<a-button type="dashed" size="small" class="reply-add-btn" @click="addReplyItem">
|
<a-button
|
||||||
|
v-if="!readonly"
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
class="reply-add-btn"
|
||||||
|
@click="addReplyItem"
|
||||||
|
>
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
添加一条消息
|
添加一条消息
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -114,7 +137,7 @@ const copyPageUrl = async (url) => {
|
|||||||
<div class="reply-item-header">
|
<div class="reply-item-header">
|
||||||
<span>消息 {{ replyIndex + 1 }}</span>
|
<span>消息 {{ replyIndex + 1 }}</span>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="replyItems.length > 1"
|
v-if="!readonly && replyItems.length > 1"
|
||||||
type="text"
|
type="text"
|
||||||
danger
|
danger
|
||||||
size="small"
|
size="small"
|
||||||
@@ -130,6 +153,7 @@ const copyPageUrl = async (url) => {
|
|||||||
:value="reply.reply_type"
|
:value="reply.reply_type"
|
||||||
button-style="solid"
|
button-style="solid"
|
||||||
class="reply-type-group"
|
class="reply-type-group"
|
||||||
|
:disabled="readonly"
|
||||||
@update:value="(v) => updateReplyField(replyIndex, 'reply_type', v)"
|
@update:value="(v) => updateReplyField(replyIndex, 'reply_type', v)"
|
||||||
>
|
>
|
||||||
<a-radio-button v-for="opt in typeOptions" :key="opt.value" :value="opt.value">
|
<a-radio-button v-for="opt in typeOptions" :key="opt.value" :value="opt.value">
|
||||||
@@ -193,6 +217,7 @@ const copyPageUrl = async (url) => {
|
|||||||
<a-form-item label="封面图片" required>
|
<a-form-item label="封面图片" required>
|
||||||
<div class="card-upload-row">
|
<div class="card-upload-row">
|
||||||
<a-upload
|
<a-upload
|
||||||
|
v-if="canUploadCards && !readonly"
|
||||||
name="file"
|
name="file"
|
||||||
list-type="picture-card"
|
list-type="picture-card"
|
||||||
:show-upload-list="false"
|
:show-upload-list="false"
|
||||||
@@ -214,6 +239,15 @@ const copyPageUrl = async (url) => {
|
|||||||
<div>上传封面</div>
|
<div>上传封面</div>
|
||||||
</div>
|
</div>
|
||||||
</a-upload>
|
</a-upload>
|
||||||
|
<div v-else class="card-upload-placeholder">
|
||||||
|
<img
|
||||||
|
v-if="reply.reply_card_cover_url || reply.reply_card_image_path"
|
||||||
|
:src="reply.reply_card_cover_url || reply.reply_card_image_path"
|
||||||
|
alt="封面"
|
||||||
|
class="card-cover-preview"
|
||||||
|
/>
|
||||||
|
<template v-else>无上传权限</template>
|
||||||
|
</div>
|
||||||
<span class="card-upload-tip">上传后自动转为 PNG favicon(32×32)与卡片封面(256×256)</span>
|
<span class="card-upload-tip">上传后自动转为 PNG favicon(32×32)与卡片封面(256×256)</span>
|
||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Sidebar menu catalog. Visibility is driven by permission codes from /auth/me.
|
||||||
|
* Admin pages also require paired action permissions (alsoRequires).
|
||||||
|
*/
|
||||||
|
export const MENU_ITEMS = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Dashboard',
|
||||||
|
title: '数据概览',
|
||||||
|
permission: 'menu.dashboard',
|
||||||
|
icon: 'DashboardOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/accounts',
|
||||||
|
name: 'Accounts',
|
||||||
|
title: '我的账号',
|
||||||
|
adminTitle: '账号管理',
|
||||||
|
permission: 'menu.accounts',
|
||||||
|
icon: 'UserOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/messages',
|
||||||
|
name: 'Messages',
|
||||||
|
title: '私信收发',
|
||||||
|
permission: 'menu.messages',
|
||||||
|
icon: 'MessageOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/rules',
|
||||||
|
name: 'Rules',
|
||||||
|
title: '自动回复规则',
|
||||||
|
permission: 'menu.rules',
|
||||||
|
icon: 'SettingOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/logs',
|
||||||
|
name: 'Logs',
|
||||||
|
title: '回复日志面板',
|
||||||
|
permission: 'menu.logs',
|
||||||
|
alsoRequires: ['logs.read'],
|
||||||
|
icon: 'FileTextOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/received-messages',
|
||||||
|
name: 'ReceivedMessages',
|
||||||
|
title: '接收消息日志',
|
||||||
|
permission: 'menu.received_messages',
|
||||||
|
alsoRequires: ['received_messages.read'],
|
||||||
|
icon: 'InboxOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/system-logs',
|
||||||
|
name: 'SystemLogs',
|
||||||
|
title: '系统诊断日志',
|
||||||
|
permission: 'menu.system_logs',
|
||||||
|
alsoRequires: ['system_logs.read'],
|
||||||
|
icon: 'BugOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/download',
|
||||||
|
name: 'Download',
|
||||||
|
title: '软件下载',
|
||||||
|
permission: 'menu.download',
|
||||||
|
icon: 'CloudDownloadOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/help',
|
||||||
|
name: 'Help',
|
||||||
|
title: '帮助中心',
|
||||||
|
permission: 'menu.help',
|
||||||
|
icon: 'QuestionCircleOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users',
|
||||||
|
name: 'Users',
|
||||||
|
title: '用户管理',
|
||||||
|
permission: 'menu.users',
|
||||||
|
alsoRequires: ['users.manage'],
|
||||||
|
icon: 'TeamOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/roles',
|
||||||
|
name: 'Roles',
|
||||||
|
title: '角色设定',
|
||||||
|
permission: 'menu.roles',
|
||||||
|
alsoRequires: ['roles.manage'],
|
||||||
|
icon: 'SafetyCertificateOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
title: '系统设置',
|
||||||
|
permission: 'menu.settings',
|
||||||
|
alsoRequires: ['settings.manage'],
|
||||||
|
icon: 'ControlOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/desktop-update',
|
||||||
|
name: 'DesktopUpdate',
|
||||||
|
title: '桌面端升级',
|
||||||
|
permission: 'menu.desktop_update',
|
||||||
|
alsoRequires: ['desktop.manage'],
|
||||||
|
icon: 'RocketOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/payment-settings',
|
||||||
|
name: 'PaymentSettings',
|
||||||
|
title: '支付配置',
|
||||||
|
permission: 'menu.payment_settings',
|
||||||
|
alsoRequires: ['payments.manage'],
|
||||||
|
icon: 'PayCircleOutlined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/payment-orders',
|
||||||
|
name: 'MyPaymentOrders',
|
||||||
|
title: '我的订单',
|
||||||
|
permission: 'menu.payment_orders',
|
||||||
|
alsoRequires: ['orders.read'],
|
||||||
|
icon: 'UnorderedListOutlined'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const HEADER_TITLES = {
|
||||||
|
Dashboard: '数据中心',
|
||||||
|
Accounts: '账号中心',
|
||||||
|
Messages: '私信中心',
|
||||||
|
Rules: '策略中心',
|
||||||
|
ReceivedMessages: '接收消息日志',
|
||||||
|
SystemLogs: '诊断中心',
|
||||||
|
Users: '用户管理',
|
||||||
|
Roles: '角色设定',
|
||||||
|
Settings: '系统设置',
|
||||||
|
DesktopUpdate: '桌面端升级',
|
||||||
|
PaymentSettings: '支付配置',
|
||||||
|
MyPaymentOrders: '我的订单',
|
||||||
|
Download: '软件下载',
|
||||||
|
Help: '帮助中心',
|
||||||
|
Logs: '日志中心'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function menuAccessible(item, hasPermission) {
|
||||||
|
if (!item || !hasPermission(item.permission)) return false
|
||||||
|
const extra = item.alsoRequires || []
|
||||||
|
return extra.every((code) => hasPermission(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstAccessiblePath(hasPermission) {
|
||||||
|
const hit = MENU_ITEMS.find((item) => menuAccessible(item, hasPermission))
|
||||||
|
return hit?.path || '/help'
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import SystemLogs from '../views/SystemLogs.vue'
|
|||||||
import ReceivedMessages from '../views/ReceivedMessages.vue'
|
import ReceivedMessages from '../views/ReceivedMessages.vue'
|
||||||
import Login from '../views/Login.vue'
|
import Login from '../views/Login.vue'
|
||||||
import Users from '../views/Users.vue'
|
import Users from '../views/Users.vue'
|
||||||
|
import Roles from '../views/Roles.vue'
|
||||||
import Settings from '../views/Settings.vue'
|
import Settings from '../views/Settings.vue'
|
||||||
import DesktopUpdate from '../views/DesktopUpdate.vue'
|
import DesktopUpdate from '../views/DesktopUpdate.vue'
|
||||||
import PaymentSettings from '../views/PaymentSettings.vue'
|
import PaymentSettings from '../views/PaymentSettings.vue'
|
||||||
@@ -15,23 +16,51 @@ import MyPaymentOrders from '../views/MyPaymentOrders.vue'
|
|||||||
import Help from '../views/Help.vue'
|
import Help from '../views/Help.vue'
|
||||||
import Download from '../views/Download.vue'
|
import Download from '../views/Download.vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { firstAccessiblePath, MENU_ITEMS, menuAccessible } from '../config/menus'
|
||||||
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/login', component: Login, name: 'Login', meta: { public: true } },
|
{ path: '/login', component: Login, name: 'Login', meta: { public: true } },
|
||||||
{ path: '/', component: Dashboard, name: 'Dashboard' },
|
{ path: '/', component: Dashboard, name: 'Dashboard', meta: { permission: 'menu.dashboard' } },
|
||||||
{ path: '/accounts', component: Accounts, name: 'Accounts', meta: { write: true } },
|
{ path: '/accounts', component: Accounts, name: 'Accounts', meta: { permission: 'menu.accounts' } },
|
||||||
{ path: '/messages', component: Messages, name: 'Messages', meta: { write: true } },
|
{ path: '/messages', component: Messages, name: 'Messages', meta: { permission: 'menu.messages' } },
|
||||||
{ path: '/rules', component: Rules, name: 'Rules', meta: { write: true } },
|
{ path: '/rules', component: Rules, name: 'Rules', meta: { permission: 'menu.rules' } },
|
||||||
{ path: '/logs', component: Logs, name: 'Logs' },
|
{ path: '/logs', component: Logs, name: 'Logs', meta: { permission: 'menu.logs' } },
|
||||||
{ path: '/received-messages', component: ReceivedMessages, name: 'ReceivedMessages' },
|
{
|
||||||
{ path: '/system-logs', component: SystemLogs, name: 'SystemLogs' },
|
path: '/received-messages',
|
||||||
{ path: '/users', component: Users, name: 'Users', meta: { admin: true } },
|
component: ReceivedMessages,
|
||||||
{ path: '/settings', component: Settings, name: 'Settings', meta: { admin: true } },
|
name: 'ReceivedMessages',
|
||||||
{ path: '/desktop-update', component: DesktopUpdate, name: 'DesktopUpdate', meta: { admin: true } },
|
meta: { permission: 'menu.received_messages' }
|
||||||
{ path: '/payment-settings', component: PaymentSettings, name: 'PaymentSettings', meta: { admin: true } },
|
},
|
||||||
{ path: '/payment-orders', component: MyPaymentOrders, name: 'MyPaymentOrders' },
|
{
|
||||||
{ path: '/help', component: Help, name: 'Help' },
|
path: '/system-logs',
|
||||||
{ path: '/download', component: Download, name: 'Download' }
|
component: SystemLogs,
|
||||||
|
name: 'SystemLogs',
|
||||||
|
meta: { permission: 'menu.system_logs' }
|
||||||
|
},
|
||||||
|
{ path: '/users', component: Users, name: 'Users', meta: { permission: 'menu.users' } },
|
||||||
|
{ path: '/roles', component: Roles, name: 'Roles', meta: { permission: 'menu.roles' } },
|
||||||
|
{ path: '/settings', component: Settings, name: 'Settings', meta: { permission: 'menu.settings' } },
|
||||||
|
{
|
||||||
|
path: '/desktop-update',
|
||||||
|
component: DesktopUpdate,
|
||||||
|
name: 'DesktopUpdate',
|
||||||
|
meta: { permission: 'menu.desktop_update' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/payment-settings',
|
||||||
|
component: PaymentSettings,
|
||||||
|
name: 'PaymentSettings',
|
||||||
|
meta: { permission: 'menu.payment_settings' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/payment-orders',
|
||||||
|
component: MyPaymentOrders,
|
||||||
|
name: 'MyPaymentOrders',
|
||||||
|
meta: { permission: 'menu.payment_orders' }
|
||||||
|
},
|
||||||
|
{ path: '/help', component: Help, name: 'Help', meta: { permission: 'menu.help' } },
|
||||||
|
{ path: '/download', component: Download, name: 'Download', meta: { permission: 'menu.download' } }
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -44,7 +73,7 @@ router.beforeEach(async (to) => {
|
|||||||
|
|
||||||
if (to.meta.public) {
|
if (to.meta.public) {
|
||||||
if (auth.isLoggedIn && to.path === '/login') {
|
if (auth.isLoggedIn && to.path === '/login') {
|
||||||
return '/'
|
return firstAccessiblePath((code) => auth.hasPermission(code))
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -60,14 +89,24 @@ router.beforeEach(async (to) => {
|
|||||||
auth.clearSession()
|
auth.clearSession()
|
||||||
return '/login'
|
return '/login'
|
||||||
}
|
}
|
||||||
|
} else if (!Array.isArray(auth.user.permissions)) {
|
||||||
|
// Old localStorage sessions lack the permission list.
|
||||||
|
try {
|
||||||
|
await auth.fetchMe()
|
||||||
|
} catch {
|
||||||
|
auth.clearSession()
|
||||||
|
return '/login'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.meta.admin && !auth.isAdmin) {
|
const required = to.meta.permission
|
||||||
return '/'
|
if (required && !auth.hasPermission(required)) {
|
||||||
|
return firstAccessiblePath((code) => auth.hasPermission(code))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.meta.write && auth.isViewer) {
|
const menuItem = MENU_ITEMS.find((item) => item.path === to.path)
|
||||||
return '/'
|
if (menuItem && !menuAccessible(menuItem, (code) => auth.hasPermission(code))) {
|
||||||
|
return firstAccessiblePath((code) => auth.hasPermission(code))
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,197 +1,188 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
import { MENU_ITEMS, menuAccessible } from '../config/menus'
|
||||||
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
|
||||||
const token = ref(localStorage.getItem('kefu_token') || '')
|
const token = ref(localStorage.getItem('kefu_token') || '')
|
||||||
|
|
||||||
const user = ref(JSON.parse(localStorage.getItem('kefu_user') || 'null'))
|
const user = ref(JSON.parse(localStorage.getItem('kefu_user') || 'null'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
const permissions = computed(() => {
|
||||||
|
const list = user.value?.permissions
|
||||||
const canWrite = computed(() => ['admin', 'operator'].includes(user.value?.role))
|
return Array.isArray(list) ? list : []
|
||||||
|
|
||||||
const isViewer = computed(() => user.value?.role === 'viewer')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const roleLabel = computed(() => {
|
|
||||||
|
|
||||||
const map = { admin: '管理员', operator: '运营', viewer: '只读' }
|
|
||||||
|
|
||||||
return map[user.value?.role] || user.value?.role || ''
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const permissionSet = computed(() => new Set(permissions.value))
|
||||||
|
|
||||||
|
const isAdmin = computed(() => {
|
||||||
|
if (typeof user.value?.is_admin === 'boolean') return user.value.is_admin
|
||||||
|
return user.value?.role === 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasPermission = (code) => {
|
||||||
|
if (!code) return true
|
||||||
|
if (isAdmin.value) return true
|
||||||
|
if (permissionSet.value.has(code)) return true
|
||||||
|
// Legacy accounts.write covers all granular account write buttons.
|
||||||
|
if (
|
||||||
|
code.startsWith('accounts.') &&
|
||||||
|
code !== 'accounts.write' &&
|
||||||
|
permissionSet.value.has('accounts.write')
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const canWrite = computed(() =>
|
||||||
|
hasPermission('accounts.write') ||
|
||||||
|
hasPermission('accounts.create') ||
|
||||||
|
hasPermission('accounts.update') ||
|
||||||
|
hasPermission('accounts.delete') ||
|
||||||
|
hasPermission('accounts.start') ||
|
||||||
|
hasPermission('accounts.stop') ||
|
||||||
|
hasPermission('accounts.cookie')
|
||||||
|
)
|
||||||
|
const canCreateAccounts = computed(() => hasPermission('accounts.create'))
|
||||||
|
const canUpdateAccounts = computed(() => hasPermission('accounts.update'))
|
||||||
|
const canDeleteAccounts = computed(() => hasPermission('accounts.delete'))
|
||||||
|
const canStartAccounts = computed(() => hasPermission('accounts.start'))
|
||||||
|
const canStopAccounts = computed(() => hasPermission('accounts.stop'))
|
||||||
|
const canManageCookies = computed(() => hasPermission('accounts.cookie'))
|
||||||
|
const canWriteMessages = computed(() => hasPermission('messages.write'))
|
||||||
|
const canWriteRules = computed(() => hasPermission('rules.write'))
|
||||||
|
const canWriteLinkCards = computed(() => hasPermission('link_cards.write'))
|
||||||
|
const canClearSystemLogs = computed(() => hasPermission('system_logs.clear'))
|
||||||
|
const canManageUsers = computed(() => hasPermission('users.manage'))
|
||||||
|
const canManageRoles = computed(() => hasPermission('roles.manage'))
|
||||||
|
const canManagePayments = computed(() => hasPermission('payments.manage'))
|
||||||
|
const canManageSettings = computed(() => hasPermission('settings.manage'))
|
||||||
|
const canManageDatabase = computed(() => hasPermission('settings.database'))
|
||||||
|
const canCreateOrders = computed(() => hasPermission('orders.create'))
|
||||||
|
const hasGlobalDataScope = computed(
|
||||||
|
() => isAdmin.value || hasPermission('data.scope_all')
|
||||||
|
)
|
||||||
|
const isViewer = computed(
|
||||||
|
() =>
|
||||||
|
!canWrite.value &&
|
||||||
|
!canWriteMessages.value &&
|
||||||
|
!canWriteRules.value &&
|
||||||
|
!isAdmin.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const roleLabel = computed(
|
||||||
|
() => user.value?.role_label || user.value?.role || ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleMenus = computed(() =>
|
||||||
|
MENU_ITEMS.filter((item) => menuAccessible(item, hasPermission)).map((item) => ({
|
||||||
|
...item,
|
||||||
|
title: isAdmin.value && item.adminTitle ? item.adminTitle : item.title
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
const setSession = (accessToken, userData) => {
|
const setSession = (accessToken, userData) => {
|
||||||
|
|
||||||
token.value = accessToken
|
token.value = accessToken
|
||||||
|
|
||||||
user.value = userData
|
user.value = userData
|
||||||
|
|
||||||
localStorage.setItem('kefu_token', accessToken)
|
localStorage.setItem('kefu_token', accessToken)
|
||||||
|
|
||||||
localStorage.setItem('kefu_user', JSON.stringify(userData))
|
localStorage.setItem('kefu_user', JSON.stringify(userData))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const clearSession = () => {
|
const clearSession = () => {
|
||||||
|
|
||||||
token.value = ''
|
token.value = ''
|
||||||
|
|
||||||
user.value = null
|
user.value = null
|
||||||
|
|
||||||
localStorage.removeItem('kefu_token')
|
localStorage.removeItem('kefu_token')
|
||||||
|
|
||||||
localStorage.removeItem('kefu_user')
|
localStorage.removeItem('kefu_user')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const login = async (username, password) => {
|
const login = async (username, password) => {
|
||||||
|
|
||||||
const res = await api.post('/auth/login', { username, password })
|
const res = await api.post('/auth/login', { username, password })
|
||||||
|
|
||||||
const accessToken = res.data.access_token
|
const accessToken = res.data.access_token
|
||||||
|
|
||||||
const me = await api.get('/auth/me', {
|
const me = await api.get('/auth/me', {
|
||||||
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` }
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setSession(accessToken, me.data)
|
setSession(accessToken, me.data)
|
||||||
|
|
||||||
return me.data
|
return me.data
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const register = async (payload) => {
|
const register = async (payload) => {
|
||||||
|
|
||||||
const res = await api.post('/auth/register', payload)
|
const res = await api.post('/auth/register', payload)
|
||||||
|
|
||||||
return res.data
|
return res.data
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const verifyEmail = async (verifyToken) => {
|
const verifyEmail = async (verifyToken) => {
|
||||||
|
|
||||||
const res = await api.post('/auth/verify-email', { token: verifyToken })
|
const res = await api.post('/auth/verify-email', { token: verifyToken })
|
||||||
|
|
||||||
return res.data
|
return res.data
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const resendVerification = async (payload) => {
|
const resendVerification = async (payload) => {
|
||||||
|
|
||||||
const res = await api.post('/auth/resend-verification', payload)
|
const res = await api.post('/auth/resend-verification', payload)
|
||||||
|
|
||||||
return res.data
|
return res.data
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const forgotPassword = async (payload) => {
|
const forgotPassword = async (payload) => {
|
||||||
|
|
||||||
const res = await api.post('/auth/forgot-password', payload)
|
const res = await api.post('/auth/forgot-password', payload)
|
||||||
|
|
||||||
return res.data
|
return res.data
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetPassword = async (resetToken, password) => {
|
||||||
|
const res = await api.post('/auth/reset-password', {
|
||||||
const resetPassword = async (token, password) => {
|
token: resetToken,
|
||||||
|
password
|
||||||
const res = await api.post('/auth/reset-password', { token, password })
|
})
|
||||||
|
|
||||||
return res.data
|
return res.data
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const fetchMe = async () => {
|
const fetchMe = async () => {
|
||||||
|
|
||||||
if (!token.value) return null
|
if (!token.value) return null
|
||||||
|
|
||||||
const res = await api.get('/auth/me')
|
const res = await api.get('/auth/me')
|
||||||
|
|
||||||
user.value = res.data
|
user.value = res.data
|
||||||
|
|
||||||
localStorage.setItem('kefu_user', JSON.stringify(res.data))
|
localStorage.setItem('kefu_user', JSON.stringify(res.data))
|
||||||
|
|
||||||
return res.data
|
return res.data
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
|
||||||
clearSession()
|
clearSession()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
||||||
token,
|
token,
|
||||||
|
|
||||||
user,
|
user,
|
||||||
|
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
|
permissions,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
|
||||||
canWrite,
|
canWrite,
|
||||||
|
canCreateAccounts,
|
||||||
|
canUpdateAccounts,
|
||||||
|
canDeleteAccounts,
|
||||||
|
canStartAccounts,
|
||||||
|
canStopAccounts,
|
||||||
|
canManageCookies,
|
||||||
|
canWriteMessages,
|
||||||
|
canWriteRules,
|
||||||
|
canWriteLinkCards,
|
||||||
|
canClearSystemLogs,
|
||||||
|
canManageUsers,
|
||||||
|
canManageRoles,
|
||||||
|
canManagePayments,
|
||||||
|
canManageSettings,
|
||||||
|
canManageDatabase,
|
||||||
|
canCreateOrders,
|
||||||
|
hasGlobalDataScope,
|
||||||
isViewer,
|
isViewer,
|
||||||
|
|
||||||
roleLabel,
|
roleLabel,
|
||||||
|
visibleMenus,
|
||||||
|
hasPermission,
|
||||||
login,
|
login,
|
||||||
|
|
||||||
register,
|
register,
|
||||||
|
|
||||||
verifyEmail,
|
verifyEmail,
|
||||||
|
|
||||||
resendVerification,
|
resendVerification,
|
||||||
|
|
||||||
forgotPassword,
|
forgotPassword,
|
||||||
|
|
||||||
resetPassword,
|
resetPassword,
|
||||||
|
|
||||||
fetchMe,
|
fetchMe,
|
||||||
|
|
||||||
logout,
|
logout,
|
||||||
|
|
||||||
clearSession
|
clearSession
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,12 @@
|
|||||||
--accent-green: hsl(150, 75%, 50%);
|
--accent-green: hsl(150, 75%, 50%);
|
||||||
--accent-red: hsl(360, 75%, 60%);
|
--accent-red: hsl(360, 75%, 60%);
|
||||||
|
|
||||||
--text-primary: hsl(0, 0%, 95%);
|
--text-primary: hsl(0, 0%, 96%);
|
||||||
--text-secondary: hsl(230, 10%, 65%);
|
--text-secondary: hsl(230, 12%, 72%);
|
||||||
--text-muted: hsl(230, 10%, 45%);
|
--text-muted: hsl(230, 10%, 58%);
|
||||||
|
|
||||||
--border-light: rgba(255, 255, 255, 0.06);
|
--border-light: rgba(255, 255, 255, 0.1);
|
||||||
--border-glow: hsla(270, 85%, 65%, 0.2);
|
--border-glow: hsla(270, 85%, 65%, 0.28);
|
||||||
|
|
||||||
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||||
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
@@ -287,17 +287,148 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
.ant-modal .ant-input-password .ant-input,
|
.ant-modal .ant-input-password .ant-input,
|
||||||
.ant-modal textarea.ant-input,
|
.ant-modal textarea.ant-input,
|
||||||
.ant-modal .ant-select-selector {
|
.ant-modal .ant-select-selector {
|
||||||
background: rgba(255, 255, 255, 0.05) !important;
|
background: rgba(255, 255, 255, 0.08) !important;
|
||||||
border-color: var(--border-light) !important;
|
border-color: rgba(255, 255, 255, 0.16) !important;
|
||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal .ant-input:hover,
|
||||||
|
.ant-modal .ant-input-affix-wrapper:hover,
|
||||||
|
.ant-modal .ant-select-selector:hover,
|
||||||
|
.ant-modal .ant-input-number:hover {
|
||||||
|
border-color: rgba(192, 132, 252, 0.45) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal .ant-input:focus,
|
||||||
|
.ant-modal .ant-input-focused,
|
||||||
|
.ant-modal .ant-input-affix-wrapper-focused,
|
||||||
|
.ant-modal .ant-select-focused .ant-select-selector,
|
||||||
|
.ant-modal .ant-input-number-focused {
|
||||||
|
border-color: rgba(192, 132, 252, 0.65) !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(170, 59, 255, 0.18) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-modal .ant-input::placeholder,
|
.ant-modal .ant-input::placeholder,
|
||||||
.ant-modal .ant-input-number-input::placeholder,
|
.ant-modal .ant-input-number-input::placeholder,
|
||||||
.ant-modal textarea.ant-input::placeholder {
|
.ant-modal textarea.ant-input::placeholder {
|
||||||
|
color: hsl(230, 10%, 62%) !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal .ant-input-affix-wrapper input::placeholder {
|
||||||
|
color: hsl(230, 10%, 62%) !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal .ant-checkbox-wrapper {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal .ant-checkbox-inner {
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal .ant-checkbox-checked .ant-checkbox-inner {
|
||||||
|
background: #7c3aed !important;
|
||||||
|
border-color: #a78bfa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal .ant-select-arrow,
|
||||||
|
.ant-modal .ant-select-selection-placeholder {
|
||||||
color: var(--text-muted) !important;
|
color: var(--text-muted) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-modal .ant-select-selection-item {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal .ant-alert-info {
|
||||||
|
background: rgba(59, 130, 246, 0.12) !important;
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal .ant-alert-message {
|
||||||
|
color: #dbeafe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs / 搜索框 / 分页 — 暗色可读性 */
|
||||||
|
.ant-tabs-top > .ant-tabs-nav::before {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs .ant-tabs-tab {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs .ant-tabs-tab:hover {
|
||||||
|
color: #e9d5ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||||
|
color: #f3e8ff !important;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs .ant-tabs-ink-bar {
|
||||||
|
background: linear-gradient(90deg, #aa3bff, #c084fc) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input,
|
||||||
|
.ant-input-affix-wrapper,
|
||||||
|
.ant-select:not(.ant-select-customize-input) .ant-select-selector {
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.14) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input::placeholder,
|
||||||
|
.ant-input-affix-wrapper input::placeholder {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-pagination {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-pagination .ant-pagination-item {
|
||||||
|
background: rgba(255, 255, 255, 0.04) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-pagination .ant-pagination-item a {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-pagination .ant-pagination-item-active {
|
||||||
|
background: rgba(147, 51, 234, 0.25) !important;
|
||||||
|
border-color: rgba(192, 132, 252, 0.55) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-pagination .ant-pagination-item-active a {
|
||||||
|
color: #f3e8ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-pagination .ant-pagination-prev .ant-pagination-item-link,
|
||||||
|
.ant-pagination .ant-pagination-next .ant-pagination-item-link,
|
||||||
|
.ant-pagination .ant-select-selector {
|
||||||
|
background: rgba(255, 255, 255, 0.04) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.12) !important;
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-pagination-options-quick-jumper input {
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.14) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-pagination.ant-pagination {
|
||||||
|
margin: 16px 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.ant-modal .ant-radio-button-wrapper {
|
.ant-modal .ant-radio-button-wrapper {
|
||||||
color: var(--text-secondary) !important;
|
color: var(--text-secondary) !important;
|
||||||
background: rgba(255, 255, 255, 0.03) !important;
|
background: rgba(255, 255, 255, 0.03) !important;
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 把「日志聚合出来的会话」对应回「账号真实会话列表」里的那一条。
|
||||||
|
*
|
||||||
|
* 这条链路决定手动发送打给谁,所以匹配必须是分级的、宁缺毋滥的:
|
||||||
|
* 抖音昵称大量重复(还有「用户1234567」「未知用户」这类占位名),
|
||||||
|
* 早先版本把 UID / 会话 ID / 昵称写在同一个 OR 谓词里交给 Array.find,
|
||||||
|
* 于是列表里靠前的一条只要昵称相同就胜出,哪怕后面有 UID 精确匹配的那条
|
||||||
|
* —— 结果就是消息发给了同名的另一个人。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const isGenericPeerName = (name, peerUid = '') => {
|
||||||
|
const value = String(name || '').trim()
|
||||||
|
if (!value) return true
|
||||||
|
if (value === '未知用户') return true
|
||||||
|
if (peerUid && value === String(peerUid)) return true
|
||||||
|
if (/^\d+$/.test(value)) return true
|
||||||
|
if (/^用户\d+$/.test(value)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isConvId = (id) => /^0:1:\d+:\d+$/.test(String(id || '').trim())
|
||||||
|
|
||||||
|
export const extractPeerUid = (conv) => {
|
||||||
|
const raw = String(conv?.sender_id || '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
if (/^\d+$/.test(raw)) return raw
|
||||||
|
if (isConvId(raw)) return raw.split(':')[3] || ''
|
||||||
|
const last = raw.split(':').pop()
|
||||||
|
return /^\d+$/.test(last || '') ? last : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话条目代表的对方 UID:sender_id 优先,其次会话 ID 末段。 */
|
||||||
|
export const conversationPeerUid = (item) =>
|
||||||
|
extractPeerUid(item) || extractPeerUid({ sender_id: item?.conversation_id })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分级匹配,命中一级就返回,绝不降级:
|
||||||
|
* 1) 对方 UID 精确相等 —— 有 UID 时只认 UID
|
||||||
|
* 2) 会话 ID 完全相等
|
||||||
|
* 3) 会话 ID 末段等于 sender_id,且全列表唯一
|
||||||
|
* 4) 昵称相等,且昵称不是占位名、且全列表唯一
|
||||||
|
* 任何一级出现多个候选都返回 null:宁可不匹配,也不能猜错人。
|
||||||
|
*/
|
||||||
|
export const matchPeerConversation = (list, conv) => {
|
||||||
|
const items = Array.isArray(list) ? list : []
|
||||||
|
if (!items.length || !conv) return null
|
||||||
|
|
||||||
|
const peerUid = extractPeerUid(conv)
|
||||||
|
if (peerUid) {
|
||||||
|
return items.find((item) => conversationPeerUid(item) === peerUid) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawId = String(conv.sender_id || '').trim()
|
||||||
|
if (rawId) {
|
||||||
|
const exact = items.find(
|
||||||
|
(item) => String(item.conversation_id || '').trim() === rawId
|
||||||
|
)
|
||||||
|
if (exact) return exact
|
||||||
|
const suffix = items.filter((item) =>
|
||||||
|
String(item.conversation_id || '').endsWith(`:${rawId}`)
|
||||||
|
)
|
||||||
|
if (suffix.length === 1) return suffix[0]
|
||||||
|
if (suffix.length > 1) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = String(conv.sender_name || '').trim()
|
||||||
|
if (!name || isGenericPeerName(name)) return null
|
||||||
|
const byName = items.filter(
|
||||||
|
(item) => String(item.sender_name || '').trim() === name
|
||||||
|
)
|
||||||
|
return byName.length === 1 ? byName[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析手动发送要用的 conversation_id;无法确认对方身份时返回空串,
|
||||||
|
* 由调用方提示用户,而不是拿一个「差不多」的会话把消息发出去。
|
||||||
|
*/
|
||||||
|
export const resolveConversationId = (list, conv) => {
|
||||||
|
if (!conv) return ''
|
||||||
|
const peerUid = extractPeerUid(conv)
|
||||||
|
const match = matchPeerConversation(list, conv)
|
||||||
|
|
||||||
|
if (match?.conversation_id) {
|
||||||
|
const matchedPeer = conversationPeerUid(match)
|
||||||
|
// 最后一道断言:匹配到的会话必须和当前会话指向同一个人。
|
||||||
|
if (!peerUid || !matchedPeer || matchedPeer === peerUid) {
|
||||||
|
return String(match.conversation_id)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = String(conv.sender_id || '').trim()
|
||||||
|
if (isConvId(raw)) return raw
|
||||||
|
// 裸 UID 是安全的:后端会用「当前账号 UID + 该 UID」拼出本账号的会话。
|
||||||
|
if (/^\d+$/.test(raw)) return raw
|
||||||
|
return ''
|
||||||
|
}
|
||||||
@@ -68,9 +68,13 @@ const accountPageSize = ref(9)
|
|||||||
const accountTotal = ref(0)
|
const accountTotal = ref(0)
|
||||||
const rules = ref([])
|
const rules = ref([])
|
||||||
const deviceProfiles = ref([])
|
const deviceProfiles = ref([])
|
||||||
|
const egressChannels = ref([])
|
||||||
|
const egressChannelsLoading = ref(false)
|
||||||
|
const egressChannelsError = ref('')
|
||||||
const CUSTOM_UA_PROFILE = '__custom__'
|
const CUSTOM_UA_PROFILE = '__custom__'
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const batchStarting = ref(false)
|
const batchStarting = ref(false)
|
||||||
|
const batchDeleting = ref(false)
|
||||||
const activeStartBatchId = ref(null)
|
const activeStartBatchId = ref(null)
|
||||||
let batchStatusTimer = null
|
let batchStatusTimer = null
|
||||||
let batchStatusRequestActive = false
|
let batchStatusRequestActive = false
|
||||||
@@ -362,6 +366,8 @@ const editForm = ref({
|
|||||||
follow_welcome_content: '',
|
follow_welcome_content: '',
|
||||||
user_agent_profile: 'chrome_win120',
|
user_agent_profile: 'chrome_win120',
|
||||||
user_agent_custom: '',
|
user_agent_custom: '',
|
||||||
|
egress_public_ip: '',
|
||||||
|
egress_auto_attempts: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const profileSelectOptions = computed(() => {
|
const profileSelectOptions = computed(() => {
|
||||||
@@ -373,6 +379,25 @@ const profileSelectOptions = computed(() => {
|
|||||||
return opts
|
return opts
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const egressChannelOptions = computed(() => {
|
||||||
|
const options = [
|
||||||
|
{ value: '', label: '自动选择(服务器默认公网出口)' }
|
||||||
|
]
|
||||||
|
for (const channel of egressChannels.value || []) {
|
||||||
|
const source = channel.source_ip ? `本地 ${channel.source_ip}` : '默认路由'
|
||||||
|
const suffix = channel.is_default ? ' · 当前默认' : ''
|
||||||
|
options.push({
|
||||||
|
value: channel.public_ip,
|
||||||
|
label: `${channel.public_ip}(${source}${suffix})`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const selected = (editForm.value.egress_public_ip || '').trim()
|
||||||
|
if (selected && !options.some((item) => item.value === selected)) {
|
||||||
|
options.push({ value: selected, label: `${selected}(当前未检测到)`, disabled: true })
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
const accountQuota = computed(() => {
|
const accountQuota = computed(() => {
|
||||||
const user = auth.user
|
const user = auth.user
|
||||||
const count = accountTotal.value
|
const count = accountTotal.value
|
||||||
@@ -395,7 +420,12 @@ const accountQuotaLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const canPurchaseSlots = computed(() => {
|
const canPurchaseSlots = computed(() => {
|
||||||
return paymentConfig.value?.payment_enabled && accountQuota.value.limited && !canAddAccount.value
|
return (
|
||||||
|
auth.canCreateOrders &&
|
||||||
|
paymentConfig.value?.payment_enabled &&
|
||||||
|
accountQuota.value.limited &&
|
||||||
|
!canAddAccount.value
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const startableAccounts = computed(() =>
|
const startableAccounts = computed(() =>
|
||||||
@@ -441,6 +471,18 @@ const selectedStartableCount = computed(() =>
|
|||||||
startableAccounts.value.filter((a) => selectedIds.value.includes(a.id)).length
|
startableAccounts.value.filter((a) => selectedIds.value.includes(a.id)).length
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const selectableAccounts = computed(() => {
|
||||||
|
if (auth.canDeleteAccounts) return accounts.value
|
||||||
|
if (auth.canStartAccounts) return startableAccounts.value
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedAccounts = computed(() =>
|
||||||
|
selectableAccounts.value.filter((a) => selectedIds.value.includes(a.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedAccountCount = computed(() => selectedAccounts.value.length)
|
||||||
|
|
||||||
const isAccountSelected = (id) => selectedIds.value.includes(id)
|
const isAccountSelected = (id) => selectedIds.value.includes(id)
|
||||||
|
|
||||||
const toggleAccountSelect = (id) => {
|
const toggleAccountSelect = (id) => {
|
||||||
@@ -451,8 +493,8 @@ const toggleAccountSelect = (id) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAllStartable = () => {
|
const selectAllAccounts = () => {
|
||||||
selectedIds.value = startableAccounts.value.map((a) => a.id)
|
selectedIds.value = selectableAccounts.value.map((a) => a.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
@@ -460,11 +502,11 @@ const clearSelection = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSelectAllChange = (e) => {
|
const onSelectAllChange = (e) => {
|
||||||
if (e.target.checked) selectAllStartable()
|
if (e.target.checked) selectAllAccounts()
|
||||||
else clearSelection()
|
else clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showMyOrdersEntry = computed(() => !auth.isAdmin)
|
const showMyOrdersEntry = computed(() => auth.hasPermission('menu.payment_orders'))
|
||||||
|
|
||||||
const goMyOrders = () => {
|
const goMyOrders = () => {
|
||||||
router.push('/payment-orders')
|
router.push('/payment-orders')
|
||||||
@@ -542,6 +584,26 @@ const fetchDeviceProfiles = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchEgressChannels = async (refresh = false) => {
|
||||||
|
if (!auth.canUpdateAccounts || egressChannelsLoading.value) return
|
||||||
|
egressChannelsLoading.value = true
|
||||||
|
egressChannelsError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await api.get('/network/egress-channels', {
|
||||||
|
params: { refresh },
|
||||||
|
timeout: 20000
|
||||||
|
})
|
||||||
|
egressChannels.value = res.data?.channels || []
|
||||||
|
if (!egressChannels.value.length) {
|
||||||
|
egressChannelsError.value = '未探测到可用公网出口,将继续使用服务器默认路由'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
egressChannelsError.value = error.response?.data?.detail || '公网通道检测失败'
|
||||||
|
} finally {
|
||||||
|
egressChannelsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchAccounts = async () => {
|
const fetchAccounts = async () => {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -555,6 +617,8 @@ const fetchAccounts = async () => {
|
|||||||
})
|
})
|
||||||
accounts.value = res.data.items || []
|
accounts.value = res.data.items || []
|
||||||
accountTotal.value = res.data.total || 0
|
accountTotal.value = res.data.total || 0
|
||||||
|
const visibleIds = new Set(selectableAccounts.value.map((a) => a.id))
|
||||||
|
selectedIds.value = selectedIds.value.filter((id) => visibleIds.has(id))
|
||||||
// 删除/筛选后当前页可能超界,自动回退到最后一页
|
// 删除/筛选后当前页可能超界,自动回退到最后一页
|
||||||
const maxPage = Math.max(1, Math.ceil(accountTotal.value / accountPageSize.value) || 1)
|
const maxPage = Math.max(1, Math.ceil(accountTotal.value / accountPageSize.value) || 1)
|
||||||
if (accountPage.value > maxPage) {
|
if (accountPage.value > maxPage) {
|
||||||
@@ -857,7 +921,7 @@ const queueActionLabel = (item) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isQueueSendDisabled = (item) => {
|
const isQueueSendDisabled = (item) => {
|
||||||
if (auth.user?.role === 'viewer') return true
|
if (!auth.canWriteMessages) return true
|
||||||
if (!queueSnapshot.value.running) return true
|
if (!queueSnapshot.value.running) return true
|
||||||
if (queueSendingJobId.value) return true
|
if (queueSendingJobId.value) return true
|
||||||
return item?.status !== 'waiting' || !!item?.expedited
|
return item?.status !== 'waiting' || !!item?.expedited
|
||||||
@@ -1000,8 +1064,8 @@ const goAccountRulesPage = (accountId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
if (!auth.canWrite) {
|
if (!auth.canCreateAccounts) {
|
||||||
message.warning('当前账号为只读角色,不能添加托管账号')
|
message.warning('当前账号无添加账号权限')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!canAddAccount.value) {
|
if (!canAddAccount.value) {
|
||||||
@@ -1058,10 +1122,74 @@ const handleAddAccount = async () => {
|
|||||||
const handleDeleteAccount = async (id) => {
|
const handleDeleteAccount = async (id) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/accounts/${id}`)
|
await api.delete(`/accounts/${id}`)
|
||||||
|
selectedIds.value = selectedIds.value.filter((item) => item !== id)
|
||||||
message.success('删除成功')
|
message.success('删除成功')
|
||||||
fetchAccounts()
|
await Promise.all([fetchAccounts(), auth.fetchMe()])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('删除账号失败')
|
message.error(error.response?.data?.detail || '删除账号失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchDeleteAccounts = async () => {
|
||||||
|
if (batchDeleting.value || batchStarting.value) return
|
||||||
|
const targets = selectedAccounts.value.map((account) => account.id)
|
||||||
|
if (!targets.length) {
|
||||||
|
message.warning('请先勾选要删除的账号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
batchDeleting.value = true
|
||||||
|
const deletedIds = []
|
||||||
|
const failures = []
|
||||||
|
message.loading({
|
||||||
|
content: `正在删除 0/${targets.length} 个账号...`,
|
||||||
|
key: 'batch_delete',
|
||||||
|
duration: 0
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
// SQLite 下并发执行多个删除事务容易互相抢锁,逐个删除更稳定。
|
||||||
|
for (let index = 0; index < targets.length; index += 1) {
|
||||||
|
const id = targets[index]
|
||||||
|
try {
|
||||||
|
await api.delete(`/accounts/${id}`)
|
||||||
|
deletedIds.push(id)
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({
|
||||||
|
id,
|
||||||
|
reason: error.response?.data?.detail || error.message || '删除失败'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
message.loading({
|
||||||
|
content: `正在删除 ${index + 1}/${targets.length} 个账号...`,
|
||||||
|
key: 'batch_delete',
|
||||||
|
duration: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedSet = new Set(deletedIds)
|
||||||
|
selectedIds.value = selectedIds.value.filter((id) => !deletedSet.has(id))
|
||||||
|
await Promise.all([fetchAccounts(), auth.fetchMe()])
|
||||||
|
|
||||||
|
if (!failures.length) {
|
||||||
|
message.success({
|
||||||
|
content: `已删除 ${deletedIds.length} 个账号`,
|
||||||
|
key: 'batch_delete'
|
||||||
|
})
|
||||||
|
} else if (deletedIds.length) {
|
||||||
|
message.warning({
|
||||||
|
content: `已删除 ${deletedIds.length} 个账号,${failures.length} 个失败,可重新勾选后重试`,
|
||||||
|
key: 'batch_delete',
|
||||||
|
duration: 6
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
message.error({
|
||||||
|
content: failures[0]?.reason || '批量删除失败',
|
||||||
|
key: 'batch_delete',
|
||||||
|
duration: 6
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
batchDeleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1295,7 +1423,7 @@ const pollBatchStartStatus = (batchId, initialSnapshot = null) => {
|
|||||||
|
|
||||||
// 批量启动只提交一个请求;凭证校验和启动由后端小并发队列处理。
|
// 批量启动只提交一个请求;凭证校验和启动由后端小并发队列处理。
|
||||||
const runBatchStart = async ({ accountIds = [], allAccounts = false }) => {
|
const runBatchStart = async ({ accountIds = [], allAccounts = false }) => {
|
||||||
if (batchStarting.value) return
|
if (batchStarting.value || batchDeleting.value) return
|
||||||
batchStarting.value = true
|
batchStarting.value = true
|
||||||
stopReplyQueueSummaryPolling()
|
stopReplyQueueSummaryPolling()
|
||||||
stopBatchStatusPolling()
|
stopBatchStatusPolling()
|
||||||
@@ -1331,7 +1459,7 @@ const runBatchStart = async ({ accountIds = [], allAccounts = false }) => {
|
|||||||
const accountLabel = (acc) => acc?.username || `账号 #${acc?.id}`
|
const accountLabel = (acc) => acc?.username || `账号 #${acc?.id}`
|
||||||
|
|
||||||
const batchStartRpa = async () => {
|
const batchStartRpa = async () => {
|
||||||
if (batchStarting.value || startingAll.value) return
|
if (batchStarting.value || batchDeleting.value || startingAll.value) return
|
||||||
const targets = startableAccounts.value
|
const targets = startableAccounts.value
|
||||||
.filter((a) => selectedIds.value.includes(a.id))
|
.filter((a) => selectedIds.value.includes(a.id))
|
||||||
.map((a) => ({ id: a.id, label: accountLabel(a) }))
|
.map((a) => ({ id: a.id, label: accountLabel(a) }))
|
||||||
@@ -1346,7 +1474,7 @@ const batchStartRpa = async () => {
|
|||||||
const startingAll = ref(false)
|
const startingAll = ref(false)
|
||||||
|
|
||||||
const startAllRpa = async () => {
|
const startAllRpa = async () => {
|
||||||
if (batchStarting.value || startingAll.value) return
|
if (batchStarting.value || batchDeleting.value || startingAll.value) return
|
||||||
startingAll.value = true
|
startingAll.value = true
|
||||||
try {
|
try {
|
||||||
await runBatchStart({ allAccounts: true })
|
await runBatchStart({ allAccounts: true })
|
||||||
@@ -1596,14 +1724,21 @@ const openEditModal = async (acc) => {
|
|||||||
follow_welcome_content: acc.follow_welcome_content || '',
|
follow_welcome_content: acc.follow_welcome_content || '',
|
||||||
user_agent_profile: 'chrome_win120',
|
user_agent_profile: 'chrome_win120',
|
||||||
user_agent_custom: '',
|
user_agent_custom: '',
|
||||||
|
egress_public_ip: acc.egress_public_ip || '',
|
||||||
|
egress_auto_attempts: Math.max(1, Number(acc.egress_auto_attempts) || 1),
|
||||||
}
|
}
|
||||||
initUserAgentFields(acc)
|
initUserAgentFields(acc)
|
||||||
|
if (auth.canUpdateAccounts) {
|
||||||
|
fetchEgressChannels(false)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/accounts/${acc.id}/cookie?purpose=management`)
|
if (auth.canManageCookies) {
|
||||||
editForm.value.cookie_data = res.data.cookie_data
|
const res = await api.get(`/accounts/${acc.id}/cookie?purpose=management`)
|
||||||
? JSON.stringify(JSON.parse(res.data.cookie_data), null, 2)
|
editForm.value.cookie_data = res.data.cookie_data
|
||||||
: ''
|
? JSON.stringify(JSON.parse(res.data.cookie_data), null, 2)
|
||||||
applyCookieResponse(res.data)
|
: ''
|
||||||
|
applyCookieResponse(res.data)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('加载 Cookie 失败')
|
message.error('加载 Cookie 失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1683,8 +1818,10 @@ const saveAccountInfo = async () => {
|
|||||||
follow_welcome_enabled: !!editForm.value.follow_welcome_enabled,
|
follow_welcome_enabled: !!editForm.value.follow_welcome_enabled,
|
||||||
follow_welcome_content: (editForm.value.follow_welcome_content || '').trim() || null,
|
follow_welcome_content: (editForm.value.follow_welcome_content || '').trim() || null,
|
||||||
user_agent: resolveUserAgentToSave() || null,
|
user_agent: resolveUserAgentToSave() || null,
|
||||||
|
egress_public_ip: (editForm.value.egress_public_ip || '').trim() || null,
|
||||||
|
egress_auto_attempts: Math.max(1, Math.min(8, Number(editForm.value.egress_auto_attempts) || 1)),
|
||||||
})
|
})
|
||||||
message.success('账号信息已保存(设备头将在下次启动托管时生效)')
|
message.success('账号信息已保存(公网发送通道立即生效,设备头下次启动生效)')
|
||||||
fetchAccounts()
|
fetchAccounts()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('保存账号信息失败')
|
message.error('保存账号信息失败')
|
||||||
@@ -1798,40 +1935,70 @@ onUnmounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<div v-if="startableAccounts.length > 0" class="batch-toolbar">
|
<div
|
||||||
|
v-if="selectableAccounts.length > 0 && (auth.canStartAccounts || auth.canDeleteAccounts)"
|
||||||
|
class="batch-toolbar"
|
||||||
|
>
|
||||||
<a-checkbox
|
<a-checkbox
|
||||||
:indeterminate="selectedStartableCount > 0 && selectedStartableCount < startableAccounts.length"
|
:indeterminate="selectedAccountCount > 0 && selectedAccountCount < selectableAccounts.length"
|
||||||
:checked="startableAccounts.length > 0 && selectedStartableCount === startableAccounts.length"
|
:checked="selectableAccounts.length > 0 && selectedAccountCount === selectableAccounts.length"
|
||||||
|
:disabled="batchStarting || batchDeleting"
|
||||||
@change="onSelectAllChange"
|
@change="onSelectAllChange"
|
||||||
>
|
>
|
||||||
全选可启动 ({{ startableAccounts.length }})
|
{{ auth.canDeleteAccounts ? '全选当前页' : '全选可启动' }} ({{ selectableAccounts.length }})
|
||||||
</a-checkbox>
|
</a-checkbox>
|
||||||
<a-button
|
<a-button
|
||||||
|
v-if="auth.canStartAccounts && startableAccounts.length > 0"
|
||||||
type="primary"
|
type="primary"
|
||||||
ghost
|
ghost
|
||||||
class="batch-start-btn"
|
class="batch-start-btn"
|
||||||
:disabled="selectedStartableCount === 0"
|
:disabled="selectedStartableCount === 0 || batchDeleting"
|
||||||
:loading="batchStarting"
|
:loading="batchStarting"
|
||||||
@click="batchStartRpa"
|
@click="batchStartRpa"
|
||||||
>
|
>
|
||||||
<template #icon><PlayCircleOutlined /></template>
|
<template #icon><PlayCircleOutlined /></template>
|
||||||
批量启动{{ selectedStartableCount ? ` (${selectedStartableCount})` : '' }}
|
批量启动{{ selectedStartableCount ? ` (${selectedStartableCount})` : '' }}
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button v-if="selectedStartableCount > 0" class="batch-clear-btn" @click="clearSelection">
|
<a-popconfirm
|
||||||
|
v-if="auth.canDeleteAccounts"
|
||||||
|
:title="`确认删除选中的 ${selectedAccountCount} 个账号?运行中的托管会先停止,关联的自动回复规则和消息日志也会被清除。`"
|
||||||
|
ok-text="确认删除"
|
||||||
|
cancel-text="取消"
|
||||||
|
placement="bottomRight"
|
||||||
|
@confirm="handleBatchDeleteAccounts"
|
||||||
|
>
|
||||||
|
<a-button
|
||||||
|
danger
|
||||||
|
class="batch-delete-btn"
|
||||||
|
:disabled="selectedAccountCount === 0 || batchStarting"
|
||||||
|
:loading="batchDeleting"
|
||||||
|
>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
批量删除{{ selectedAccountCount ? ` (${selectedAccountCount})` : '' }}
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
<a-button
|
||||||
|
v-if="selectedAccountCount > 0"
|
||||||
|
class="batch-clear-btn"
|
||||||
|
:disabled="batchStarting || batchDeleting"
|
||||||
|
@click="clearSelection"
|
||||||
|
>
|
||||||
取消选择
|
取消选择
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
<a-space wrap>
|
<a-space wrap>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
|
v-if="auth.canStartAccounts"
|
||||||
title="将启动所有未启动的账号(含其他分页),确认继续?"
|
title="将启动所有未启动的账号(含其他分页),确认继续?"
|
||||||
ok-text="全部启动"
|
ok-text="全部启动"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
@confirm="startAllRpa"
|
@confirm="startAllRpa"
|
||||||
>
|
>
|
||||||
<a-button
|
<a-button
|
||||||
type="primary"
|
type="primary"
|
||||||
ghost
|
ghost
|
||||||
:loading="startingAll || batchStarting"
|
:loading="startingAll || batchStarting"
|
||||||
|
:disabled="batchDeleting"
|
||||||
>
|
>
|
||||||
<template #icon><ThunderboltOutlined /></template>
|
<template #icon><ThunderboltOutlined /></template>
|
||||||
一键启动全部
|
一键启动全部
|
||||||
@@ -1857,7 +2024,7 @@ onUnmounted(() => {
|
|||||||
购买额度
|
购买额度
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="auth.canWrite"
|
v-if="auth.canCreateAccounts"
|
||||||
type="primary"
|
type="primary"
|
||||||
class="gradient-btn"
|
class="gradient-btn"
|
||||||
:disabled="!canAddAccount && !canPurchaseSlots"
|
:disabled="!canAddAccount && !canPurchaseSlots"
|
||||||
@@ -1925,9 +2092,10 @@ onUnmounted(() => {
|
|||||||
:class="{ 'account-card-selected': isAccountSelected(acc.id) }"
|
:class="{ 'account-card-selected': isAccountSelected(acc.id) }"
|
||||||
>
|
>
|
||||||
<a-checkbox
|
<a-checkbox
|
||||||
v-if="!acc.quota_disabled && (acc.status === 'offline' || acc.status === 'error')"
|
v-if="auth.canDeleteAccounts || (auth.canStartAccounts && !acc.quota_disabled && (acc.status === 'offline' || acc.status === 'error'))"
|
||||||
class="account-select-checkbox"
|
class="account-select-checkbox"
|
||||||
:checked="isAccountSelected(acc.id)"
|
:checked="isAccountSelected(acc.id)"
|
||||||
|
:disabled="batchStarting || batchDeleting"
|
||||||
@change="toggleAccountSelect(acc.id)"
|
@change="toggleAccountSelect(acc.id)"
|
||||||
/>
|
/>
|
||||||
<!-- 账号顶部信息 -->
|
<!-- 账号顶部信息 -->
|
||||||
@@ -2050,7 +2218,13 @@ onUnmounted(() => {
|
|||||||
<template #icon><MessageOutlined /></template>
|
<template #icon><MessageOutlined /></template>
|
||||||
自动回复
|
自动回复
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="text" size="small" class="action-edit-btn" @click="openEditModal(acc)">
|
<a-button
|
||||||
|
v-if="auth.canUpdateAccounts"
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
class="action-edit-btn"
|
||||||
|
@click="openEditModal(acc)"
|
||||||
|
>
|
||||||
<template #icon><EditOutlined /></template>
|
<template #icon><EditOutlined /></template>
|
||||||
编辑
|
编辑
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -2061,7 +2235,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="account-actions-primary">
|
<div class="account-actions-primary">
|
||||||
<a-button
|
<a-button
|
||||||
v-if="!acc.quota_disabled && (acc.status === 'offline' || acc.status === 'error' || acc.status === 'starting')"
|
v-if="auth.canStartAccounts && !acc.quota_disabled && (acc.status === 'offline' || acc.status === 'error' || acc.status === 'starting')"
|
||||||
type="primary"
|
type="primary"
|
||||||
ghost
|
ghost
|
||||||
size="small"
|
size="small"
|
||||||
@@ -2073,7 +2247,7 @@ onUnmounted(() => {
|
|||||||
启动托管
|
启动托管
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="!acc.quota_disabled && acc.status === 'logging_in'"
|
v-if="auth.canStartAccounts && !acc.quota_disabled && acc.status === 'logging_in'"
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
class="action-btn-warn"
|
class="action-btn-warn"
|
||||||
@@ -2083,7 +2257,7 @@ onUnmounted(() => {
|
|||||||
扫码登录
|
扫码登录
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="!acc.quota_disabled && acc.status === 'online'"
|
v-if="auth.canStopAccounts && !acc.quota_disabled && acc.status === 'online'"
|
||||||
danger
|
danger
|
||||||
ghost
|
ghost
|
||||||
size="small"
|
size="small"
|
||||||
@@ -2093,12 +2267,19 @@ onUnmounted(() => {
|
|||||||
停止托管
|
停止托管
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
|
v-if="auth.canDeleteAccounts"
|
||||||
title="确认删除该账号?删除后其所有的自动回复规则和消息日志也将被清除。"
|
title="确认删除该账号?删除后其所有的自动回复规则和消息日志也将被清除。"
|
||||||
ok-text="确认"
|
ok-text="确认"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
@confirm="handleDeleteAccount(acc.id)"
|
@confirm="handleDeleteAccount(acc.id)"
|
||||||
>
|
>
|
||||||
<a-button type="text" danger size="small" class="action-delete-btn">
|
<a-button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
class="action-delete-btn"
|
||||||
|
:disabled="batchDeleting"
|
||||||
|
>
|
||||||
<template #icon><DeleteOutlined /></template>
|
<template #icon><DeleteOutlined /></template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-popconfirm>
|
</a-popconfirm>
|
||||||
@@ -2113,7 +2294,7 @@ onUnmounted(() => {
|
|||||||
<UserOutlined style="font-size: 4rem; color: var(--text-muted); margin-bottom: 16px;" />
|
<UserOutlined style="font-size: 4rem; color: var(--text-muted); margin-bottom: 16px;" />
|
||||||
<h3>暂无托管账号</h3>
|
<h3>暂无托管账号</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 20px;">添加一个抖音账号,开始自动化回复工作吧</p>
|
<p style="color: var(--text-secondary); margin-bottom: 20px;">添加一个抖音账号,开始自动化回复工作吧</p>
|
||||||
<a-button v-if="auth.canWrite" type="primary" class="gradient-btn" @click="openAddModal">
|
<a-button v-if="auth.canCreateAccounts" type="primary" class="gradient-btn" @click="openAddModal">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
立即添加
|
立即添加
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -2168,7 +2349,10 @@ onUnmounted(() => {
|
|||||||
<a-form-item label="手机号 (可选,方便记录备注)">
|
<a-form-item label="手机号 (可选,方便记录备注)">
|
||||||
<a-input v-model:value="addForm.phone" placeholder="请输入绑定的手机号码" />
|
<a-input v-model:value="addForm.phone" placeholder="请输入绑定的手机号码" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Cookie 数据 (可选,可直接导入登录态)">
|
<a-form-item
|
||||||
|
v-if="auth.canManageCookies"
|
||||||
|
label="Cookie 数据 (可选,可直接导入登录态)"
|
||||||
|
>
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-model:value="addForm.cookie_data"
|
v-model:value="addForm.cookie_data"
|
||||||
:rows="10"
|
:rows="10"
|
||||||
@@ -2354,6 +2538,53 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-form-item label="公网发送通道">
|
||||||
|
<div class="egress-channel-row">
|
||||||
|
<a-select
|
||||||
|
v-model:value="editForm.egress_public_ip"
|
||||||
|
:options="egressChannelOptions"
|
||||||
|
:loading="egressChannelsLoading"
|
||||||
|
placeholder="自动选择服务器默认公网出口"
|
||||||
|
style="flex: 1; min-width: 0;"
|
||||||
|
/>
|
||||||
|
<a-button
|
||||||
|
:loading="egressChannelsLoading"
|
||||||
|
@click="fetchEgressChannels(true)"
|
||||||
|
>
|
||||||
|
重新检测
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint">
|
||||||
|
<template v-if="egressChannels.length > 1">
|
||||||
|
已检测到 {{ egressChannels.length }} 个不同公网 IP。固定选择后,该账号的 IM 请求将绑定到对应本地网卡地址。
|
||||||
|
</template>
|
||||||
|
<template v-else-if="egressChannels.length === 1">
|
||||||
|
当前仅检测到一个公网出口 {{ egressChannels[0].public_ip }};仍可提前保存自动切换次数,增加出口后重新检测即可。
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
系统会自动检测服务器网卡与公网 IP 的映射;未检测到时保持默认路由。
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="egressChannelsError" class="egress-channel-error">
|
||||||
|
{{ egressChannelsError }}
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="12">
|
||||||
|
<a-form-item label="发送最多尝试通道数 N">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="editForm.egress_auto_attempts"
|
||||||
|
:min="1"
|
||||||
|
:max="8"
|
||||||
|
:precision="0"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
<div class="field-hint">
|
||||||
|
包含首选通道。只有明确收到通道/安全校验失败时才按顺序切换;超时等结果不确定的请求不会重发,避免重复消息。
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<a-form-item label="伪装设备头(User-Agent)">
|
<a-form-item label="伪装设备头(User-Agent)">
|
||||||
<a-select
|
<a-select
|
||||||
@@ -2381,7 +2612,10 @@ onUnmounted(() => {
|
|||||||
启动托管时,浏览器登录与 IM 发送将使用所选设备头;须与 a_bogus 签名一致,修改后请重新启动托管。
|
启动托管时,浏览器登录与 IM 发送将使用所选设备头;须与 a_bogus 签名一致,修改后请重新启动托管。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a-form-item label="Cookie 数据(Playwright storage_state JSON)">
|
<a-form-item
|
||||||
|
v-if="auth.canManageCookies"
|
||||||
|
label="Cookie 数据(Playwright storage_state JSON)"
|
||||||
|
>
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-model:value="editForm.cookie_data"
|
v-model:value="editForm.cookie_data"
|
||||||
:rows="isMobile ? 8 : 12"
|
:rows="isMobile ? 8 : 12"
|
||||||
@@ -2392,6 +2626,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div class="edit-modal-footer">
|
<div class="edit-modal-footer">
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
|
v-if="auth.canManageCookies"
|
||||||
title="确认清除该账号的 Cookie?清除后需重新扫码登录。"
|
title="确认清除该账号的 Cookie?清除后需重新扫码登录。"
|
||||||
ok-text="确认"
|
ok-text="确认"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
@@ -2399,8 +2634,20 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
<a-button danger :loading="editSaving">清除 Cookie</a-button>
|
<a-button danger :loading="editSaving">清除 Cookie</a-button>
|
||||||
</a-popconfirm>
|
</a-popconfirm>
|
||||||
<a-button :loading="editSaving" @click="saveAccountInfo">保存账号信息</a-button>
|
<a-button
|
||||||
<a-button type="primary" class="gradient-btn" :loading="editSaving" @click="saveCookie">
|
v-if="auth.canUpdateAccounts"
|
||||||
|
:loading="editSaving"
|
||||||
|
@click="saveAccountInfo"
|
||||||
|
>
|
||||||
|
保存账号信息
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="auth.canManageCookies"
|
||||||
|
type="primary"
|
||||||
|
class="gradient-btn"
|
||||||
|
:loading="editSaving"
|
||||||
|
@click="saveCookie"
|
||||||
|
>
|
||||||
保存 Cookie
|
保存 Cookie
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2439,17 +2686,30 @@ onUnmounted(() => {
|
|||||||
关闭后该账号不再发送兜底回复,状态与策略中心的「是否启用」开关同步。
|
关闭后该账号不再发送兜底回复,状态与策略中心的「是否启用」开关同步。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<a-switch v-model:checked="accountDefaultRuleForm.is_active" />
|
<a-switch
|
||||||
|
v-model:checked="accountDefaultRuleForm.is_active"
|
||||||
|
:disabled="!auth.canWriteRules"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReplyRuleEditor v-model:replies="accountDefaultRuleForm.replies" />
|
<ReplyRuleEditor
|
||||||
|
v-model:replies="accountDefaultRuleForm.replies"
|
||||||
|
:readonly="!auth.canWriteRules"
|
||||||
|
:can-upload-cards="auth.canWriteLinkCards"
|
||||||
|
/>
|
||||||
|
|
||||||
<a-space style="width: 100%; justify-content: flex-end; margin-top: 8px;">
|
<a-space style="width: 100%; justify-content: flex-end; margin-top: 8px;">
|
||||||
<a-button @click="goAccountRulesPage(rulesModalAccount.id)">
|
<a-button @click="goAccountRulesPage(rulesModalAccount.id)">
|
||||||
<template #icon><SettingOutlined /></template>
|
<template #icon><SettingOutlined /></template>
|
||||||
管理全部规则
|
管理全部规则
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="primary" class="gradient-btn" :loading="rulesModalSaving" @click="saveAccountDefaultRule">
|
<a-button
|
||||||
|
v-if="auth.canWriteRules"
|
||||||
|
type="primary"
|
||||||
|
class="gradient-btn"
|
||||||
|
:loading="rulesModalSaving"
|
||||||
|
@click="saveAccountDefaultRule"
|
||||||
|
>
|
||||||
保存兜底回复
|
保存兜底回复
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
@@ -2491,10 +2751,10 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-alert
|
<a-alert
|
||||||
v-if="auth.user?.role === 'viewer'"
|
v-if="!auth.canWriteMessages"
|
||||||
type="info"
|
type="info"
|
||||||
show-icon
|
show-icon
|
||||||
message="当前账号为只读权限,可查看队列详情,但不能执行立即发送。"
|
message="当前账号无消息发送权限,可查看队列详情,但不能执行立即发送。"
|
||||||
class="reply-queue-alert reply-queue-readonly-alert"
|
class="reply-queue-alert reply-queue-readonly-alert"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -3075,6 +3335,24 @@ onUnmounted(() => {
|
|||||||
background: rgba(255, 255, 255, 0.02) !important;
|
background: rgba(255, 255, 255, 0.02) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-toolbar :deep(.batch-delete-btn.ant-btn-dangerous) {
|
||||||
|
color: #fca5a5 !important;
|
||||||
|
border-color: rgba(248, 113, 113, 0.45) !important;
|
||||||
|
background: rgba(239, 68, 68, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-toolbar :deep(.batch-delete-btn.ant-btn-dangerous:not(:disabled):hover) {
|
||||||
|
color: #fecaca !important;
|
||||||
|
border-color: rgba(252, 165, 165, 0.75) !important;
|
||||||
|
background: rgba(239, 68, 68, 0.16) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-toolbar :deep(.batch-delete-btn.ant-btn-dangerous:disabled) {
|
||||||
|
color: rgba(203, 213, 225, 0.45) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.02) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.batch-toolbar :deep(.batch-clear-btn.ant-btn-default) {
|
.batch-toolbar :deep(.batch-clear-btn.ant-btn-default) {
|
||||||
color: #cbd5e1 !important;
|
color: #cbd5e1 !important;
|
||||||
border-color: rgba(255, 255, 255, 0.16) !important;
|
border-color: rgba(255, 255, 255, 0.16) !important;
|
||||||
@@ -4317,6 +4595,18 @@ onUnmounted(() => {
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.egress-channel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.egress-channel-error {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
.im-credential-grid {
|
.im-credential-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -98,20 +98,20 @@ onUnmounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="banner-side">
|
<div class="banner-side">
|
||||||
<div v-if="auth.canWrite" class="banner-actions">
|
<div v-if="auth.hasPermission('menu.accounts')" class="banner-actions">
|
||||||
<router-link to="/accounts">
|
<router-link to="/accounts">
|
||||||
<a-button size="large">
|
<a-button size="large">
|
||||||
<template #icon><UserOutlined /></template>
|
<template #icon><UserOutlined /></template>
|
||||||
{{ auth.isAdmin ? '账号管理' : `我的账号(${stats.myAccounts})` }}
|
{{ auth.hasGlobalDataScope ? '账号管理' : `我的账号(${stats.myAccounts})` }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="auth.canWrite"
|
v-if="auth.canCreateAccounts"
|
||||||
:to="{ path: '/accounts', query: { action: 'add' } }"
|
:to="{ path: '/accounts', query: { action: 'add' } }"
|
||||||
>
|
>
|
||||||
<a-button type="primary" size="large" class="gradient-btn">
|
<a-button type="primary" size="large" class="gradient-btn">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
{{ auth.isAdmin ? '添加账号' : '添加自己的账号' }}
|
{{ auth.hasGlobalDataScope ? '添加账号' : '添加自己的账号' }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,8 +231,8 @@ onUnmounted(() => {
|
|||||||
<div class="quick-actions-grid" style="margin-top: 20px;">
|
<div class="quick-actions-grid" style="margin-top: 20px;">
|
||||||
<router-link to="/accounts" class="quick-action-card">
|
<router-link to="/accounts" class="quick-action-card">
|
||||||
<UserOutlined class="action-icon text-gradient" />
|
<UserOutlined class="action-icon text-gradient" />
|
||||||
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
|
<span>{{ auth.hasGlobalDataScope ? '账号管理' : '我的账号' }}</span>
|
||||||
<p v-if="auth.isAdmin">管理全平台账号配置与运行状态</p>
|
<p v-if="auth.hasGlobalDataScope">管理全平台账号配置与运行状态</p>
|
||||||
<p v-else>仅查看和管理自己添加的账号,当前 {{ stats.myAccounts }} 个</p>
|
<p v-else>仅查看和管理自己添加的账号,当前 {{ stats.myAccounts }} 个</p>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import {
|
|||||||
buildStickerPayload,
|
buildStickerPayload,
|
||||||
parseMessageContent
|
parseMessageContent
|
||||||
} from '../utils/messageContent'
|
} from '../utils/messageContent'
|
||||||
|
import {
|
||||||
|
isGenericPeerName,
|
||||||
|
extractPeerUid,
|
||||||
|
matchPeerConversation,
|
||||||
|
resolveConversationId as resolvePeerConversationId
|
||||||
|
} from '../utils/peerMatch'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const logs = ref([])
|
const logs = ref([])
|
||||||
@@ -241,26 +247,6 @@ const getAccountName = (accountId) => {
|
|||||||
const getAccount = (accountId) =>
|
const getAccount = (accountId) =>
|
||||||
accounts.value.find((a) => Number(a.id) === Number(accountId)) || null
|
accounts.value.find((a) => Number(a.id) === Number(accountId)) || null
|
||||||
|
|
||||||
const isGenericPeerName = (name, peerUid = '') => {
|
|
||||||
const value = (name || '').trim()
|
|
||||||
if (!value) return true
|
|
||||||
if (peerUid && value === peerUid) return true
|
|
||||||
if (/^\d+$/.test(value)) return true
|
|
||||||
if (/^用户\d+$/.test(value)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractPeerUid = (conv) => {
|
|
||||||
const raw = String(conv?.sender_id || '').trim()
|
|
||||||
if (!raw) return ''
|
|
||||||
if (/^\d+$/.test(raw)) return raw
|
|
||||||
if (/^0:1:\d+:\d+$/.test(raw)) {
|
|
||||||
return raw.split(':')[3] || ''
|
|
||||||
}
|
|
||||||
const last = raw.split(':').pop()
|
|
||||||
return /^\d+$/.test(last || '') ? last : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const peerUidFromLog = (log) => {
|
const peerUidFromLog = (log) => {
|
||||||
const uid = extractPeerUid({ sender_id: log.sender_id })
|
const uid = extractPeerUid({ sender_id: log.sender_id })
|
||||||
if (uid) return uid
|
if (uid) return uid
|
||||||
@@ -272,11 +258,18 @@ const peerUidFromLog = (log) => {
|
|||||||
if (fromConv) return fromConv
|
if (fromConv) return fromConv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (log.sender_name && log.sender_name !== '[系统发送]') {
|
// 昵称回退只在「非占位名 + 全列表唯一」时可信:抖音重名很多,
|
||||||
const byName = list.find(
|
// 猜错会把两个人的消息并进同一个会话,随后手动发送就发错人。
|
||||||
(c) => c.sender_name === log.sender_name && extractPeerUid(c)
|
if (
|
||||||
|
log.sender_name
|
||||||
|
&& log.sender_name !== '[系统发送]'
|
||||||
|
&& !isGenericPeerName(log.sender_name)
|
||||||
|
) {
|
||||||
|
const byName = list.filter(
|
||||||
|
(c) => String(c.sender_name || '').trim() === String(log.sender_name).trim()
|
||||||
|
&& extractPeerUid(c)
|
||||||
)
|
)
|
||||||
if (byName) return extractPeerUid(byName)
|
if (byName.length === 1) return extractPeerUid(byName[0])
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -310,17 +303,8 @@ const convKey = (log, peerUid = undefined) => {
|
|||||||
return `${log.account_id}::name:${log.sender_name || 'unknown'}`
|
return `${log.account_id}::name:${log.sender_name || 'unknown'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const findPeerMeta = (conv) => {
|
const findPeerMeta = (conv) =>
|
||||||
const list = convListCache.value[conv.account_id] || []
|
matchPeerConversation(convListCache.value[conv.account_id] || [], conv)
|
||||||
const peerUid = extractPeerUid(conv)
|
|
||||||
return list.find(
|
|
||||||
(c) =>
|
|
||||||
c.sender_name === conv.sender_name ||
|
|
||||||
c.conversation_id === conv.sender_id ||
|
|
||||||
(peerUid && (c.sender_id === peerUid || extractPeerUid(c) === peerUid)) ||
|
|
||||||
(conv.sender_id && c.conversation_id?.endsWith(`:${conv.sender_id}`))
|
|
||||||
) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const enrichConversation = (conv) => {
|
const enrichConversation = (conv) => {
|
||||||
const peer = findPeerMeta(conv)
|
const peer = findPeerMeta(conv)
|
||||||
@@ -333,8 +317,6 @@ const enrichConversation = (conv) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isConvId = (id) => /^0:1:\d+:\d+$/.test(String(id || '').trim())
|
|
||||||
|
|
||||||
const fetchConvList = async (accountId) => {
|
const fetchConvList = async (accountId) => {
|
||||||
if (!accountId) return []
|
if (!accountId) return []
|
||||||
if (convListCache.value[accountId]) {
|
if (convListCache.value[accountId]) {
|
||||||
@@ -349,20 +331,8 @@ const fetchConvList = async (accountId) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveConversationId = (conv) => {
|
const resolveConversationId = (conv) =>
|
||||||
const peerUid = extractPeerUid(conv)
|
resolvePeerConversationId(convListCache.value[conv.account_id] || [], conv)
|
||||||
const list = convListCache.value[conv.account_id] || []
|
|
||||||
const match = list.find(
|
|
||||||
(c) =>
|
|
||||||
(peerUid && (c.sender_id === peerUid || extractPeerUid(c) === peerUid)) ||
|
|
||||||
c.sender_name === conv.sender_name ||
|
|
||||||
c.conversation_id === conv.sender_id ||
|
|
||||||
(conv.sender_id && c.conversation_id?.endsWith(`:${conv.sender_id}`))
|
|
||||||
)
|
|
||||||
if (match?.conversation_id) return match.conversation_id
|
|
||||||
if (isConvId(conv.sender_id)) return conv.sender_id
|
|
||||||
return conv.sender_id || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAccountOnline = (accountId) => {
|
const isAccountOnline = (accountId) => {
|
||||||
const acc = accounts.value.find(a => a.id === accountId)
|
const acc = accounts.value.find(a => a.id === accountId)
|
||||||
@@ -531,13 +501,20 @@ const sendMessage = async () => {
|
|||||||
}
|
}
|
||||||
const conversationId = resolveConversationId(conv)
|
const conversationId = resolveConversationId(conv)
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
message.error('无法解析会话 ID,请刷新日志或到私信收发页重试')
|
message.error(
|
||||||
|
'无法确认这条会话对应的抖音用户(常见于昵称重复或对方资料未解析),'
|
||||||
|
+ '已阻止发送以免发错人;请到「私信收发」页选中该用户后再发'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sending.value = true
|
sending.value = true
|
||||||
try {
|
try {
|
||||||
const body = { conversation_id: conversationId, content }
|
const body = { conversation_id: conversationId, content }
|
||||||
|
// 把界面认定的收件人一并告诉后端:写入点会在发出去之前核对,
|
||||||
|
// 万一会话匹配选错了人(例如昵称重复),发送会被拒绝而不是发错。
|
||||||
|
const expectedPeerUid = extractPeerUid(conv)
|
||||||
|
if (expectedPeerUid) body.peer_uid = expectedPeerUid
|
||||||
const parsed = parseMessageContent(content)
|
const parsed = parseMessageContent(content)
|
||||||
if (parsed.type === 'sticker') {
|
if (parsed.type === 'sticker') {
|
||||||
body.message_type = 'sticker'
|
body.message_type = 'sticker'
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
buildStickerPayload,
|
buildStickerPayload,
|
||||||
parseMessageContent
|
parseMessageContent
|
||||||
} from '../utils/messageContent'
|
} from '../utils/messageContent'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { conversationPeerUid } from '../utils/peerMatch'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
const accounts = ref([])
|
const accounts = ref([])
|
||||||
const selectedAccount = ref(undefined)
|
const selectedAccount = ref(undefined)
|
||||||
const conversations = ref([])
|
const conversations = ref([])
|
||||||
@@ -96,6 +99,10 @@ const onPickSticker = (item) => {
|
|||||||
const previewContent = (raw) => messagePreview(raw)
|
const previewContent = (raw) => messagePreview(raw)
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
|
if (!auth.canWriteMessages) {
|
||||||
|
message.warning('当前角色无发送私信权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!selectedAccount.value || !selectedConv.value) {
|
if (!selectedAccount.value || !selectedConv.value) {
|
||||||
message.warning('请选择账号和会话')
|
message.warning('请选择账号和会话')
|
||||||
return
|
return
|
||||||
@@ -113,6 +120,9 @@ const sendMessage = async () => {
|
|||||||
sending.value = true
|
sending.value = true
|
||||||
try {
|
try {
|
||||||
const body = { conversation_id: selectedConv.value.conversation_id, content }
|
const body = { conversation_id: selectedConv.value.conversation_id, content }
|
||||||
|
// 会话是用户在列表里点选的,把它的对方 UID 一并送去后端核对收件人
|
||||||
|
const expectedPeerUid = conversationPeerUid(selectedConv.value)
|
||||||
|
if (expectedPeerUid) body.peer_uid = expectedPeerUid
|
||||||
const parsed = parseMessageContent(content)
|
const parsed = parseMessageContent(content)
|
||||||
if (parsed.type === 'sticker') {
|
if (parsed.type === 'sticker') {
|
||||||
body.message_type = 'sticker'
|
body.message_type = 'sticker'
|
||||||
@@ -223,25 +233,34 @@ onMounted(fetchAccounts)
|
|||||||
<MessageBubble :content="pendingPayload" compact />
|
<MessageBubble :content="pendingPayload" compact />
|
||||||
<a-button type="link" size="small" @click="pendingPayload = ''">取消</a-button>
|
<a-button type="link" size="small" @click="pendingPayload = ''">取消</a-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="compose-toolbar">
|
<template v-if="auth.canWriteMessages">
|
||||||
<EmojiPicker @pick-emoji="onPickEmoji" @pick-sticker="onPickSticker" />
|
<div class="compose-toolbar">
|
||||||
</div>
|
<EmojiPicker @pick-emoji="onPickEmoji" @pick-sticker="onPickSticker" />
|
||||||
<a-textarea
|
</div>
|
||||||
v-model:value="sendContent"
|
<a-textarea
|
||||||
:rows="6"
|
v-model:value="sendContent"
|
||||||
placeholder="输入文字,或使用上方按钮发送表情..."
|
:rows="6"
|
||||||
:disabled="!selectedConv"
|
placeholder="输入文字,或使用上方按钮发送表情..."
|
||||||
|
:disabled="!selectedConv"
|
||||||
|
/>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
class="gradient-btn send-btn"
|
||||||
|
:loading="sending"
|
||||||
|
:disabled="!selectedConv || (!sendContent.trim() && !pendingPayload)"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
<template #icon><SendOutlined /></template>
|
||||||
|
发送
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
<a-alert
|
||||||
|
v-else
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="当前角色为只读,可查看会话但无法发送私信"
|
||||||
|
style="margin-top: 12px;"
|
||||||
/>
|
/>
|
||||||
<a-button
|
|
||||||
type="primary"
|
|
||||||
class="gradient-btn send-btn"
|
|
||||||
:loading="sending"
|
|
||||||
:disabled="!selectedConv || (!sendContent.trim() && !pendingPayload)"
|
|
||||||
@click="sendMessage"
|
|
||||||
>
|
|
||||||
<template #icon><SendOutlined /></template>
|
|
||||||
发送
|
|
||||||
</a-button>
|
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|||||||
@@ -0,0 +1,798 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
DeleteOutlined
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import api from '../api'
|
||||||
|
import { useIsMobile } from '../composables/useIsMobile'
|
||||||
|
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const roleRecords = ref([])
|
||||||
|
const permissionCatalog = ref({ menus: [], actions: [], data: [], tree: [], pairs: [] })
|
||||||
|
const rolesLoading = ref(false)
|
||||||
|
const roleModalVisible = ref(false)
|
||||||
|
const roleModalTitle = ref('新建角色')
|
||||||
|
const editingRoleCode = ref(null)
|
||||||
|
const roleSaving = ref(false)
|
||||||
|
const roleForm = ref({
|
||||||
|
code: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
permissions: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const rolePermissionsReadonly = computed(() => editingRoleCode.value === 'admin')
|
||||||
|
|
||||||
|
/** Build parent/child tree when API omits ``tree`` (older backends). */
|
||||||
|
const buildPermissionTreeFallback = (catalog) => {
|
||||||
|
const menus = catalog?.menus || []
|
||||||
|
const actions = catalog?.actions || []
|
||||||
|
const data = catalog?.data || []
|
||||||
|
const pairs = catalog?.pairs || []
|
||||||
|
const actionToMenu = {}
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (pair?.action && pair?.menu) actionToMenu[pair.action] = pair.menu
|
||||||
|
}
|
||||||
|
// Heuristic: accounts.* → menu.accounts, messages.* → menu.messages, etc.
|
||||||
|
const guessParent = (code) => {
|
||||||
|
if (actionToMenu[code]) return actionToMenu[code]
|
||||||
|
if (code.startsWith('accounts.')) return 'menu.accounts'
|
||||||
|
if (code.startsWith('messages.')) return 'menu.messages'
|
||||||
|
if (code.startsWith('rules.') || code === 'link_cards.write') return 'menu.rules'
|
||||||
|
if (code.startsWith('logs.')) return 'menu.logs'
|
||||||
|
if (code.startsWith('received_messages.')) return 'menu.received_messages'
|
||||||
|
if (code.startsWith('system_logs.')) return 'menu.system_logs'
|
||||||
|
if (code === 'users.manage') return 'menu.users'
|
||||||
|
if (code === 'roles.manage') return 'menu.roles'
|
||||||
|
if (code.startsWith('settings.')) return 'menu.settings'
|
||||||
|
if (code === 'desktop.manage') return 'menu.desktop_update'
|
||||||
|
if (code === 'payments.manage') return 'menu.payment_settings'
|
||||||
|
if (code.startsWith('orders.')) return 'menu.payment_orders'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const childrenByMenu = Object.fromEntries(menus.map((m) => [m.code, []]))
|
||||||
|
for (const action of actions) {
|
||||||
|
const parent = guessParent(action.code)
|
||||||
|
if (parent && childrenByMenu[parent]) {
|
||||||
|
childrenByMenu[parent].push({ code: action.code, label: action.label })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tree = menus.map((menu) => ({
|
||||||
|
code: menu.code,
|
||||||
|
label: menu.label,
|
||||||
|
kind: 'menu',
|
||||||
|
children: childrenByMenu[menu.code] || []
|
||||||
|
}))
|
||||||
|
if (data.length) {
|
||||||
|
tree.push({
|
||||||
|
code: '__group.data__',
|
||||||
|
label: '数据权限',
|
||||||
|
kind: 'group',
|
||||||
|
children: data.map((item) => ({ code: item.code, label: item.label }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionTree = computed(() => {
|
||||||
|
const catalog = permissionCatalog.value || {}
|
||||||
|
if (Array.isArray(catalog.tree) && catalog.tree.length) return catalog.tree
|
||||||
|
return buildPermissionTreeFallback(catalog)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedPermissionSet = computed(
|
||||||
|
() => new Set(roleForm.value.permissions || [])
|
||||||
|
)
|
||||||
|
|
||||||
|
const childCodesOf = (node) => (node.children || []).map((c) => c.code)
|
||||||
|
|
||||||
|
const applyPermissionPairs = (codes) => {
|
||||||
|
const selected = new Set(codes || [])
|
||||||
|
const pairs = permissionCatalog.value.pairs || []
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (selected.has(pair.menu)) selected.add(pair.action)
|
||||||
|
}
|
||||||
|
const actionPrimary = {}
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (!actionPrimary[pair.action]) actionPrimary[pair.action] = pair.menu
|
||||||
|
}
|
||||||
|
// Also map from tree: any selected child implies its menu parent.
|
||||||
|
for (const node of permissionTree.value) {
|
||||||
|
if (node.kind === 'menu') {
|
||||||
|
for (const child of node.children || []) {
|
||||||
|
if (selected.has(child.code)) selected.add(node.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [action, menu] of Object.entries(actionPrimary)) {
|
||||||
|
if (selected.has(action)) selected.add(menu)
|
||||||
|
}
|
||||||
|
return [...selected]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChecked = (code) => selectedPermissionSet.value.has(code)
|
||||||
|
|
||||||
|
const isParentChecked = (node) => {
|
||||||
|
if (node.kind === 'group') {
|
||||||
|
const kids = childCodesOf(node)
|
||||||
|
return kids.length > 0 && kids.every((code) => isChecked(code))
|
||||||
|
}
|
||||||
|
return isChecked(node.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isParentIndeterminate = (node) => {
|
||||||
|
const kids = childCodesOf(node)
|
||||||
|
if (!kids.length) return false
|
||||||
|
const checkedCount = kids.filter((code) => isChecked(code)).length
|
||||||
|
if (node.kind === 'group') {
|
||||||
|
return checkedCount > 0 && checkedCount < kids.length
|
||||||
|
}
|
||||||
|
// Menu parent: indeterminate when some (not all) children checked,
|
||||||
|
// or when menu is checked but children are mixed.
|
||||||
|
if (checkedCount === 0) return false
|
||||||
|
if (checkedCount === kids.length && isChecked(node.code)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPermissions = (codes) => {
|
||||||
|
roleForm.value.permissions = applyPermissionPairs(codes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleParent = (node, checked) => {
|
||||||
|
const current = new Set(roleForm.value.permissions || [])
|
||||||
|
const kids = childCodesOf(node)
|
||||||
|
if (node.kind === 'group') {
|
||||||
|
for (const code of kids) {
|
||||||
|
if (checked) current.add(code)
|
||||||
|
else current.delete(code)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (checked) {
|
||||||
|
current.add(node.code)
|
||||||
|
} else {
|
||||||
|
current.delete(node.code)
|
||||||
|
for (const code of kids) current.delete(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPermissions([...current])
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleChild = (parent, childCode, checked) => {
|
||||||
|
const current = new Set(roleForm.value.permissions || [])
|
||||||
|
if (checked) {
|
||||||
|
current.add(childCode)
|
||||||
|
if (parent.kind === 'menu') current.add(parent.code)
|
||||||
|
} else {
|
||||||
|
current.delete(childCode)
|
||||||
|
}
|
||||||
|
setPermissions([...current])
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatApiError = (error, fallback = '操作失败') => {
|
||||||
|
const detail = error?.response?.data?.detail
|
||||||
|
if (detail == null || detail === '') return error?.message || fallback
|
||||||
|
if (typeof detail === 'string') return detail
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
return detail
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === 'string') return item
|
||||||
|
return item?.msg || item?.message || JSON.stringify(item)
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(';') || fallback
|
||||||
|
}
|
||||||
|
if (typeof detail === 'object') {
|
||||||
|
return detail.msg || detail.message || JSON.stringify(detail)
|
||||||
|
}
|
||||||
|
return String(detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRoleRecords = async () => {
|
||||||
|
rolesLoading.value = true
|
||||||
|
try {
|
||||||
|
const [rolesRes, catalogRes] = await Promise.all([
|
||||||
|
api.get('/roles'),
|
||||||
|
api.get('/roles/catalog')
|
||||||
|
])
|
||||||
|
roleRecords.value = rolesRes.data.roles || []
|
||||||
|
permissionCatalog.value = catalogRes.data || {
|
||||||
|
menus: [],
|
||||||
|
actions: [],
|
||||||
|
data: [],
|
||||||
|
tree: [],
|
||||||
|
pairs: []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(formatApiError(error, '获取角色列表失败'))
|
||||||
|
} finally {
|
||||||
|
rolesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAddRole = () => {
|
||||||
|
editingRoleCode.value = null
|
||||||
|
roleModalTitle.value = '新建角色'
|
||||||
|
roleForm.value = {
|
||||||
|
code: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
permissions: [
|
||||||
|
'menu.dashboard',
|
||||||
|
'menu.accounts',
|
||||||
|
'menu.messages',
|
||||||
|
'menu.rules',
|
||||||
|
'menu.help',
|
||||||
|
'menu.download'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
roleModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditRole = (record) => {
|
||||||
|
editingRoleCode.value = record.value
|
||||||
|
roleModalTitle.value = record.is_admin ? '查看管理员角色' : '编辑角色'
|
||||||
|
roleForm.value = {
|
||||||
|
code: record.value,
|
||||||
|
label: record.label,
|
||||||
|
description: record.description || '',
|
||||||
|
permissions: [...(record.permissions || [])]
|
||||||
|
}
|
||||||
|
roleModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveRole = async () => {
|
||||||
|
const code = (roleForm.value.code || '').trim().toLowerCase()
|
||||||
|
const label = (roleForm.value.label || '').trim()
|
||||||
|
if (!editingRoleCode.value && !code) {
|
||||||
|
message.warning('请填写角色码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!label) {
|
||||||
|
message.warning('请填写角色名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roleSaving.value = true
|
||||||
|
try {
|
||||||
|
if (editingRoleCode.value) {
|
||||||
|
await api.put(`/roles/${encodeURIComponent(editingRoleCode.value)}`, {
|
||||||
|
label,
|
||||||
|
description: roleForm.value.description || null,
|
||||||
|
permissions: rolePermissionsReadonly.value
|
||||||
|
? undefined
|
||||||
|
: roleForm.value.permissions
|
||||||
|
})
|
||||||
|
message.success('角色已更新')
|
||||||
|
} else {
|
||||||
|
await api.post('/roles', {
|
||||||
|
code,
|
||||||
|
label,
|
||||||
|
description: roleForm.value.description || null,
|
||||||
|
permissions: roleForm.value.permissions
|
||||||
|
})
|
||||||
|
message.success('角色已创建')
|
||||||
|
}
|
||||||
|
roleModalVisible.value = false
|
||||||
|
await fetchRoleRecords()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(formatApiError(error, '保存角色失败'))
|
||||||
|
} finally {
|
||||||
|
roleSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteRole = (record) => {
|
||||||
|
if (record.is_system) {
|
||||||
|
message.warning('系统内置角色不可删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确定删除角色「${record.label}」吗?`,
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/roles/${encodeURIComponent(record.value)}`)
|
||||||
|
message.success('角色已删除')
|
||||||
|
await fetchRoleRecords()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(formatApiError(error, '删除角色失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRoleRecords()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="roles-page">
|
||||||
|
<div class="page-header glass-card">
|
||||||
|
<div class="page-header-main">
|
||||||
|
<h2 class="page-title">
|
||||||
|
<SafetyCertificateOutlined class="page-title-icon" />
|
||||||
|
角色设定
|
||||||
|
</h2>
|
||||||
|
<p class="subtitle">按菜单父子层级配置权限:父级进页面,子级控按钮与数据范围</p>
|
||||||
|
</div>
|
||||||
|
<a-button type="primary" class="gradient-btn add-btn" @click="openAddRole">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
新建角色
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-card roles-panel">
|
||||||
|
<a-spin :spinning="rolesLoading">
|
||||||
|
<a-table
|
||||||
|
v-if="!isMobile"
|
||||||
|
:data-source="roleRecords"
|
||||||
|
row-key="value"
|
||||||
|
:pagination="false"
|
||||||
|
>
|
||||||
|
<a-table-column title="角色码" data-index="value" key="value" :width="140" />
|
||||||
|
<a-table-column title="名称" data-index="label" key="label" :width="140" />
|
||||||
|
<a-table-column title="说明" key="description">
|
||||||
|
<template #default="{ record }">
|
||||||
|
<span :class="{ 'text-muted': !record.description }">
|
||||||
|
{{ record.description || '—' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="类型" key="type" :width="110">
|
||||||
|
<template #default="{ record }">
|
||||||
|
<a-tag v-if="record.is_admin" color="purple">超级管理员</a-tag>
|
||||||
|
<a-tag v-else-if="record.is_system" color="blue">系统</a-tag>
|
||||||
|
<a-tag v-else color="geekblue">自定义</a-tag>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="权限数" key="perm_count" :width="90">
|
||||||
|
<template #default="{ record }">
|
||||||
|
{{ (record.permissions || []).length }}
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="用户数" data-index="user_count" key="user_count" :width="80" />
|
||||||
|
<a-table-column title="操作" key="action" :width="160">
|
||||||
|
<template #default="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="text" class="edit-btn" @click="openEditRole(record)">
|
||||||
|
{{ record.is_admin ? '查看' : '编辑' }}
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="!record.is_system"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
@click="handleDeleteRole(record)"
|
||||||
|
>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<div v-else class="role-card-list">
|
||||||
|
<div v-for="record in roleRecords" :key="record.value" class="role-card glass-card">
|
||||||
|
<div class="role-card-head">
|
||||||
|
<div>
|
||||||
|
<div class="role-card-name">{{ record.label }}</div>
|
||||||
|
<div class="role-card-code">{{ record.value }}</div>
|
||||||
|
</div>
|
||||||
|
<a-tag v-if="record.is_admin" color="purple">超级管理员</a-tag>
|
||||||
|
<a-tag v-else-if="record.is_system" color="blue">系统</a-tag>
|
||||||
|
<a-tag v-else color="geekblue">自定义</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="role-card-meta">
|
||||||
|
<div class="role-card-row">
|
||||||
|
<span class="role-card-label">权限</span>
|
||||||
|
<span>{{ (record.permissions || []).length }} 项</span>
|
||||||
|
</div>
|
||||||
|
<div class="role-card-row">
|
||||||
|
<span class="role-card-label">用户</span>
|
||||||
|
<span>{{ record.user_count ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="record.description" class="role-card-desc">
|
||||||
|
{{ record.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="role-card-actions">
|
||||||
|
<a-button type="text" class="edit-btn" @click="openEditRole(record)">
|
||||||
|
{{ record.is_admin ? '查看' : '编辑' }}
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="!record.is_system"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
@click="handleDeleteRole(record)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-empty v-if="!roleRecords.length" description="暂无角色" />
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-modal
|
||||||
|
v-model:visible="roleModalVisible"
|
||||||
|
:title="roleModalTitle"
|
||||||
|
:width="isMobile ? 'calc(100vw - 32px)' : 760"
|
||||||
|
:confirm-loading="roleSaving"
|
||||||
|
@ok="handleSaveRole"
|
||||||
|
ok-text="保存"
|
||||||
|
cancel-text="取消"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical" class="role-form" style="margin-top: 16px;">
|
||||||
|
<a-form-item label="角色码" required>
|
||||||
|
<a-input
|
||||||
|
v-model:value="roleForm.code"
|
||||||
|
placeholder="小写字母开头,如 ops_leader"
|
||||||
|
:disabled="!!editingRoleCode"
|
||||||
|
:maxlength="50"
|
||||||
|
/>
|
||||||
|
<div class="field-hint">创建后不可修改;仅小写字母、数字、下划线</div>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="显示名称" required>
|
||||||
|
<a-input v-model:value="roleForm.label" placeholder="界面展示名称" :maxlength="100" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="说明">
|
||||||
|
<a-input
|
||||||
|
v-model:value="roleForm.description"
|
||||||
|
placeholder="可选,描述该角色的职责"
|
||||||
|
:maxlength="255"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="父级为菜单入口,子级为页面内按钮/操作;勾选子级会自动勾选父级菜单。取消父级会清除其下全部子权限。"
|
||||||
|
style="margin-bottom: 16px;"
|
||||||
|
/>
|
||||||
|
<a-alert
|
||||||
|
v-if="rolePermissionsReadonly"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="管理员角色固定拥有全部权限,不可取消勾选"
|
||||||
|
style="margin-bottom: 16px;"
|
||||||
|
/>
|
||||||
|
<a-form-item label="权限配置">
|
||||||
|
<div class="perm-tree" :class="{ 'perm-tree-readonly': rolePermissionsReadonly }">
|
||||||
|
<div
|
||||||
|
v-for="node in permissionTree"
|
||||||
|
:key="node.code"
|
||||||
|
class="perm-tree-node"
|
||||||
|
:class="{ 'perm-tree-node-group': node.kind === 'group' }"
|
||||||
|
>
|
||||||
|
<label class="perm-tree-parent">
|
||||||
|
<a-checkbox
|
||||||
|
:checked="isParentChecked(node)"
|
||||||
|
:indeterminate="isParentIndeterminate(node)"
|
||||||
|
:disabled="rolePermissionsReadonly"
|
||||||
|
@change="(e) => toggleParent(node, e.target.checked)"
|
||||||
|
>
|
||||||
|
<span class="perm-tree-parent-label">{{ node.label }}</span>
|
||||||
|
<span v-if="node.kind === 'menu' && !(node.children || []).length" class="perm-tree-tag">仅菜单</span>
|
||||||
|
<span v-else-if="node.kind === 'group'" class="perm-tree-tag">数据范围</span>
|
||||||
|
</a-checkbox>
|
||||||
|
</label>
|
||||||
|
<div v-if="(node.children || []).length" class="perm-tree-children">
|
||||||
|
<label
|
||||||
|
v-for="child in node.children"
|
||||||
|
:key="child.code"
|
||||||
|
class="perm-tree-child"
|
||||||
|
>
|
||||||
|
<a-checkbox
|
||||||
|
:checked="isChecked(child.code)"
|
||||||
|
:disabled="rolePermissionsReadonly"
|
||||||
|
@change="(e) => toggleChild(node, child.code, e.target.checked)"
|
||||||
|
>
|
||||||
|
{{ child.label }}
|
||||||
|
</a-checkbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="node.kind === 'group'" class="field-hint perm-tree-hint">
|
||||||
|
勾选「查看全部用户数据」后可跨用户查看账号/规则/日志;未勾选则仅本人数据。内置管理员始终拥有全局范围。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<div class="glass-card role-help">
|
||||||
|
<h3 style="margin-top: 0; color: #fff;">角色权限说明</h3>
|
||||||
|
<ul class="role-list">
|
||||||
|
<li><strong>管理员</strong>:全局数据范围 + 全部权限,不可删除</li>
|
||||||
|
<li><strong>运营 / 只读 / 自定义角色</strong>:仅能访问自己的账号数据;若有用户管理权限,也只能管理自己创建的用户</li>
|
||||||
|
<li>自定义角色创建后,可在「用户管理」中分配给用户</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.roles-page {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-icon {
|
||||||
|
color: #c084fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-btn {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||||
|
border: none !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-panel {
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-panel:hover,
|
||||||
|
.role-help:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-page :deep(.ant-table) {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-page :deep(.ant-table-thead > tr > th) {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
color: #d1d5db !important;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-page :deep(.ant-table-tbody > tr > td) {
|
||||||
|
background: transparent !important;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-page :deep(.ant-table-tbody > tr:hover > td) {
|
||||||
|
background: rgba(170, 59, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-page :deep(.ant-tag) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree {
|
||||||
|
max-height: min(58vh, 520px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 4px 2px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-node {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-node-group {
|
||||||
|
border-color: rgba(96, 165, 250, 0.28);
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-parent {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-parent :deep(.ant-checkbox-wrapper) {
|
||||||
|
color: #f3f4f6 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-parent-label {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-children {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin: 10px 0 0 28px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-child {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-child :deep(.ant-checkbox-wrapper) {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-hint {
|
||||||
|
margin: 10px 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-readonly {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
color: #d8b4fe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn:hover {
|
||||||
|
color: #f3e8ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-form :deep(.ant-form-item-label > label) {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-help {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-list {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: hsla(230, 20%, 12%, 0.85);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card:hover {
|
||||||
|
transform: none;
|
||||||
|
border-color: rgba(192, 132, 252, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-code {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-help {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FilterOutlined
|
FilterOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
import ReplyRuleEditor from '../components/ReplyRuleEditor.vue'
|
import ReplyRuleEditor from '../components/ReplyRuleEditor.vue'
|
||||||
import {
|
import {
|
||||||
emptyReplyForm,
|
emptyReplyForm,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 720))
|
const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 720))
|
||||||
|
|
||||||
@@ -452,7 +454,12 @@ watch(
|
|||||||
>
|
>
|
||||||
<template #suffixIcon><FilterOutlined /></template>
|
<template #suffixIcon><FilterOutlined /></template>
|
||||||
</a-select>
|
</a-select>
|
||||||
<a-button type="primary" class="gradient-btn add-rule-btn" @click="openAddModal">
|
<a-button
|
||||||
|
v-if="auth.canWriteRules"
|
||||||
|
type="primary"
|
||||||
|
class="gradient-btn add-rule-btn"
|
||||||
|
@click="openAddModal"
|
||||||
|
>
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
添加规则
|
添加规则
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -489,8 +496,15 @@ watch(
|
|||||||
:max="86400"
|
:max="86400"
|
||||||
style="width: 220px;"
|
style="width: 220px;"
|
||||||
:placeholder="`留空用全局默认 ${cooldownEffective} 秒`"
|
:placeholder="`留空用全局默认 ${cooldownEffective} 秒`"
|
||||||
|
:disabled="!auth.canWriteRules"
|
||||||
/>
|
/>
|
||||||
<a-button type="primary" class="gradient-btn" :loading="cooldownSaving" @click="saveCooldown">
|
<a-button
|
||||||
|
v-if="auth.canWriteRules"
|
||||||
|
type="primary"
|
||||||
|
class="gradient-btn"
|
||||||
|
:loading="cooldownSaving"
|
||||||
|
@click="saveCooldown"
|
||||||
|
>
|
||||||
保存冷却时间
|
保存冷却时间
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -535,7 +549,7 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="column.key === 'sort_order'">
|
<template v-if="column.key === 'sort_order'">
|
||||||
<a-space size="small">
|
<a-space v-if="auth.canWriteRules" size="small">
|
||||||
<a-button
|
<a-button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -553,6 +567,7 @@ watch(
|
|||||||
<template #icon><ArrowDownOutlined /></template>
|
<template #icon><ArrowDownOutlined /></template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
<span v-else>{{ record.sort_order ?? '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="column.key === 'account_id'">
|
<template v-if="column.key === 'account_id'">
|
||||||
@@ -562,11 +577,15 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="column.key === 'is_active'">
|
<template v-if="column.key === 'is_active'">
|
||||||
<a-switch :checked="record.is_active" @change="handleToggleRule(record)" />
|
<a-switch
|
||||||
|
:checked="record.is_active"
|
||||||
|
:disabled="!auth.canWriteRules"
|
||||||
|
@change="handleToggleRule(record)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<a-space size="middle">
|
<a-space v-if="auth.canWriteRules" size="middle">
|
||||||
<a-button type="text" style="color: #c084fc;" @click="openEditModal(record)">
|
<a-button type="text" style="color: #c084fc;" @click="openEditModal(record)">
|
||||||
<template #icon><EditOutlined /></template>
|
<template #icon><EditOutlined /></template>
|
||||||
编辑
|
编辑
|
||||||
@@ -584,6 +603,7 @@ watch(
|
|||||||
</a-button>
|
</a-button>
|
||||||
</a-popconfirm>
|
</a-popconfirm>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
<span v-else class="muted-readonly">只读</span>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
@@ -597,7 +617,12 @@ watch(
|
|||||||
<a-tag :color="matchTypeColor(record.match_type)">
|
<a-tag :color="matchTypeColor(record.match_type)">
|
||||||
{{ matchTypeLabel(record.match_type) }}
|
{{ matchTypeLabel(record.match_type) }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-switch :checked="record.is_active" size="small" @change="handleToggleRule(record)" />
|
<a-switch
|
||||||
|
:checked="record.is_active"
|
||||||
|
size="small"
|
||||||
|
:disabled="!auth.canWriteRules"
|
||||||
|
@change="handleToggleRule(record)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rule-card-keyword">
|
<div class="rule-card-keyword">
|
||||||
@@ -618,7 +643,7 @@ watch(
|
|||||||
</a-tag>
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rule-card-actions">
|
<div v-if="auth.canWriteRules" class="rule-card-actions">
|
||||||
<a-space size="small">
|
<a-space size="small">
|
||||||
<a-button type="text" size="small" class="sort-move-btn" @click="handleMoveRule(record, 'up')">
|
<a-button type="text" size="small" class="sort-move-btn" @click="handleMoveRule(record, 'up')">
|
||||||
<ArrowUpOutlined />
|
<ArrowUpOutlined />
|
||||||
@@ -689,7 +714,11 @@ watch(
|
|||||||
<a-input v-model:value="ruleForm.keyword" placeholder="当对方发来的信息包含此词语时触发回复..." />
|
<a-input v-model:value="ruleForm.keyword" placeholder="当对方发来的信息包含此词语时触发回复..." />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<ReplyRuleEditor v-model:replies="ruleForm.replies" />
|
<ReplyRuleEditor
|
||||||
|
v-model:replies="ruleForm.replies"
|
||||||
|
:readonly="!auth.canWriteRules"
|
||||||
|
:can-upload-cards="auth.canWriteLinkCards"
|
||||||
|
/>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { ref, onMounted } from 'vue'
|
|||||||
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
@@ -24,6 +26,8 @@ import api from '../api'
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -589,7 +593,9 @@ const handleTestEmail = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchSettings()
|
fetchSettings()
|
||||||
fetchDatabaseSettings()
|
if (auth.canManageDatabase) {
|
||||||
|
fetchDatabaseSettings()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -978,7 +984,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="glass-card panel db-panel">
|
<div v-if="auth.canManageDatabase" class="glass-card panel db-panel">
|
||||||
|
|
||||||
<div class="db-panel-header">
|
<div class="db-panel-header">
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ onMounted(() => {
|
|||||||
刷新
|
刷新
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<a-button v-if="auth.isAdmin" danger @click="clearLogs">
|
<a-button v-if="auth.canClearSystemLogs" danger @click="clearLogs">
|
||||||
<template #icon><DeleteOutlined /></template>
|
<template #icon><DeleteOutlined /></template>
|
||||||
清空
|
清空
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons-vue'
|
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useIsMobile } from '../composables/useIsMobile'
|
import { useIsMobile } from '../composables/useIsMobile'
|
||||||
@@ -11,29 +11,50 @@ const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 520))
|
|||||||
|
|
||||||
const pageCurrent = ref(1)
|
const pageCurrent = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
|
||||||
const paginatedUsers = computed(() => {
|
const FIELD_LABELS = {
|
||||||
const start = (pageCurrent.value - 1) * pageSize.value
|
username: '用户名',
|
||||||
return users.value.slice(start, start + pageSize.value)
|
password: '密码',
|
||||||
})
|
display_name: '显示名称',
|
||||||
|
email: '邮箱',
|
||||||
|
role: '角色',
|
||||||
|
max_accounts: '可添加抖音账号数',
|
||||||
|
is_active: '账号状态',
|
||||||
|
email_verified: '邮箱验证状态'
|
||||||
|
}
|
||||||
|
|
||||||
const paginationConfig = computed(() => ({
|
/** FastAPI 422 detail 可能是字符串,也可能是校验错误对象数组。 */
|
||||||
current: pageCurrent.value,
|
const formatApiError = (error, fallback = '操作失败') => {
|
||||||
pageSize: pageSize.value,
|
const detail = error?.response?.data?.detail
|
||||||
total: users.value.length,
|
if (detail == null || detail === '') {
|
||||||
showSizeChanger: !isMobile.value,
|
return error?.message || fallback
|
||||||
pageSizeOptions: ['10', '20', '50'],
|
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
|
||||||
size: isMobile.value ? 'small' : 'default',
|
|
||||||
onChange: (page, size) => {
|
|
||||||
pageCurrent.value = page
|
|
||||||
pageSize.value = size
|
|
||||||
}
|
}
|
||||||
}))
|
if (typeof detail === 'string') return detail
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
const parts = detail.map((item) => {
|
||||||
|
if (typeof item === 'string') return item
|
||||||
|
if (!item || typeof item !== 'object') return String(item)
|
||||||
|
const rawField = Array.isArray(item.loc)
|
||||||
|
? item.loc.filter((part) => part !== 'body' && part !== 'query').join('.')
|
||||||
|
: ''
|
||||||
|
const field = FIELD_LABELS[rawField] || rawField
|
||||||
|
let msg = item.msg || item.message || JSON.stringify(item)
|
||||||
|
if (/at least 2 characters/i.test(msg)) msg = '至少 2 个字符'
|
||||||
|
else if (/at least 6 characters/i.test(msg)) msg = '至少 6 位'
|
||||||
|
else if (/valid email/i.test(msg)) msg = '邮箱格式不正确'
|
||||||
|
return field ? `${field}:${msg}` : msg
|
||||||
|
}).filter(Boolean)
|
||||||
|
return parts.length ? parts.join(';') : fallback
|
||||||
|
}
|
||||||
|
if (typeof detail === 'object') {
|
||||||
|
return detail.msg || detail.message || JSON.stringify(detail)
|
||||||
|
}
|
||||||
|
return String(detail)
|
||||||
|
}
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
const roles = ref([])
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const modalTitle = ref('新增用户')
|
const modalTitle = ref('新增用户')
|
||||||
@@ -54,26 +75,73 @@ const defaultRegisterMaxAccounts = ref(3)
|
|||||||
const emailVerificationRequired = ref(true)
|
const emailVerificationRequired = ref(true)
|
||||||
const emailBindingRequired = ref(false)
|
const emailBindingRequired = ref(false)
|
||||||
|
|
||||||
const isAdminRole = computed(() => userForm.value.role === 'admin')
|
const roleOptions = ref([
|
||||||
|
{ value: 'admin', label: '管理员', is_admin: true },
|
||||||
|
{ value: 'operator', label: '运营', is_admin: false },
|
||||||
|
{ value: 'viewer', label: '只读', is_admin: false }
|
||||||
|
])
|
||||||
|
|
||||||
|
const assignableRoleOptions = computed(() => {
|
||||||
|
if (auth.isAdmin) return roleOptions.value
|
||||||
|
return roleOptions.value.filter((r) => !r.is_admin)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAdminRole = computed(() => {
|
||||||
|
const hit = roleOptions.value.find((r) => r.value === userForm.value.role)
|
||||||
|
return !!(hit?.is_admin || userForm.value.role === 'admin')
|
||||||
|
})
|
||||||
const emailRequiredForRole = computed(
|
const emailRequiredForRole = computed(
|
||||||
() => emailBindingRequired.value && !isAdminRole.value
|
() => emailBindingRequired.value && !isAdminRole.value
|
||||||
)
|
)
|
||||||
|
|
||||||
const roleOptions = ref([
|
|
||||||
{ value: 'admin', label: '管理员' },
|
|
||||||
{ value: 'operator', label: '运营' },
|
|
||||||
{ value: 'viewer', label: '只读' }
|
|
||||||
])
|
|
||||||
|
|
||||||
const hasEmail = computed(() => !!userForm.value.email?.trim())
|
const hasEmail = computed(() => !!userForm.value.email?.trim())
|
||||||
|
|
||||||
|
const getRoleLabel = (role) =>
|
||||||
|
roleOptions.value.find((r) => r.value === role)?.label || role
|
||||||
|
|
||||||
|
const filteredUsers = computed(() => {
|
||||||
|
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||||
|
if (!keyword) return users.value
|
||||||
|
return users.value.filter((user) => {
|
||||||
|
const roleLabel = (getRoleLabel(user.role) || '').toLowerCase()
|
||||||
|
const haystack = [
|
||||||
|
String(user.id ?? ''),
|
||||||
|
user.username || '',
|
||||||
|
user.display_name || '',
|
||||||
|
user.email || '',
|
||||||
|
user.role || '',
|
||||||
|
roleLabel
|
||||||
|
].join(' ').toLowerCase()
|
||||||
|
return haystack.includes(keyword)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginatedUsers = computed(() => {
|
||||||
|
const start = (pageCurrent.value - 1) * pageSize.value
|
||||||
|
return filteredUsers.value.slice(start, start + pageSize.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginationConfig = computed(() => ({
|
||||||
|
current: pageCurrent.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
total: filteredUsers.value.length,
|
||||||
|
showSizeChanger: !isMobile.value,
|
||||||
|
pageSizeOptions: ['10', '20', '50'],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
size: isMobile.value ? 'small' : 'default',
|
||||||
|
onChange: (page, size) => {
|
||||||
|
pageCurrent.value = page
|
||||||
|
pageSize.value = size
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/users')
|
const res = await api.get('/users')
|
||||||
users.value = res.data
|
users.value = res.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.detail || '获取用户列表失败')
|
message.error(formatApiError(error, '获取用户列表失败'))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -82,9 +150,13 @@ const fetchUsers = async () => {
|
|||||||
const fetchRoles = async () => {
|
const fetchRoles = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/auth/roles')
|
const res = await api.get('/auth/roles')
|
||||||
roles.value = res.data.roles
|
const list = res.data.roles || []
|
||||||
if (roles.value.length) {
|
if (list.length) {
|
||||||
roleOptions.value = roles.value
|
roleOptions.value = list.map((r) => ({
|
||||||
|
value: r.value,
|
||||||
|
label: r.label,
|
||||||
|
is_admin: !!r.is_admin
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// keep defaults
|
// keep defaults
|
||||||
@@ -131,7 +203,9 @@ const openEdit = (record) => {
|
|||||||
email_verified: !!record.email_verified,
|
email_verified: !!record.email_verified,
|
||||||
role: record.role,
|
role: record.role,
|
||||||
is_active: record.is_active,
|
is_active: record.is_active,
|
||||||
max_accounts: record.role === 'admin' ? defaultRegisterMaxAccounts.value : resolveAccountLimit(record)
|
max_accounts: isUnlimitedQuota(record)
|
||||||
|
? defaultRegisterMaxAccounts.value
|
||||||
|
: resolveAccountLimit(record)
|
||||||
}
|
}
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -144,6 +218,8 @@ const onEmailChange = () => {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const email = userForm.value.email?.trim() || null
|
const email = userForm.value.email?.trim() || null
|
||||||
|
const username = userForm.value.username.trim()
|
||||||
|
const password = userForm.value.password || ''
|
||||||
|
|
||||||
if (emailRequiredForRole.value && !email) {
|
if (emailRequiredForRole.value && !email) {
|
||||||
message.warning('当前系统要求非管理员用户必须绑定邮箱')
|
message.warning('当前系统要求非管理员用户必须绑定邮箱')
|
||||||
@@ -151,15 +227,23 @@ const handleSave = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!editingId.value) {
|
if (!editingId.value) {
|
||||||
if (!userForm.value.username.trim() || !userForm.value.password) {
|
if (!username || !password) {
|
||||||
message.warning('请填写用户名和密码')
|
message.warning('请填写用户名和密码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (username.length < 2) {
|
||||||
|
message.warning('用户名至少 2 个字符')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
message.warning('密码至少 6 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api.post('/users', {
|
await api.post('/users', {
|
||||||
username: userForm.value.username.trim(),
|
username,
|
||||||
password: userForm.value.password,
|
password,
|
||||||
display_name: userForm.value.display_name || userForm.value.username,
|
display_name: userForm.value.display_name || username,
|
||||||
role: userForm.value.role,
|
role: userForm.value.role,
|
||||||
email: email || undefined,
|
email: email || undefined,
|
||||||
email_verified: email ? userForm.value.email_verified : true,
|
email_verified: email ? userForm.value.email_verified : true,
|
||||||
@@ -169,11 +253,16 @@ const handleSave = async () => {
|
|||||||
modalVisible.value = false
|
modalVisible.value = false
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.detail || '创建失败')
|
message.error(formatApiError(error, '创建失败'))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (password && password.length < 6) {
|
||||||
|
message.warning('新密码至少 6 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
display_name: userForm.value.display_name,
|
display_name: userForm.value.display_name,
|
||||||
role: userForm.value.role,
|
role: userForm.value.role,
|
||||||
@@ -183,8 +272,8 @@ const handleSave = async () => {
|
|||||||
if (email) {
|
if (email) {
|
||||||
payload.email_verified = userForm.value.email_verified
|
payload.email_verified = userForm.value.email_verified
|
||||||
}
|
}
|
||||||
if (userForm.value.password) {
|
if (password) {
|
||||||
payload.password = userForm.value.password
|
payload.password = password
|
||||||
}
|
}
|
||||||
if (!isAdminRole.value) {
|
if (!isAdminRole.value) {
|
||||||
payload.max_accounts = userForm.value.max_accounts
|
payload.max_accounts = userForm.value.max_accounts
|
||||||
@@ -198,7 +287,7 @@ const handleSave = async () => {
|
|||||||
await auth.fetchMe()
|
await auth.fetchMe()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.detail || '更新失败')
|
message.error(formatApiError(error, '更新失败'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,15 +296,17 @@ const handleDelete = (record) => {
|
|||||||
title: `确定删除用户「${record.username}」吗?`,
|
title: `确定删除用户「${record.username}」吗?`,
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
await api.delete(`/users/${record.id}`)
|
try {
|
||||||
message.success('已删除')
|
await api.delete(`/users/${record.id}`)
|
||||||
fetchUsers()
|
message.success('已删除')
|
||||||
|
fetchUsers()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(formatApiError(error, '删除失败'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRoleLabel = (role) => roleOptions.value.find(r => r.value === role)?.label || role
|
|
||||||
|
|
||||||
const roleTagColor = (role) => {
|
const roleTagColor = (role) => {
|
||||||
if (role === 'admin') return 'purple'
|
if (role === 'admin') return 'purple'
|
||||||
if (role === 'operator') return 'geekblue'
|
if (role === 'operator') return 'geekblue'
|
||||||
@@ -244,7 +335,9 @@ const emailVerifyColor = (record) => {
|
|||||||
return record.email_verified ? 'green' : 'gold'
|
return record.email_verified ? 'green' : 'gold'
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUnlimitedQuota = (record) => record.role === 'admin'
|
const isUnlimitedQuota = (record) =>
|
||||||
|
!!(record.is_admin || record.role === 'admin' ||
|
||||||
|
roleOptions.value.find((r) => r.value === record.role)?.is_admin)
|
||||||
|
|
||||||
const resolveAccountLimit = (record) => {
|
const resolveAccountLimit = (record) => {
|
||||||
if (isUnlimitedQuota(record)) return null
|
if (isUnlimitedQuota(record)) return null
|
||||||
@@ -269,13 +362,17 @@ const accountQuotaLabel = (record) => {
|
|||||||
return `${total}/${limit}`
|
return `${total}/${limit}`
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(users, (list) => {
|
watch([filteredUsers, pageSize], () => {
|
||||||
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
|
const maxPage = Math.max(1, Math.ceil(filteredUsers.value.length / pageSize.value))
|
||||||
if (pageCurrent.value > maxPage) {
|
if (pageCurrent.value > maxPage) {
|
||||||
pageCurrent.value = maxPage
|
pageCurrent.value = maxPage
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(searchKeyword, () => {
|
||||||
|
pageCurrent.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchRoles()
|
fetchRoles()
|
||||||
fetchDefaultMaxAccounts()
|
fetchDefaultMaxAccounts()
|
||||||
@@ -289,9 +386,9 @@ onMounted(() => {
|
|||||||
<div class="page-header-main">
|
<div class="page-header-main">
|
||||||
<h2 class="page-title">
|
<h2 class="page-title">
|
||||||
<TeamOutlined class="page-title-icon" />
|
<TeamOutlined class="page-title-icon" />
|
||||||
用户与角色管理
|
用户管理
|
||||||
</h2>
|
</h2>
|
||||||
<p class="subtitle">管理员可创建用户并分配角色,实现数据隔离与权限控制</p>
|
<p class="subtitle">创建与管理后台用户并分配角色;非管理员仅能查看自己创建的用户</p>
|
||||||
</div>
|
</div>
|
||||||
<a-button type="primary" class="gradient-btn add-user-btn" @click="openAdd">
|
<a-button type="primary" class="gradient-btn add-user-btn" @click="openAdd">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
@@ -299,7 +396,22 @@ onMounted(() => {
|
|||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 桌面端:表格 -->
|
<div class="users-toolbar glass-card">
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
allow-clear
|
||||||
|
placeholder="搜索用户名、显示名、邮箱、角色、ID"
|
||||||
|
class="users-search-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<SearchOutlined />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
<span class="users-toolbar-meta">
|
||||||
|
{{ searchKeyword.trim() ? `匹配 ${filteredUsers.length} / 共 ${users.length}` : `共 ${users.length}` }} 个用户
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="!isMobile" class="glass-card table-card">
|
<div v-if="!isMobile" class="glass-card table-card">
|
||||||
<a-table
|
<a-table
|
||||||
:data-source="paginatedUsers"
|
:data-source="paginatedUsers"
|
||||||
@@ -350,7 +462,7 @@ onMounted(() => {
|
|||||||
<a-table-column title="操作" key="action" width="180px">
|
<a-table-column title="操作" key="action" width="180px">
|
||||||
<template #default="{ record }">
|
<template #default="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button type="text" style="color: #c084fc;" @click="openEdit(record)">
|
<a-button type="text" class="edit-btn" @click="openEdit(record)">
|
||||||
<template #icon><EditOutlined /></template>
|
<template #icon><EditOutlined /></template>
|
||||||
编辑
|
编辑
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -369,7 +481,6 @@ onMounted(() => {
|
|||||||
</a-table>
|
</a-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 手机端:卡片列表 -->
|
|
||||||
<div v-else class="users-mobile-list">
|
<div v-else class="users-mobile-list">
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<div v-if="paginatedUsers.length" class="user-card-list">
|
<div v-if="paginatedUsers.length" class="user-card-list">
|
||||||
@@ -435,14 +546,17 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a-empty v-else description="暂无用户" />
|
<a-empty
|
||||||
|
v-else
|
||||||
|
:description="searchKeyword.trim() ? '未找到匹配用户' : '暂无用户'"
|
||||||
|
/>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
|
|
||||||
<div v-if="users.length" class="users-mobile-pagination">
|
<div v-if="filteredUsers.length" class="users-mobile-pagination">
|
||||||
<a-pagination
|
<a-pagination
|
||||||
v-model:current="pageCurrent"
|
v-model:current="pageCurrent"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:total="users.length"
|
:total="filteredUsers.length"
|
||||||
:show-size-changer="false"
|
:show-size-changer="false"
|
||||||
size="small"
|
size="small"
|
||||||
:show-total="(total) => `共 ${total} 条`"
|
:show-total="(total) => `共 ${total} 条`"
|
||||||
@@ -460,10 +574,18 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<a-form layout="vertical" class="user-form" style="margin-top: 16px;">
|
<a-form layout="vertical" class="user-form" style="margin-top: 16px;">
|
||||||
<a-form-item v-if="!editingId" label="用户名" required>
|
<a-form-item v-if="!editingId" label="用户名" required>
|
||||||
<a-input v-model:value="userForm.username" placeholder="登录用户名" />
|
<a-input
|
||||||
|
v-model:value="userForm.username"
|
||||||
|
placeholder="登录用户名,至少 2 个字符"
|
||||||
|
:maxlength="50"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item :label="editingId ? '新密码(留空不修改)' : '密码'" :required="!editingId">
|
<a-form-item :label="editingId ? '新密码(留空不修改)' : '密码'" :required="!editingId">
|
||||||
<a-input-password v-model:value="userForm.password" placeholder="至少 6 位" />
|
<a-input-password
|
||||||
|
v-model:value="userForm.password"
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
:maxlength="128"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="显示名称">
|
<a-form-item label="显示名称">
|
||||||
<a-input v-model:value="userForm.display_name" placeholder="界面展示名称" />
|
<a-input v-model:value="userForm.display_name" placeholder="界面展示名称" />
|
||||||
@@ -491,7 +613,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="角色">
|
<a-form-item label="角色">
|
||||||
<a-select v-model:value="userForm.role" :options="roleOptions" />
|
<a-select v-model:value="userForm.role" :options="assignableRoleOptions" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="!isAdminRole" label="可添加抖音账号数">
|
<a-form-item v-if="!isAdminRole" label="可添加抖音账号数">
|
||||||
<a-input-number
|
<a-input-number
|
||||||
@@ -510,15 +632,6 @@ onMounted(() => {
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<div class="glass-card role-help">
|
|
||||||
<h3 style="margin-top: 0; color: #fff;">角色权限说明</h3>
|
|
||||||
<ul class="role-list">
|
|
||||||
<li><strong>管理员</strong>:管理所有抖音账号、用户、全局规则与系统日志</li>
|
|
||||||
<li><strong>运营</strong>:管理自己创建的抖音账号、规则与私信(不可见他人数据)</li>
|
|
||||||
<li><strong>只读</strong>:仅查看自己账号的数据,不可修改或发送</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -562,6 +675,33 @@ onMounted(() => {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.users-toolbar:hover,
|
||||||
|
.table-card:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-toolbar-meta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.gradient-btn {
|
.gradient-btn {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
@@ -614,19 +754,20 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.users-page :deep(.ant-table-thead > tr > th) {
|
.users-page :deep(.ant-table-thead > tr > th) {
|
||||||
background: rgba(255, 255, 255, 0.03) !important;
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
color: var(--text-secondary) !important;
|
color: #d1d5db !important;
|
||||||
border-bottom: 1px solid var(--border-light) !important;
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-page :deep(.ant-table-tbody > tr > td) {
|
.users-page :deep(.ant-table-tbody > tr > td) {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border-bottom: 1px solid var(--border-light) !important;
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
color: var(--text-primary);
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-page :deep(.ant-table-tbody > tr:hover > td) {
|
.users-page :deep(.ant-table-tbody > tr:hover > td) {
|
||||||
background: rgba(170, 59, 255, 0.05) !important;
|
background: rgba(170, 59, 255, 0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-card-head {
|
.user-card-head {
|
||||||
@@ -699,7 +840,20 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn {
|
.edit-btn {
|
||||||
color: #c084fc !important;
|
color: #d8b4fe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn:hover {
|
||||||
|
color: #f3e8ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page :deep(.ant-btn-dangerous.ant-btn-text) {
|
||||||
|
color: #fca5a5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page :deep(.ant-btn-dangerous.ant-btn-text:hover) {
|
||||||
|
color: #fecaca !important;
|
||||||
|
background: rgba(239, 68, 68, 0.12) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-mobile-pagination {
|
.users-mobile-pagination {
|
||||||
@@ -718,18 +872,6 @@ onMounted(() => {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-help {
|
|
||||||
margin-top: 24px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-list {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.8;
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-form :deep(.ant-form-item-label > label) {
|
.user-form :deep(.ant-form-item-label > label) {
|
||||||
color: var(--text-secondary) !important;
|
color: var(--text-secondary) !important;
|
||||||
}
|
}
|
||||||
@@ -753,14 +895,14 @@ onMounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-help {
|
.users-toolbar {
|
||||||
margin-top: 16px !important;
|
padding: 12px 16px;
|
||||||
padding: 16px !important;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-list {
|
.users-search-input {
|
||||||
padding-left: 18px;
|
max-width: none;
|
||||||
font-size: 0.88rem;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||