diff --git a/backend/app/api/endpoints/users.py b/backend/app/api/endpoints/users.py index 36b1f72..0fa97ce 100755 --- a/backend/app/api/endpoints/users.py +++ b/backend/app/api/endpoints/users.py @@ -355,13 +355,15 @@ async def upload_avatar( # 未登录用户本地落盘,避免 base64 超过 avatar_url 字段长度 avatar_url = await _save_local_avatar(file_bytes, file.filename or "", file.content_type) - # 更新数据库 - await db.execute(update(_VU).where(_VU.id == user_id).values(avatar_url=avatar_url)) - await db.commit() - - # 如果已同步到平台,再调用 update_user_profile 更新头像字段 + # 已登录用户必须同时写入会会当前资料和“TA 的主页”。 + # 任一接口失败都不得返回“头像更新成功”。 if sync_to_platform and user.status == 2 and avatar_url: - await news_service.update_user_profile(db, user, avatar=avatar_url) + ok, err = await news_service.update_user_profile(db, user, avatar=avatar_url) + if not ok: + return ApiResponse(code=502, message=f"头像已上传,但同步到会会失败: {err}") + else: + await db.execute(update(_VU).where(_VU.id == user_id).values(avatar_url=avatar_url)) + await db.commit() return ApiResponse(data={"avatar_url": avatar_url}, message="头像更新成功") @router.post("/logout-all") diff --git a/backend/app/services/news_service.py b/backend/app/services/news_service.py index c53c62d..d7d2729 100755 --- a/backend/app/services/news_service.py +++ b/backend/app/services/news_service.py @@ -228,6 +228,31 @@ class NewsPlatformService: "avatar": sync_avatar, }, expire=86400) + # 导入用户的昵称/头像先保存在本地;首次或后续登录时, + # 如果会会端仍是旧值,必须补写“当前资料 + TA 的主页”。 + desired_nickname = preferred_nickname + desired_real_name = (user.real_name or desired_nickname or "").strip() + desired_avatar = (user.avatar_url or sync_avatar or "").strip() + needs_profile_sync = any([ + desired_nickname and desired_nickname != sync_nickname, + desired_real_name and desired_real_name != sync_real_name, + desired_avatar and desired_avatar != sync_avatar, + ]) + if needs_profile_sync: + ok, err = await self.update_user_profile( + db, + user, + nick_name=desired_nickname or None, + real_name=desired_real_name or None, + avatar=desired_avatar or None, + ) + if not ok: + await delete_session(user.id) + raise ValueError(f"会会用户资料同步失败: {err}") + sync_nickname = desired_nickname + sync_real_name = desired_real_name + sync_avatar = desired_avatar + # 更新本地数据库,同步平台用户信息 update_vals = dict( status=2, session_token=access_token, @@ -1247,7 +1272,10 @@ class NewsPlatformService: if description is not None: body["description"] = description if email is not None: body["email"] = email - # 使用 PATCH /v2/users/current 接口(支持修改昵称) + # 会会把“当前用户资料”和“TA 的主页”分开保存: + # 1. /v2/users/current 更新当前用户资料; + # 2. /users/page/{id} 更新公开主页的昵称和头像。 + # 两处都成功后才允许把本地同步标记为成功。 headers = dict(self._bearer(token)) headers["Content-Type"] = "application/json" @@ -1259,27 +1287,100 @@ class NewsPlatformService: headers=headers, ) d = r.json() - if d.get("code") in [0, 200]: - # 同步到本地数据库 - local_vals = {} - if nick_name is not None: local_vals["nickname"] = nick_name - if real_name is not None: local_vals["real_name"] = real_name - if sex is not None: local_vals["sex"] = sex - if avatar is not None: local_vals["avatar_url"] = avatar - if local_vals: - from sqlalchemy import update - await db.execute(update(VirtualUser).where( - VirtualUser.id == user.id).values(**local_vals)) - await db.commit() - logger.info(f"✅ 用户 {user.account} 信息已同步到目标系统") - return True, "" - err = d.get("message") or f"code={d.get('code')}" - logger.warning(f"[修改用户信息] {user.account} 失败: {err} body={r.text[:200]}") - return False, err + if d.get("code") not in [0, 200]: + err = d.get("message") or f"code={d.get('code')}" + logger.warning(f"[修改用户信息] {user.account} 失败: {err} body={r.text[:200]}") + return False, err + + page_ok, page_err = await self.update_public_user_page( + db, + user, + nick_name=nick_name, + avatar=avatar, + ) + if not page_ok: + logger.warning(f"[同步TA的主页] {user.account} 失败: {page_err}") + return False, f"TA的主页同步失败: {page_err}" + + # 两个会会接口均成功后再同步本地数据库。 + local_vals = {} + if nick_name is not None: local_vals["nickname"] = nick_name + if real_name is not None: local_vals["real_name"] = real_name + if sex is not None: local_vals["sex"] = sex + if avatar is not None: local_vals["avatar_url"] = avatar + if local_vals: + await db.execute(update(VirtualUser).where( + VirtualUser.id == user.id).values(**local_vals)) + await db.commit() + logger.info(f"✅ 用户 {user.account} 当前资料与TA的主页均已同步") + return True, "" except Exception as e: logger.warning(f"[修改用户信息] {user.account} 异常: {e}") return False, str(e) + async def update_public_user_page( + self, db: AsyncSession, user: VirtualUser, + nick_name: str = None, avatar: str = None, + ) -> tuple[bool, str]: + """同步会会 App“TA 的主页”展示的公开昵称和头像。""" + sess = await get_session(user.id) + if not sess: + return False, "用户未登录,请先登录" + + platform_uid = sess.get("platform_uid") or user.platform_uid or "" + if not platform_uid: + return False, "缺少平台用户ID,请重新登录" + + cfg = await self._client(db) + auth = await self._auth_url(db) + params = {"userId": platform_uid} + if nick_name is not None: + params["nickName"] = nick_name + if avatar is not None: + params["icon"] = avatar + signed_params = self._build_form(params, cfg) + + try: + async with httpx.AsyncClient(timeout=15) as c: + r = await c.patch( + f"{auth}/users/page/{platform_uid}", + params=signed_params, + headers=self._bearer(sess.get("token", "")), + ) + d = r.json() + if r.status_code == 200 and d.get("code") in [0, 200] and d.get("data") is not False: + return True, "" + return False, d.get("message") or f"HTTP={r.status_code}, code={d.get('code')}" + except Exception as e: + return False, str(e) + + async def get_public_user_profile( + self, db: AsyncSession, user: VirtualUser, + ) -> tuple[bool, dict | str]: + """通过公开用户详情接口回读“TA 的主页”数据。""" + sess = await get_session(user.id) + if not sess: + return False, "用户未登录" + platform_uid = sess.get("platform_uid") or user.platform_uid or "" + if not platform_uid: + return False, "缺少平台用户ID" + cfg = await self._client(db) + auth = await self._auth_url(db) + params = self._build_form({"userId": platform_uid}, cfg) + try: + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get( + f"{auth}/users/{platform_uid}", + params=params, + headers=self._bearer(sess.get("token", "")), + ) + d = r.json() + if r.status_code == 200 and d.get("code") in [0, 200] and isinstance(d.get("data"), dict): + return True, d["data"] + return False, d.get("message") or f"HTTP={r.status_code}, code={d.get('code')}" + except Exception as e: + return False, str(e) + async def upload_avatar( self, db: AsyncSession, user: VirtualUser, file_bytes: bytes, filename: str ) -> tuple[bool, str]: diff --git a/backend/tests/test_huihui_profile_sync.py b/backend/tests/test_huihui_profile_sync.py new file mode 100644 index 0000000..965e5e1 --- /dev/null +++ b/backend/tests/test_huihui_profile_sync.py @@ -0,0 +1,92 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.services.news_service import NewsPlatformService + + +class _Response: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + self.text = str(payload) + + def json(self): + return self._payload + + +class _Client: + responses = [] + calls = [] + + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def patch(self, url, **kwargs): + self.__class__.calls.append(("PATCH", url, kwargs)) + return self.__class__.responses.pop(0) + + +class HuihuiProfileSyncTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.service = NewsPlatformService() + self.service._auth_url = AsyncMock(return_value="https://99hui.com/api/usercenter") + self.service._client = AsyncMock(return_value={ + "appId": "app", "accessId": "access", "accessSecret": "secret", + "clientCode": "", "orgId": "", + }) + self.db = SimpleNamespace(execute=AsyncMock(), commit=AsyncMock()) + self.user = SimpleNamespace( + id=51, account="13721560046", platform_uid="platform-51", + nickname="黎佳怡", real_name="黎佳怡", sex=0, avatar_url="https://img/avatar.jpg", + ) + _Client.calls = [] + + async def test_updates_current_profile_and_public_page(self): + _Client.responses = [ + _Response({"code": 0, "data": True}), + _Response({"code": 0, "data": True}), + ] + session = {"token": "token", "platform_uid": "platform-51"} + with patch("app.services.news_service.get_session", AsyncMock(return_value=session)), \ + patch("app.services.news_service.httpx.AsyncClient", _Client): + ok, err = await self.service.update_user_profile( + self.db, self.user, + nick_name="黎佳怡", real_name="黎佳怡", + avatar="https://img/avatar.jpg", + ) + + self.assertTrue(ok, err) + self.assertEqual(_Client.calls[0][1], "https://99hui.com/api/usercenter/v2/users/current") + self.assertEqual(_Client.calls[1][1], "https://99hui.com/api/usercenter/users/page/platform-51") + public_params = _Client.calls[1][2]["params"] + self.assertEqual(public_params["nickName"], "黎佳怡") + self.assertEqual(public_params["icon"], "https://img/avatar.jpg") + self.db.commit.assert_awaited_once() + + async def test_public_page_failure_is_not_reported_as_success(self): + _Client.responses = [ + _Response({"code": 0, "data": True}), + _Response({"code": 500, "message": "page update failed"}), + ] + session = {"token": "token", "platform_uid": "platform-51"} + with patch("app.services.news_service.get_session", AsyncMock(return_value=session)), \ + patch("app.services.news_service.httpx.AsyncClient", _Client): + ok, err = await self.service.update_user_profile( + self.db, self.user, + nick_name="黎佳怡", avatar="https://img/avatar.jpg", + ) + + self.assertFalse(ok) + self.assertIn("TA的主页同步失败", err) + self.db.commit.assert_not_awaited() + + +if __name__ == "__main__": + unittest.main()