feat: refine embedded avatar authorization controls #29

Closed
stefanfeng wants to merge 5 commits from codex/avatar-continuation-20260819 into main
2 changed files with 161 additions and 7 deletions
Showing only changes of commit 3975f6e764 - Show all commits
+108 -5
View File
@@ -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]:
+53 -2
View File
@@ -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()