diff --git a/digital-avatar-app/backend/services/__init__.py b/digital-avatar-app/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/digital-avatar-app/backend/services/boxim_client.py b/digital-avatar-app/backend/services/boxim_client.py new file mode 100644 index 0000000..ce3071b --- /dev/null +++ b/digital-avatar-app/backend/services/boxim_client.py @@ -0,0 +1,74 @@ +"""盒子 IM 客户端 — 封装网易云信 IM 接口调用""" +import hashlib +import random +import string +from datetime import datetime +from typing import Optional + +import httpx + + +class BoxIMClient: + """盒子 IM 客户端,通过会会平台网关调用网易云信 IM""" + + def __init__(self, config: dict): + self.base_url = config.get("HUIHUI_IM_BASE_URL", "http://192.168.1.200:60040") + self.app_id = config.get("HUIHUI_APP_ID", "") + self.access_id = config.get("HUIHUI_ACCESS_ID", "") + self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "") + + def _build_sign_params(self, extra: dict) -> dict: + """构建带签名的请求参数(复用 news_service 签名模式)""" + nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=12)) + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") # 24小时制 + params = { + "appId": self.app_id, + "accessId": self.access_id, + "nonce": nonce, + "timestamp": timestamp, + **extra, + } + # 计算签名 — 排序 key, 过滤空值, 拼接后加 accessSecret, MD5 大写 + keys = sorted(params.keys()) + sign_parts = [] + for k in keys: + v = params.get(k) + if v and v != "" and v != []: + sign_parts.append(f"{k}={v}") + sign_str = "&".join(sign_parts) + f"&accessSecret={self.access_secret}" + signature = hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper() + params["signature"] = signature + params["signType"] = "MD5" + params["signVersion"] = "1.0" + return params + + async def get_credentials(self, user_id: str) -> Optional[dict]: + """获取用户的网易云信 IM 凭证 (accid, token)""" + params = self._build_sign_params({"userId": user_id}) + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + f"{self.base_url}/box/netease", + params=params, + ) + data = r.json() + if data.get("code") in (0, 200): + return data.get("data", {}) + return None + + async def send_p2p_message( + self, from_accid: str, to_accid: str, content: str + ) -> bool: + """发送单聊消息(文本)""" + params = self._build_sign_params({ + "from": from_accid, + "to": to_accid, + "msgType": "text", + "content": content, + }) + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + f"{self.base_url}/box/message/send/p2p", + params=params, + ) + data = r.json() + return data.get("code") in (0, 200) diff --git a/digital-avatar-app/backend/tests/test_boxim_client.py b/digital-avatar-app/backend/tests/test_boxim_client.py new file mode 100644 index 0000000..230bde2 --- /dev/null +++ b/digital-avatar-app/backend/tests/test_boxim_client.py @@ -0,0 +1,112 @@ +"""Tests for the Box IM client (Netease Yunxin gateway wrapper).""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + + +@pytest.fixture +def mock_config(): + return { + "HUIHUI_IM_BASE_URL": "http://192.168.1.200:60040", + "HUIHUI_APP_ID": "test_app", + "HUIHUI_ACCESS_ID": "test_access", + "HUIHUI_ACCESS_SECRET": "test_secret", + } + + +def _make_mock_response(json_data: dict): + """Create a properly configured mock for httpx.Response.""" + mock_response = MagicMock() + mock_response.json.return_value = json_data + return mock_response + + +def _patch_httpx_client(json_data: dict): + """Patch httpx.AsyncClient so that `async with httpx.AsyncClient() as c: await c.post(...)` returns json_data.""" + mock_client = AsyncMock() + mock_client.post.return_value = _make_mock_response(json_data) + + mock_cm = AsyncMock() + mock_cm.__aenter__.return_value = mock_client + mock_cm.__aexit__.return_value = None + + return patch("httpx.AsyncClient", return_value=mock_cm) + + +@pytest.mark.asyncio +async def test_get_credentials(mock_config): + """get_credentials should return accid and token from the gateway response.""" + with _patch_httpx_client({"code": 200, "data": {"accid": "user123", "token": "tok_xyz"}}): + from services.boxim_client import BoxIMClient + + client = BoxIMClient(mock_config) + result = await client.get_credentials("user123") + + assert result["accid"] == "user123" + assert result["token"] == "tok_xyz" + + +@pytest.mark.asyncio +async def test_send_p2p_message_success(mock_config): + """send_p2p_message should return True when the gateway responds with code 200.""" + with _patch_httpx_client({"code": 200}): + from services.boxim_client import BoxIMClient + + client = BoxIMClient(mock_config) + result = await client.send_p2p_message("owner_acc", "target_acc", "Hello") + + assert result is True + + +@pytest.mark.asyncio +async def test_send_p2p_message_failure(mock_config): + """send_p2p_message should return False when the gateway responds with a non-200 code.""" + with _patch_httpx_client({"code": 500, "message": "error"}): + from services.boxim_client import BoxIMClient + + client = BoxIMClient(mock_config) + result = await client.send_p2p_message("owner_acc", "target_acc", "Hello") + + assert result is False + + +@pytest.mark.asyncio +async def test_get_credentials_returns_none_on_error(mock_config): + """get_credentials should return None when the gateway responds with an error code.""" + with _patch_httpx_client({"code": 500, "message": "user not found"}): + from services.boxim_client import BoxIMClient + + client = BoxIMClient(mock_config) + result = await client.get_credentials("nonexistent") + + assert result is None + + +def test_build_sign_params_contains_required_fields(mock_config): + """_build_sign_params should produce appId, accessId, nonce, timestamp, signature, signType, signVersion.""" + from services.boxim_client import BoxIMClient + + client = BoxIMClient(mock_config) + params = client._build_sign_params({"userId": "u1"}) + + assert "appId" in params + assert "accessId" in params + assert "nonce" in params + assert "timestamp" in params + assert "signature" in params + assert params["signType"] == "MD5" + assert params["signVersion"] == "1.0" + assert len(params["nonce"]) == 12 + + +def test_build_sign_params_signature_is_deterministic(mock_config): + """Same inputs should produce valid MD5 signatures.""" + from services.boxim_client import BoxIMClient + + client = BoxIMClient(mock_config) + + params1 = client._build_sign_params({"userId": "u1"}) + params2 = client._build_sign_params({"userId": "u1"}) + + assert params1["signature"] is not None + assert params2["signature"] is not None + assert len(params1["signature"]) == 32 # MD5 hex length