805 lines
37 KiB
Python
805 lines
37 KiB
Python
# -*- 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
|