Files
server-core/app/im/client.py
T

138 lines
4.9 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""IM 微服务客户端(opc-im-serviceloopback)。
- 对外 REST 转发:server-core /im/{path} → im-service /api/v1/{path}(透传用户 JWT
- 内部同步:任务群 / 园区群 / 客服通知(带 IM_INTERNAL_TOKEN
- WS 桥接在 router.py 实现
"""
from __future__ import annotations
import asyncio
import httpx
from .. import config
class IMError(Exception):
"""IM 服务调用异常。"""
def _base() -> str:
return config.IM_BASE_URL.rstrip("/")
def _internal_headers() -> dict[str, str]:
if not config.IM_INTERNAL_TOKEN:
return {}
return {"X-IM-Token": config.IM_INTERNAL_TOKEN}
async def _request(*, method: str, path: str, headers: dict[str, str] | None = None,
json_body: dict | None = None, params: dict | None = None) -> dict:
"""带超时/重试的 IM 服务请求;2xx 返回 JSON,否则抛 IMError。"""
url = f"{_base()}{path}"
last: Exception | None = None
for _ in range(max(1, config.IM_RETRIES + 1)):
try:
async with httpx.AsyncClient(timeout=config.IM_TIMEOUT) as client:
resp = await client.request(method, url, json=json_body, headers=headers, params=params)
if resp.status_code == 200:
try:
return resp.json() if resp.content else {}
except Exception: # noqa: BLE001
return {}
if resp.status_code < 500:
raise IMError(f"im-service {resp.status_code}: {resp.text[:200]}")
last = IMError(f"im-service {resp.status_code}: {resp.text[:200]}")
except httpx.HTTPError as exc:
last = IMError(f"im-service request failed: {exc}")
except IMError as exc:
last = exc
if " 4" in str(exc) or " 3" in str(exc):
break
await asyncio.sleep(0.2)
raise last or IMError("im-service unavailable")
async def forward(api_path: str, *, auth_header: str, method: str = "GET",
body: dict | None = None, params: dict | None = None) -> dict:
"""把用户 REST 请求转发到 im-service /api/v1/*,透传 JWT。
出口统一把 ``*avatar*`` 字段 resolve 为可访问直链(im-service 存的是
users 表原始对象路径 /oss/...,桌面/Web 无法直接渲染)。
"""
headers = {"Authorization": auth_header} if auth_header else {}
data = await _request(method=method, path=f"/api/v1{api_path}", headers=headers, json_body=body, params=params)
return resolve_avatar_fields(data)
def resolve_avatar_fields(obj):
"""递归把 dict/list 中所有键含 ``avatar`` 的字符串字段补全为 CDN 直链。
覆盖 im-service 返回的:conversation.avatar / peer.avatar / members[].avatar /
message.sender_avatar / friend.from_avatar 等。非 /oss 与完整 URL 由 resolve_url 原样处理。
"""
from ..infrastructure.oss import resolve_url
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if isinstance(k, str) and "avatar" in k.lower() and isinstance(v, str):
out[k] = resolve_url(v)
elif isinstance(v, (dict, list)):
out[k] = resolve_avatar_fields(v)
else:
out[k] = v
return out
if isinstance(obj, list):
return [resolve_avatar_fields(i) for i in obj]
return obj
# ── 内部同步(业务路由调用,fire-and-forget 不阻断主流程) ─────
async def sync_task_group(task_id: str, task_title: str = "") -> bool:
"""任务发布/接单/中标后自动建/刷新任务群。失败仅记录,不抛给主流程。"""
try:
await _request(
method="POST", path="/internal/task-sync",
headers=_internal_headers(),
json_body={"task_id": task_id, "task_title": task_title},
)
return True
except IMError:
return False
async def sync_park_group(park_id: str, park_name: str = "") -> bool:
"""确保园区公共群存在。"""
try:
await _request(
method="POST", path="/internal/park-sync",
headers=_internal_headers(),
json_body={"park_id": park_id, "park_name": park_name},
)
return True
except IMError:
return False
async def internal_mqtt_credentials(user_id: str) -> dict:
"""登录时生成/更新用户 MQTT 凭证并同步 EMQX(随登录响应下发)。
生成新的一次性密码 → im-service 写 im_mqtt_credentials → 同步 EMQX。
返回 dict 含 broker_ws_url/username/password/client_id/topic_prefix/presence_topic。
"""
return await _request(
method="POST", path="/internal/mqtt-credentials",
headers=_internal_headers(),
json_body={"user_id": str(user_id)},
)
async def health() -> dict:
try:
return await _request(method="GET", path="/health")
except IMError as exc:
return {"ok": False, "detail": str(exc)}