From 3975f6e7648265533e7320d0e9a4816b11de7abc Mon Sep 17 00:00:00 2001 From: stefanfeng Date: Mon, 17 Aug 2026 18:19:39 +0800 Subject: [PATCH] fix: sync Huihui app user homepage profile --- backend/app/services/news_service.py | 113 +++++++++++++++++++++- backend/tests/test_huihui_profile_sync.py | 55 ++++++++++- 2 files changed, 161 insertions(+), 7 deletions(-) diff --git a/backend/app/services/news_service.py b/backend/app/services/news_service.py index d7d2729..fc08a6e 100755 --- a/backend/app/services/news_service.py +++ b/backend/app/services/news_service.py @@ -238,6 +238,15 @@ class NewsPlatformService: desired_real_name and desired_real_name != sync_real_name, desired_avatar and desired_avatar != sync_avatar, ]) + # usercenter 与 App 的“TA 的主页”不是同一数据源。即使 + # usercenter 已一致,也必须检查 huihuiuserextend 的主页资料。 + if not needs_profile_sync: + home_ok, home_data = await self.get_huihui_user_home(db, user) + needs_profile_sync = ( + not home_ok + or (desired_nickname and home_data.get("name") != desired_nickname) + or (desired_avatar and home_data.get("avatar") != desired_avatar) + ) if needs_profile_sync: ok, err = await self.update_user_profile( db, @@ -1272,10 +1281,11 @@ class NewsPlatformService: if description is not None: body["description"] = description if email is not None: body["email"] = email - # 会会把“当前用户资料”和“TA 的主页”分开保存: + # 会会实际有三份用户资料: # 1. /v2/users/current 更新当前用户资料; - # 2. /users/page/{id} 更新公开主页的昵称和头像。 - # 两处都成功后才允许把本地同步标记为成功。 + # 2. /users/page/{id} 更新 usercenter 公开资料; + # 3. /huihuiuserextend/user 更新 App“TA 的主页”。 + # 三处都成功且 App 主页回读一致后才允许标记为成功。 headers = dict(self._bearer(token)) headers["Content-Type"] = "application/json" @@ -1302,7 +1312,17 @@ class NewsPlatformService: logger.warning(f"[同步TA的主页] {user.account} 失败: {page_err}") return False, f"TA的主页同步失败: {page_err}" - # 两个会会接口均成功后再同步本地数据库。 + home_ok, home_err = await self.update_huihui_user_home( + db, + user, + name=nick_name, + avatar=avatar, + ) + if not home_ok: + logger.warning(f"[同步App用户主页] {user.account} 失败: {home_err}") + return False, f"App用户主页同步失败: {home_err}" + + # 三套会会资料均成功并通过 App 主页回读后再同步本地数据库。 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 @@ -1312,7 +1332,7 @@ class NewsPlatformService: await db.execute(update(VirtualUser).where( VirtualUser.id == user.id).values(**local_vals)) await db.commit() - logger.info(f"✅ 用户 {user.account} 当前资料与TA的主页均已同步") + logger.info(f"✅ 用户 {user.account} 三套资料与App用户主页均已同步") return True, "" except Exception as e: logger.warning(f"[修改用户信息] {user.account} 异常: {e}") @@ -1381,6 +1401,89 @@ class NewsPlatformService: except Exception as e: return False, str(e) + async def get_huihui_user_home( + self, db: AsyncSession, user: VirtualUser, + ) -> tuple[bool, dict | str]: + """回读会会 App `/otherIndex` 实际使用的“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) + api_root = self._api_root(await self._biz_url(db)) + extra = {"userId": platform_uid} + org_id = sess.get("org_id") or cfg.get("orgId") or "" + if org_id: + extra["orgId"] = org_id + params = self._build_form(extra, cfg) + try: + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get( + f"{api_root}/huihuiuserextend/user/home/{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 update_huihui_user_home( + self, db: AsyncSession, user: VirtualUser, + name: str = None, avatar: str = None, + ) -> tuple[bool, str]: + """写入并回读验证会会 App 真正使用的用户扩展主页资料。""" + 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,请重新登录" + + current_ok, current = await self.get_huihui_user_home(db, user) + if not current_ok: + return False, f"主页资料回读失败: {current}" + extend_id = current.get("id") + if not extend_id: + return False, "会会用户扩展资料缺少记录ID" + + desired_name = name if name is not None else (user.nickname or "") + desired_avatar = avatar if avatar is not None else (user.avatar_url or "") + body = { + "id": extend_id, + "userId": platform_uid, + "name": desired_name, + "avatar": desired_avatar, + } + cfg = await self._client(db) + api_root = self._api_root(await self._biz_url(db)) + params = self._build_form({"userId": platform_uid}, cfg) + try: + async with httpx.AsyncClient(timeout=15) as c: + r = await c.patch( + f"{api_root}/huihuiuserextend/user", + params=params, + json=body, + headers={**self._bearer(sess.get("token", "")), "Content-Type": "application/json"}, + ) + d = r.json() + if r.status_code != 200 or d.get("code") not in [0, 200]: + return False, d.get("message") or f"HTTP={r.status_code}, code={d.get('code')}" + + verify_ok, verified = await self.get_huihui_user_home(db, user) + if not verify_ok: + return False, f"写入后回读失败: {verified}" + if verified.get("name") != desired_name or verified.get("avatar") != desired_avatar: + return False, "写入后App用户主页昵称或头像不一致" + return True, "" + 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 index 965e5e1..5836a84 100644 --- a/backend/tests/test_huihui_profile_sync.py +++ b/backend/tests/test_huihui_profile_sync.py @@ -32,11 +32,16 @@ class _Client: self.__class__.calls.append(("PATCH", url, kwargs)) return self.__class__.responses.pop(0) + async def get(self, url, **kwargs): + self.__class__.calls.append(("GET", 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._biz_url = AsyncMock(return_value="https://99hui.com/api/huihuibusiness") self.service._client = AsyncMock(return_value={ "appId": "app", "accessId": "access", "accessSecret": "secret", "clientCode": "", "orgId": "", @@ -48,12 +53,20 @@ class HuihuiProfileSyncTests(unittest.IsolatedAsyncioTestCase): ) _Client.calls = [] - async def test_updates_current_profile_and_public_page(self): + async def test_updates_all_profiles_and_verifies_app_home(self): _Client.responses = [ _Response({"code": 0, "data": True}), _Response({"code": 0, "data": True}), + _Response({"code": 0, "data": { + "id": "extend-51", "userId": "platform-51", "name": "", "avatar": "", + }}), + _Response({"code": 0, "data": None}), + _Response({"code": 0, "data": { + "id": "extend-51", "userId": "platform-51", "name": "黎佳怡", + "avatar": "https://img/avatar.jpg", + }}), ] - session = {"token": "token", "platform_uid": "platform-51"} + session = {"token": "token", "platform_uid": "platform-51", "org_id": "org-1"} 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( @@ -68,6 +81,20 @@ class HuihuiProfileSyncTests(unittest.IsolatedAsyncioTestCase): public_params = _Client.calls[1][2]["params"] self.assertEqual(public_params["nickName"], "黎佳怡") self.assertEqual(public_params["icon"], "https://img/avatar.jpg") + self.assertEqual(_Client.calls[2][0], "GET") + self.assertEqual( + _Client.calls[2][1], + "https://99hui.com/api/huihuiuserextend/user/home/platform-51", + ) + self.assertEqual(_Client.calls[3][0], "PATCH") + self.assertEqual( + _Client.calls[3][1], + "https://99hui.com/api/huihuiuserextend/user", + ) + self.assertEqual(_Client.calls[3][2]["json"]["id"], "extend-51") + self.assertEqual(_Client.calls[3][2]["json"]["name"], "黎佳怡") + self.assertEqual(_Client.calls[3][2]["json"]["avatar"], "https://img/avatar.jpg") + self.assertEqual(_Client.calls[4][0], "GET") self.db.commit.assert_awaited_once() async def test_public_page_failure_is_not_reported_as_success(self): @@ -87,6 +114,30 @@ class HuihuiProfileSyncTests(unittest.IsolatedAsyncioTestCase): self.assertIn("TA的主页同步失败", err) self.db.commit.assert_not_awaited() + async def test_app_home_mismatch_is_not_reported_as_success(self): + _Client.responses = [ + _Response({"code": 0, "data": True}), + _Response({"code": 0, "data": True}), + _Response({"code": 0, "data": { + "id": "extend-51", "userId": "platform-51", "name": "", "avatar": "", + }}), + _Response({"code": 0, "data": None}), + _Response({"code": 0, "data": { + "id": "extend-51", "userId": "platform-51", "name": "", "avatar": "", + }}), + ] + session = {"token": "token", "platform_uid": "platform-51", "org_id": "org-1"} + 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("App用户主页同步失败", err) + self.db.commit.assert_not_awaited() + if __name__ == "__main__": unittest.main()