feat: refine embedded avatar authorization controls #29

Closed
stefanfeng wants to merge 5 commits from codex/avatar-continuation-20260819 into main
3 changed files with 219 additions and 24 deletions
Showing only changes of commit 472dce7cae - Show all commits
+8 -6
View File
@@ -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")
+119 -18
View File
@@ -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]:
+92
View File
@@ -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()