2026-09-03 12:40:05 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""IM 微服务客户端(opc-im-service,loopback)。
|
|
|
|
|
|
|
|
|
|
|
|
- 对外 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:
|
2026-09-05 03:40:58 +08:00
|
|
|
|
"""把用户 REST 请求转发到 im-service /api/v1/*,透传 JWT。
|
|
|
|
|
|
|
|
|
|
|
|
出口统一把 ``*avatar*`` 字段 resolve 为可访问直链(im-service 存的是
|
|
|
|
|
|
users 表原始对象路径 /oss/...,桌面/Web 无法直接渲染)。
|
|
|
|
|
|
"""
|
2026-09-03 12:40:05 +08:00
|
|
|
|
headers = {"Authorization": auth_header} if auth_header else {}
|
2026-09-05 03:40:58 +08:00
|
|
|
|
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
|
2026-09-03 12:40:05 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 内部同步(业务路由调用,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)}
|