Files
kefu/wechat_rpa/remote_api.py
T
2026-09-29 15:52:07 +08:00

805 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""手机远程控制台与桌面命令通道的 HTTP 接口。
/api/v2/mobile/* 手机 / 平板 H5 用,ZYT 账号密码登录(终端 9,由本服务代登)
/api/v2/desktop/commands/* 桌面端长轮询领取指令、回执结果、顺带上报状态
/api/v2/desktop/blacklist* 桌面端同步服务端会话黑名单
手机令牌和桌面令牌分别由 ``mobile_sessions`` 和 ``desktop_sessions`` 签发,互相
不能冒用:手机端拿不到归档上传之类的桌面接口,桌面端也调不了手机接口。
"""
from __future__ import annotations
import asyncio
import ipaddress
import os
import threading
import time
from collections import defaultdict, deque
from typing import Any, Callable
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from pydantic import BaseModel, Field
import zyt_auth
from remote_store import (
COMMAND_TYPES,
MOBILE_IDENTITY_RECHECK_SECONDS,
PERM_BLACKLIST_ADD,
PERM_BLACKLIST_REMOVE,
PERM_REMOTE_ALL,
PERM_REMOTE_CONTROL,
RemoteStore,
account_label,
account_permissions,
valid_device_id,
)
# 桌面端长轮询最长挂起时间。uvicorn 停机时会等在途请求结束,这里必须明显短于
# compose 里 20 秒的 stop_grace_period,否则每次发版都会被强杀;指令本身靠进程内
# 通知即时送达,挂起时长只影响"没事时多久回来报一次状态"。
MAX_POLL_WAIT_SECONDS = 15
# 挂起期间多久回库复查一次;进程内通知失效(多进程部署)时的兜底。
POLL_RECHECK_SECONDS = 5
def _error(status_code: int, code: str, message: str, **extra: Any) -> HTTPException:
return HTTPException(status_code=status_code, detail={"code": code, "message": message, **extra})
def _bearer(request: Request) -> str:
raw = request.headers.get("authorization", "")
return raw[7:].strip() if raw.lower().startswith("bearer ") else ""
def trusted_client_ip(request: Request) -> str:
"""做安全判断用的客户端地址。
X-Forwarded-For 的第一段是客户端自己填的,可以伪造;X-Real-IP 由 Nginx 按
$remote_addr 覆盖写入,API 端口又只监听本机回环,只有 Nginx 能连进来。
"""
real_ip = request.headers.get("x-real-ip", "").strip()
if real_ip:
return real_ip
return request.client.host if request.client else ""
def _is_private_address(value: str) -> bool:
try:
address = ipaddress.ip_address(str(value or "").strip())
except ValueError:
return False
return address.is_private or address.is_loopback or address.is_link_local
def request_scheme(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
return forwarded or request.url.scheme.lower()
def https_required(request: Request) -> bool:
"""手机端登录是否必须走 HTTPS。
MOBILE_REQUIRE_HTTPS:never(默认,内外网都允许明文 HTTP 登录,按用户要求)/
auto(公网地址必须 HTTPS,内网可用 HTTP)/ always。配好 HTTPS 入口后建议改成
auto:明文 HTTP 下 ZYT 密码和手机会话令牌在公网上可被截获。
auto 模式下没有 X-Real-IP 时按"来源不明"处理:生产环境里直连 API 的对端是 Docker
网桥(172.x,看起来像内网),代理漏配头就会把公网请求误当内网放行。只有本机回环
(开发时直接访问 127.0.0.1)才在没有这个头时被信任。
"""
policy = os.environ.get("MOBILE_REQUIRE_HTTPS", "never").strip().lower()
if policy == "never" or request_scheme(request) == "https":
return False
if policy == "always":
return True
real_ip = request.headers.get("x-real-ip", "").strip()
if real_ip:
return not _is_private_address(real_ip)
peer = request.client.host if request.client else ""
try:
return not ipaddress.ip_address(peer).is_loopback
except ValueError:
return True
class LoginAttempt:
__slots__ = ("ip", "account", "finished")
def __init__(self, ip: str, account: str):
self.ip, self.account, self.finished = ip, account, False
class MobileLoginGuard:
"""手机登录限流。
ZYT 按请求来源 IP 计数锁定(默认 30 分钟内错 5 次锁 30 分钟),而手机端的
登录全部由本服务代发,在 ZYT 看来都来自服务器出口 IP——如果公司电脑也走同一
个出口,锁住的就是全公司的 ZYT 登录。所以这里除了按账号、按客户端 IP 限流,
还给"真正发到 ZYT 的密码错误"设了一个全局预算,并在 ZYT 已锁定时直接短路。
限流必须把"正在进行"的尝试也算进去:先检查、等 ZYT 回来再记账的话,一次并发
发来十几个请求会全部通过检查,一起打到 ZYT 上。`begin()` 在锁里占位,
`finish()` 按结果转成失败记录或释放。
"""
def __init__(
self, *, account_limit: int = 5, account_window: int = 600,
ip_limit: int = 10, ip_window: int = 600,
upstream_budget: int | None = None, upstream_window: int = 1800,
):
if upstream_budget is None:
try:
upstream_budget = int(os.environ.get("MOBILE_LOGIN_UPSTREAM_FAILURE_BUDGET", "4"))
except ValueError:
upstream_budget = 4
self.account_limit, self.account_window = account_limit, account_window
self.ip_limit, self.ip_window = ip_limit, ip_window
self.upstream_budget, self.upstream_window = max(0, upstream_budget), upstream_window
self._accounts: dict[str, deque[float]] = {}
self._ips: dict[str, deque[float]] = {}
self._upstream: deque[float] = deque()
self._pending_accounts: dict[str, int] = defaultdict(int)
self._pending_ips: dict[str, int] = defaultdict(int)
self._pending_upstream = 0
self._locked_until = 0.0
self._lock = threading.Lock()
@staticmethod
def _recent(book: dict[str, deque[float]], key: str, window: int, now: float) -> deque[float]:
"""剪掉过期记录;空了就把这个键删掉,随机账号名刷不大内存。"""
items = book.get(key)
if items is None:
return deque()
while items and items[0] <= now - window:
items.popleft()
if not items:
del book[key]
return items
@staticmethod
def _minutes(seconds: float) -> int:
return max(1, int((seconds + 59) // 60))
def begin(self, ip: str, account: str) -> LoginAttempt:
now = time.time()
with self._lock:
if self._locked_until > now:
wait = self._locked_until - now
raise _error(
429, "zyt_locked",
f"ZYT 已暂时锁定登录,请 {self._minutes(wait)} 分钟后再试",
retry_after=int(wait),
)
failures = self._recent(self._accounts, account, self.account_window, now)
if len(failures) >= self.account_limit:
wait = failures[0] + self.account_window - now
raise _error(
429, "rate_limited",
f"该账号登录失败次数过多,请 {self._minutes(wait)} 分钟后再试",
retry_after=int(wait),
)
if len(failures) + self._pending_accounts.get(account, 0) >= self.account_limit:
raise _error(429, "rate_limited", "该账号正在登录,请稍候几秒再试", retry_after=5)
failures = self._recent(self._ips, ip, self.ip_window, now)
if len(failures) + self._pending_ips.get(ip, 0) >= self.ip_limit:
wait = (failures[0] + self.ip_window - now) if failures else 5
raise _error(
429, "rate_limited",
f"登录尝试过于频繁,请 {self._minutes(wait)} 分钟后再试",
retry_after=int(wait),
)
while self._upstream and self._upstream[0] <= now - self.upstream_window:
self._upstream.popleft()
if self.upstream_budget:
if len(self._upstream) >= self.upstream_budget:
wait = self._upstream[0] + self.upstream_window - now
raise _error(
429, "upstream_budget",
"近期手机端密码错误次数较多,为避免 ZYT 锁定服务器出口 IP,"
f"已暂停手机登录,请 {self._minutes(wait)} 分钟后再试",
retry_after=int(wait),
)
if len(self._upstream) + self._pending_upstream >= self.upstream_budget:
raise _error(429, "rate_limited", "同时登录的人较多,请几秒后再试", retry_after=5)
self._pending_accounts[account] += 1
self._pending_ips[ip] += 1
self._pending_upstream += 1
return LoginAttempt(ip, account)
def finish(self, attempt: LoginAttempt, outcome: str) -> None:
"""outcome:credentials(密码错)、unknown_account(账号不存在)、success(ZYT 认可了密码)、
other(网络、终端未开通等,不记账)。"""
if attempt.finished:
return
attempt.finished = True
now = time.time()
with self._lock:
for book, key in ((self._pending_accounts, attempt.account), (self._pending_ips, attempt.ip)):
book[key] -= 1
if book[key] <= 0:
del book[key]
self._pending_upstream = max(0, self._pending_upstream - 1)
if outcome in ("credentials", "unknown_account"):
self._accounts.setdefault(attempt.account, deque()).append(now)
self._ips.setdefault(attempt.ip, deque()).append(now)
# ZYT 只给"账号存在、密码不对"累计出口 IP 的失败次数。
if outcome == "credentials":
self._upstream.append(now)
elif outcome == "success":
self._accounts.pop(attempt.account, None)
# 密码校验通过时 ZYT 会清零该出口 IP 的失败计数(AdminAccountSafeCache::relieve)。
self._upstream.clear()
def locked(self, seconds: int) -> None:
with self._lock:
self._locked_until = max(self._locked_until, time.time() + max(60, int(seconds)))
class ZytMobileAuth:
"""默认实现:真的去调 ZYT。测试里换成假的。"""
def __init__(self, api_url: Callable[[], str]):
self._api_url = api_url
def login(self, account: str, password: str) -> str:
return zyt_auth.login_admin(
account, password, terminal=zyt_auth.WECOM_RPA_MOBILE_TERMINAL, api_url=self._api_url()
)
def identity(self, token: str) -> dict[str, Any]:
return zyt_auth.verify_admin_session(
token, terminal=zyt_auth.WECOM_RPA_MOBILE_TERMINAL, api_url=self._api_url()
)
def logout(self, token: str) -> None:
zyt_auth.logout_admin(token, api_url=self._api_url())
class _Waiter:
__slots__ = ("key", "loop", "event")
def __init__(self, key: tuple[int, str]):
self.key = key
self.loop = asyncio.get_running_loop()
self.event = asyncio.Event()
async def wait(self, timeout: float) -> None:
try:
await asyncio.wait_for(self.event.wait(), timeout=max(0.0, timeout))
except asyncio.TimeoutError:
pass
class CommandNotifier:
"""进程内唤醒:有新指令或黑名单变化时,立刻结束对应电脑挂着的长轮询。
先登记再查库:登记和查库之间落库的指令也能把它叫醒,不会白等一个复查周期。
唤醒走 ``call_soon_threadsafe``,发通知的请求和挂起的长轮询不在同一个事件
循环(线程)里也不会漏掉。
"""
def __init__(self) -> None:
self._waiters: dict[tuple[int, str], set[_Waiter]] = {}
self._lock = threading.Lock()
def register(self, account_id: int, device_id: str) -> _Waiter:
waiter = _Waiter((int(account_id), str(device_id)))
with self._lock:
self._waiters.setdefault(waiter.key, set()).add(waiter)
return waiter
def unregister(self, waiter: _Waiter) -> None:
with self._lock:
waiters = self._waiters.get(waiter.key)
if waiters is not None:
waiters.discard(waiter)
if not waiters:
self._waiters.pop(waiter.key, None)
def notify(self, account_id: int, device_id: str | None = None) -> None:
with self._lock:
targets = [
waiter
for key, waiters in self._waiters.items()
if key[0] == int(account_id) and (device_id is None or key[1] == device_id)
for waiter in waiters
]
for waiter in targets:
try:
waiter.loop.call_soon_threadsafe(waiter.event.set)
except RuntimeError:
# 事件循环已经关闭(请求刚好结束),没有人需要被叫醒。
continue
class MobilePrincipal:
def __init__(self, row: dict[str, Any], token: str):
self.row = row
self.token = token
self.permissions = account_permissions(row)
@property
def account_id(self) -> int:
return int(self.row["id"])
@property
def session_id(self) -> int:
return int(self.row["session_id"])
@property
def label(self) -> str:
return f"{account_label(self.row)}(手机)"
def me(self) -> dict[str, Any]:
return {
"account": {
"id": self.account_id,
"nickname": str(self.row.get("nickname") or ""),
"sn": str(self.row.get("zyt_sn") or ""),
"avatar": str(self.row.get("avatar") or ""),
},
"permissions": {
"control": self.permissions["control"],
"control_all": self.permissions["control_all"],
"blacklist_add": self.permissions["blacklist_add"],
"blacklist_remove": self.permissions["blacklist_remove"],
"root": self.permissions["root"],
"known": self.permissions["known"],
},
"permission_codes": {
"control": PERM_REMOTE_CONTROL,
"control_all": PERM_REMOTE_ALL,
"blacklist_add": PERM_BLACKLIST_ADD,
"blacklist_remove": PERM_BLACKLIST_REMOVE,
},
"session": {
"created_at": str(self.row.get("session_created_at") or ""),
"expires_at": int(self.row.get("session_expires_at") or 0),
},
}
# ── 请求体 ───────────────────────────────────────────────────────────────────
class MobileLoginBody(BaseModel):
account: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=128, repr=False)
client_name: str = Field(default="", max_length=120)
class CommandBody(BaseModel):
type: str = Field(min_length=1, max_length=32)
class BlacklistBody(BaseModel):
name: str = Field(default="", max_length=191)
account_name: str = Field(default="", max_length=191)
reason: str = Field(default="", max_length=200)
class StatusBody(BaseModel):
listening: bool = False
state: str = Field(default="", max_length=32)
transport: str = Field(default="", max_length=16)
wecom_connected: bool = False
pending_count: int = Field(default=0, ge=0, le=1_000_000)
app_version: str = Field(default="", max_length=64)
capabilities: list[str] = Field(default_factory=list, max_length=16)
class DesktopPollBody(BaseModel):
wait: int = Field(default=15, ge=0, le=60)
status: StatusBody | None = None
# 桌面端当前持有的黑名单版本;与服务端不一致时立即返回,让它去同步。
blacklist_version: int | None = Field(default=None, ge=0)
class AckBody(BaseModel):
ok: bool
result: dict[str, Any] = Field(default_factory=dict)
error: str = Field(default="", max_length=500)
status: StatusBody | None = None
class DesktopBlacklistSyncBody(BaseModel):
add: list[dict[str, Any]] = Field(default_factory=list, max_length=500)
def register_remote_routes(
app: FastAPI,
database: Any,
*,
current_desktop: Callable[..., Any],
zyt_api_url: Callable[[], str],
zyt_mobile_auth: Any = None,
login_guard: MobileLoginGuard | None = None,
) -> RemoteStore:
store = RemoteStore(database)
store.initialize()
app.state.remote_store = store
notifier = CommandNotifier()
app.state.remote_notifier = notifier
guard = login_guard or MobileLoginGuard()
auth = zyt_mobile_auth or ZytMobileAuth(zyt_api_url)
identity_locks: dict[int, asyncio.Lock] = {}
# ── 手机端鉴权 ───────────────────────────────────────────────────────
async def _recheck_identity(row: dict[str, Any], request: Request) -> dict[str, Any] | None:
# 同一会话并发的几个请求只复核一次;用完即删,锁表不随会话数增长。
session_id = int(row["session_id"])
lock = identity_locks.setdefault(session_id, asyncio.Lock())
try:
async with lock:
return await _recheck_identity_locked(session_id, request)
finally:
if not lock.locked() and identity_locks.get(session_id) is lock:
identity_locks.pop(session_id, None)
async def _recheck_identity_locked(session_id: int, request: Request) -> dict[str, Any] | None:
fresh = await asyncio.to_thread(store.mobile_session, _bearer(request))
if fresh is None:
return None
if time.time() - int(fresh["identity_checked_at"] or 0) < MOBILE_IDENTITY_RECHECK_SECONDS:
return fresh
zyt_token = await asyncio.to_thread(store.mobile_zyt_token, session_id)
try:
if not zyt_token:
raise zyt_auth.ZytLoginError("ZYT 会话缺失", kind="rejected")
identity = await asyncio.to_thread(auth.identity, zyt_token)
if str(identity.get("user_id") or "") != str(fresh["zyt_user_id"]):
raise zyt_auth.ZytLoginError("ZYT 身份与手机会话不一致", kind="rejected")
except zyt_auth.ZytAuthError as exc:
if getattr(exc, "kind", "") == "unavailable":
# ZYT 暂时连不上不算下线:沿用上次同步的权限,一分钟后再试。
await asyncio.to_thread(store.mark_identity_checked, session_id, retry_in=60)
return fresh
await asyncio.to_thread(
store.revoke_mobile_session, session_id=session_id,
reason="zyt_invalid", ip=trusted_client_ip(request),
)
return None
try:
await asyncio.to_thread(store.refresh_identity, session_id, identity)
except PermissionError:
await asyncio.to_thread(
store.revoke_mobile_session, session_id=session_id,
reason="account_disabled", ip=trusted_client_ip(request),
)
return None
return await asyncio.to_thread(store.mobile_session, _bearer(request))
async def current_mobile(request: Request) -> MobilePrincipal:
token = _bearer(request)
row = await asyncio.to_thread(store.mobile_session, token) if token else None
if row is not None and time.time() - int(row["identity_checked_at"] or 0) >= MOBILE_IDENTITY_RECHECK_SECONDS:
row = await _recheck_identity(row, request)
if row is None:
raise _error(401, "session_expired", "登录已失效,请重新登录")
return MobilePrincipal(row, token)
def target_account(principal: MobilePrincipal, account_id: int | None) -> dict[str, Any]:
if account_id is None or int(account_id) == principal.account_id:
return principal.row
if not principal.permissions["control_all"]:
raise _error(403, "forbidden", "只能查看和控制自己的电脑")
account = store.account(int(account_id))
if account is None or str(account.get("status")) != "active":
raise _error(404, "account_not_found", "客服账号不存在或已停用")
return account
# ── 手机端:登录 ─────────────────────────────────────────────────────
@app.post("/api/v2/mobile/auth/login")
async def mobile_login(body: MobileLoginBody, request: Request) -> dict:
ip = trusted_client_ip(request)
if https_required(request):
raise _error(403, "https_required", "请通过 HTTPS 地址访问手机控制台,明文连接不能提交密码")
account_key = body.account.strip().lower()
attempt = guard.begin(ip, account_key)
outcome = "other"
try:
zyt_token = await asyncio.to_thread(auth.login, body.account.strip(), body.password)
outcome = "success"
except zyt_auth.ZytLoginError as exc:
if exc.kind in ("credentials", "unknown_account"):
outcome = exc.kind
database.audit(None, "mobile.login.failed", f"account={account_key[:64]}", ip)
raise _error(401, "invalid_credentials", "账号或密码错误") from exc
if exc.kind == "password_change_required":
outcome = "success"
raise _error(403, "password_change_required", str(exc)) from exc
if exc.kind == "locked":
guard.locked(exc.retry_after or 1800)
database.audit(None, "mobile.login.locked", f"account={account_key[:64]}", ip)
raise _error(429, "zyt_locked", str(exc), retry_after=exc.retry_after) from exc
if exc.kind == "disabled":
raise _error(403, "account_disabled", str(exc)) from exc
if exc.kind == "terminal_unsupported":
raise _error(503, "terminal_unsupported", str(exc)) from exc
if exc.kind == "unavailable":
raise _error(502, "zyt_unavailable", "ZYT 登录服务暂不可用,请稍后重试") from exc
raise _error(400, "login_rejected", str(exc)) from exc
except zyt_auth.ZytAuthError as exc:
raise _error(502, "zyt_unavailable", "ZYT 登录服务暂不可用,请稍后重试") from exc
finally:
guard.finish(attempt, outcome)
try:
identity = await asyncio.to_thread(auth.identity, zyt_token)
account = await asyncio.to_thread(store.upsert_account, identity)
except zyt_auth.ZytLoginError as exc:
await asyncio.to_thread(auth.logout, zyt_token)
status_code = {"terminal_unsupported": 503, "disabled": 403}.get(exc.kind, 502)
code = {"terminal_unsupported": "terminal_unsupported",
"disabled": "account_disabled"}.get(exc.kind, "zyt_unavailable")
raise _error(status_code, code, str(exc)) from exc
except zyt_auth.ZytAuthError as exc:
await asyncio.to_thread(auth.logout, zyt_token)
raise _error(502, "zyt_unavailable", str(exc)) from exc
except PermissionError as exc:
await asyncio.to_thread(auth.logout, zyt_token)
raise _error(403, "account_disabled", str(exc)) from exc
except ValueError as exc:
await asyncio.to_thread(auth.logout, zyt_token)
raise _error(502, "zyt_unavailable", str(exc)) from exc
token, session = await asyncio.to_thread(
store.create_mobile_session, int(account["id"]), zyt_token=zyt_token,
client_name=body.client_name, user_agent=request.headers.get("user-agent", ""), ip=ip,
)
row = await asyncio.to_thread(store.mobile_session, token)
principal = MobilePrincipal(row, token)
return {"access_token": token, "expires_at": session["expires_at"], "me": principal.me()}
@app.post("/api/v2/mobile/auth/logout")
async def mobile_logout(request: Request, principal: MobilePrincipal = Depends(current_mobile)) -> dict:
zyt_token = await asyncio.to_thread(store.mobile_zyt_token, principal.session_id)
await asyncio.to_thread(
store.revoke_mobile_session, session_id=principal.session_id, reason="logout",
ip=trusted_client_ip(request),
)
if zyt_token:
await asyncio.to_thread(auth.logout, zyt_token)
return {"ok": True}
@app.get("/api/v2/mobile/me")
async def mobile_me(principal: MobilePrincipal = Depends(current_mobile)) -> dict:
return principal.me()
# ── 手机端:电脑 ─────────────────────────────────────────────────────
@app.get("/api/v2/mobile/accounts")
async def mobile_accounts(
q: str = Query(default="", max_length=64),
principal: MobilePrincipal = Depends(current_mobile),
) -> dict:
if not principal.permissions["control_all"]:
raise _error(403, "forbidden", f"查看全部客服电脑需要 ZYT 权限 {PERM_REMOTE_ALL}")
return {"items": await asyncio.to_thread(store.accounts_overview, q)}
@app.get("/api/v2/mobile/desktops")
async def mobile_desktops(
account_id: int | None = Query(default=None, ge=1),
principal: MobilePrincipal = Depends(current_mobile),
) -> dict:
account = target_account(principal, account_id)
items = await asyncio.to_thread(store.devices, int(account["id"]))
return {
"account": {
"id": int(account["id"]),
"nickname": str(account.get("nickname") or ""),
"sn": str(account.get("zyt_sn") or ""),
},
"items": items,
"server_time": int(time.time()),
}
@app.post("/api/v2/mobile/desktops/{device_id}/commands")
async def mobile_create_command(
device_id: str, body: CommandBody, request: Request,
account_id: int | None = Query(default=None, ge=1),
principal: MobilePrincipal = Depends(current_mobile),
) -> dict:
account = target_account(principal, account_id)
if not principal.permissions["control"]:
raise _error(403, "forbidden", f"远程启停需要 ZYT 权限 {PERM_REMOTE_CONTROL}")
if body.type not in COMMAND_TYPES:
raise _error(400, "invalid_command", "不支持的指令")
try:
device_id = valid_device_id(device_id)
except ValueError as exc:
raise _error(404, "device_not_found", "电脑不存在") from exc
device = await asyncio.to_thread(store.device, int(account["id"]), device_id)
if device is None:
raise _error(404, "device_not_found", "电脑不存在或已被管理端下线")
if not device["online"]:
raise _error(409, "device_offline", "电脑离线:客服软件没有运行,无法远程拉起")
if not device["remote_capable"]:
raise _error(409, "client_outdated", "该电脑上的客服软件版本过低,请升级后再远程控制")
try:
command = await asyncio.to_thread(
store.create_command, account_id=int(account["id"]), device_id=device_id,
command_type=body.type, actor_account_id=principal.account_id,
actor_label=principal.label, ip=trusted_client_ip(request),
)
except LookupError as exc:
raise _error(404, "device_not_found", str(exc)) from exc
notifier.notify(int(account["id"]), device_id)
return {"command": command}
@app.get("/api/v2/mobile/commands/{command_id}")
async def mobile_command(
command_id: str, principal: MobilePrincipal = Depends(current_mobile),
) -> dict:
command = await asyncio.to_thread(store.command, command_id)
if command is None:
raise _error(404, "command_not_found", "指令不存在")
if command["account_id"] != principal.account_id and not principal.permissions["control_all"]:
raise _error(404, "command_not_found", "指令不存在")
command.pop("account_id", None)
return {"command": command}
# ── 手机端:会话与黑名单 ─────────────────────────────────────────────
@app.get("/api/v2/mobile/conversations")
async def mobile_conversations(
account_id: int | None = Query(default=None, ge=1),
q: str = Query(default="", max_length=100),
scope: str = Query(default="all", pattern="^(all|blocked)$"),
cursor: str = Query(default="", max_length=200),
limit: int = Query(default=30, ge=1, le=100),
principal: MobilePrincipal = Depends(current_mobile),
) -> dict:
account = target_account(principal, account_id)
try:
return await asyncio.to_thread(
store.conversations, str(account["tenant_id"]), query=q, scope=scope,
cursor=cursor, limit=limit,
)
except ValueError as exc:
raise _error(400, "invalid_cursor", str(exc)) from exc
@app.put("/api/v2/mobile/blacklist/{account}/{conversation:path}")
async def mobile_blacklist_add(
account: str, conversation: str, body: BlacklistBody, request: Request,
account_id: int | None = Query(default=None, ge=1),
principal: MobilePrincipal = Depends(current_mobile),
) -> dict:
target = target_account(principal, account_id)
if not principal.permissions["blacklist_add"]:
raise _error(403, "forbidden", f"加入黑名单需要 ZYT 权限 {PERM_BLACKLIST_ADD}")
try:
entry, version = await asyncio.to_thread(
store.add_blacklist, str(target["tenant_id"]),
{"account": account, "conversation": conversation, **body.model_dump()},
source="mobile", actor_label=principal.label,
actor_account_id=principal.account_id, ip=trusted_client_ip(request),
)
except ValueError as exc:
raise _error(400, "invalid_conversation", str(exc)) from exc
notifier.notify(int(target["id"]))
return {"entry": entry, "version": version}
@app.delete("/api/v2/mobile/blacklist/{account}/{conversation:path}")
async def mobile_blacklist_remove(
account: str, conversation: str, request: Request,
account_id: int | None = Query(default=None, ge=1),
principal: MobilePrincipal = Depends(current_mobile),
) -> dict:
target = target_account(principal, account_id)
if not principal.permissions["blacklist_remove"]:
raise _error(403, "forbidden", f"移出黑名单需要 ZYT 权限 {PERM_BLACKLIST_REMOVE},请联系主管")
try:
version = await asyncio.to_thread(
store.remove_blacklist, str(target["tenant_id"]), account, conversation,
source="mobile", actor_label=principal.label, ip=trusted_client_ip(request),
)
except LookupError as exc:
raise _error(404, "not_blocked", str(exc)) from exc
except ValueError as exc:
raise _error(400, "invalid_conversation", str(exc)) from exc
notifier.notify(int(target["id"]))
return {"ok": True, "version": version}
# ── 桌面端:命令通道 ─────────────────────────────────────────────────
def _desktop_label(account: Any) -> str:
return f"{account_label(account)}(电脑 {str(account['device_name'] or '')[:40]})"
async def _report(account: Any, status: StatusBody | None) -> None:
if status is None:
return
await asyncio.to_thread(
store.report_status, int(account["id"]), str(account["device_id"]),
status.model_dump(), app_version=str(account["app_version"] or ""),
)
@app.post("/api/v2/desktop/commands/poll")
async def desktop_poll(
body: DesktopPollBody, request: Request, account: Any = Depends(current_desktop),
) -> dict:
account_id = int(account["id"])
device_id = str(account["device_id"])
tenant = str(account["tenant_id"])
await _report(account, body.status)
wait = float(min(body.wait, MAX_POLL_WAIT_SECONDS))
deadline = time.monotonic() + wait
while True:
waiter = notifier.register(account_id, device_id)
try:
commands = await asyncio.to_thread(store.take_commands, account_id, device_id)
version = await asyncio.to_thread(store.blacklist_version, tenant)
remaining = deadline - time.monotonic()
if (
commands
or (body.blacklist_version is not None and version != body.blacklist_version)
or remaining <= 0
or await request.is_disconnected()
):
break
await waiter.wait(min(remaining, POLL_RECHECK_SECONDS))
finally:
notifier.unregister(waiter)
return {"commands": commands, "blacklist_version": version, "server_time": int(time.time())}
@app.post("/api/v2/desktop/commands/{command_id}/ack")
async def desktop_ack(
command_id: str, body: AckBody, request: Request, account: Any = Depends(current_desktop),
) -> dict:
await _report(account, body.status)
try:
command = await asyncio.to_thread(
store.ack_command, account_id=int(account["id"]),
device_id=str(account["device_id"]), command_id=command_id, ok=body.ok,
result=body.result, error=body.error, ip=trusted_client_ip(request),
)
except LookupError as exc:
raise _error(404, "command_not_found", str(exc)) from exc
return {"ok": True, "command": command}
@app.post("/api/v2/desktop/status")
async def desktop_status(body: StatusBody, account: Any = Depends(current_desktop)) -> dict:
await _report(account, body)
return {"ok": True}
# ── 桌面端:黑名单 ───────────────────────────────────────────────────
@app.get("/api/v2/desktop/blacklist")
async def desktop_blacklist(account: Any = Depends(current_desktop)) -> dict:
version, items = await asyncio.to_thread(store.blacklist, str(account["tenant_id"]))
return {"version": version, "items": items}
@app.post("/api/v2/desktop/blacklist/sync")
async def desktop_blacklist_sync(
body: DesktopBlacklistSyncBody, request: Request, account: Any = Depends(current_desktop),
) -> dict:
# 电脑前的人加黑名单不要求 ZYT 权限:拉黑只会让系统少发消息,是安全方向;
# 离线时加的、升级前本地就有的条目也都从这里补传。
before = await asyncio.to_thread(store.blacklist_version, str(account["tenant_id"]))
version, items, rejected = await asyncio.to_thread(
store.sync_desktop_blacklist, str(account["tenant_id"]), body.add,
actor_label=_desktop_label(account), actor_account_id=int(account["id"]),
ip=trusted_client_ip(request),
)
if version != before:
notifier.notify(int(account["id"]))
return {"version": version, "items": items, "rejected": rejected}
@app.delete("/api/v2/desktop/blacklist/{account_key}/{conversation:path}")
async def desktop_blacklist_remove(
account_key: str, conversation: str, request: Request,
account: Any = Depends(current_desktop),
) -> dict:
if not account_permissions(account)["blacklist_remove"]:
raise _error(
403, "forbidden",
f"移出黑名单需要 ZYT 权限 {PERM_BLACKLIST_REMOVE},请联系主管在手机控制台操作",
)
try:
version = await asyncio.to_thread(
store.remove_blacklist, str(account["tenant_id"]), account_key, conversation,
source="desktop", actor_label=_desktop_label(account), ip=trusted_client_ip(request),
)
except LookupError as exc:
raise _error(404, "not_blocked", str(exc)) from exc
except ValueError as exc:
raise _error(400, "invalid_conversation", str(exc)) from exc
notifier.notify(int(account["id"]))
return {"ok": True, "version": version}
return store