This commit is contained in:
Your Name
2026-08-26 17:18:09 +08:00
parent 327a0bc42f
commit 4ac6990efe
20 changed files with 1336 additions and 130 deletions
+84 -2
View File
@@ -2,6 +2,7 @@ import os
import sys
import json
import asyncio
import ipaddress
import logging
import uuid
from datetime import datetime, timezone
@@ -93,6 +94,10 @@ from utils.cookie_store import (
analyze_cookie,
)
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")
from utils import system_logger
@@ -443,6 +448,8 @@ def _build_account_im_session(account: Account) -> DouyinImSession:
storage = json.loads(cookie_data) if cookie_data else {}
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.egress_public_ip = str(account.egress_public_ip or "").strip()
session.egress_auto_attempts = clamp_attempts(account.egress_auto_attempts)
return session
@@ -806,6 +813,8 @@ class AccountResponse(BaseModel):
follow_welcome_content: Optional[str] = None
user_agent: Optional[str] = None
user_agent_label: Optional[str] = None
egress_public_ip: Optional[str] = None
egress_auto_attempts: int = 1
quota_disabled: bool = False
class Config:
@@ -850,6 +859,10 @@ class AccountUpdate(BaseModel):
follow_welcome_enabled: Optional[bool] = None
follow_welcome_content: 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):
@@ -1074,6 +1087,8 @@ def _build_account_response(account: Account) -> AccountResponse:
follow_welcome_content=account.follow_welcome_content or None,
user_agent=account.user_agent or None,
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),
)
@@ -1203,12 +1218,54 @@ class DeviceProfileItem(BaseModel):
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])
async def get_device_profiles(user: User = Depends(get_current_user)):
"""可选的伪装设备头(User-Agent)预设列表。"""
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 路由接口 ---
# 1. 账号管理接口
@@ -1663,6 +1720,18 @@ async def update_account(
if body.user_agent is not None:
ua = (body.user_agent or "").strip()
account.user_agent = ua or None
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")
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()
await db.commit()
await db.refresh(account)
@@ -1671,6 +1740,13 @@ async def update_account(
invalidate = getattr(worker, "invalidate_follow_welcome_config", None)
if callable(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
runtime_session.egress_public_ip = str(account.egress_public_ip or "").strip()
runtime_session.egress_source_ip = ""
runtime_session.egress_auto_attempts = clamp_attempts(account.egress_auto_attempts)
return _build_account_response(account)
@@ -2912,6 +2988,9 @@ async def send_account_message(
db.add(failed_log)
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 = (
not session.keys_str
or not session.web_protect_str
@@ -2919,11 +2998,14 @@ async def send_account_message(
or "ticket" in (last_error or "")
or "签名密钥" 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:
msg = last_error or "缺少 IM 签名密钥"
if last_error == "INVALID_REQUEST":
if session_kicked:
msg = "抖音已踢下当前 IM 登录态(decision=KICK),请停止托管后用浏览器模式重新登录并打开私信页"
elif invalid_request:
msg = "IM 会话创建失败(INVALID_REQUEST),请停止托管后用浏览器模式重新登录并打开私信页"
return SendMessageResponse(
success=False,