From 528f56254de8a4ba76adff7ae37e93e9e6f2ff80 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 6 Sep 2026 12:06:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(#1718):=20PATCH=20/auth/me=20=E8=B5=84?= =?UTF-8?q?=E6=96=99=E6=9B=B4=E6=96=B0=E6=8E=A5=E5=8F=A3=20+=20profile=5Fc?= =?UTF-8?q?ompleted=20=E5=AD=97=E6=AE=B5=20(#1728)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- .../versions/068_user_profile_completed.py | 26 ++ apps/api/app/api/routes/auth.py | 84 +++++-- packages/adapters/sqlalchemy_impl/models.py | 1 + .../sqlalchemy_impl/user_repository.py | 2 + .../application/auth/wechat_sync_use_case.py | 2 + packages/domain/entities.py | 2 + tests/unit/test_patch_me_profile_1718.py | 222 ++++++++++++++++++ tests/unit/test_wechat_bind_routes_1719.py | 2 + 8 files changed, 322 insertions(+), 19 deletions(-) create mode 100644 alembic/versions/068_user_profile_completed.py create mode 100644 tests/unit/test_patch_me_profile_1718.py diff --git a/alembic/versions/068_user_profile_completed.py b/alembic/versions/068_user_profile_completed.py new file mode 100644 index 000000000..61d9f6a90 --- /dev/null +++ b/alembic/versions/068_user_profile_completed.py @@ -0,0 +1,26 @@ +"""add profile_completed to users + +Issue #1718:微信新用户首次登录需设置昵称(PATCH /auth/me)。 +- users.profile_completed:资料是否已完善;存量行默认 True(不触发引导), + 微信新建用户在应用层置 False。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "068_user_profile_completed" +down_revision = "067_celery_task_id" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("profile_completed", sa.Boolean(), nullable=False, server_default=sa.text("true")), + ) + + +def downgrade() -> None: + op.drop_column("users", "profile_completed") diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index 0c268d92d..44cdaa6b8 100755 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -1,4 +1,5 @@ """ +from __future__ import annotations Canonical authentication API routes. The route layer is intentionally thin: repository construction lives in @@ -15,7 +16,7 @@ from app.config import settings from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, field_validator from packages.adapters.redis import NoopSessionStore from packages.adapters.smtp import NoopEmailService @@ -85,6 +86,22 @@ class CurrentUserResponse(BaseModel): phone_verified: bool = False binding_complete: bool = False wechat_bound: bool = False + profile_completed: bool = True + + +class UserProfileResponse(BaseModel): + """用户资料负载(PATCH /me、绑定/解绑接口复用;字段与 GET /auth/me 一致,前端 normalizeUser 直接消费)""" + + user_id: str + email: str + username: str + display_name: str + email_verified: bool + phone: str = "" + phone_verified: bool = False + binding_complete: bool = False + wechat_bound: bool = False + profile_completed: bool = True class PasswordResetRequestModel(BaseModel): @@ -274,9 +291,51 @@ async def get_current_user_info( phone_verified=user.phone_verified, binding_complete=binding_complete, wechat_bound=bool(user.wechat_openid), + profile_completed=user.profile_completed, ) +class UpdateProfileRequest(BaseModel): + """更新个人资料请求(当前仅支持昵称)""" + + display_name: str + + @field_validator("display_name") + @classmethod + def _validate_display_name(cls, v: str) -> str: + name = (v or "").strip() + if not name: + raise ValueError("昵称不能为空白") + if len(name) > 20: + raise ValueError("昵称长度需在 1-20 个字符之间") + return name + + +class UpdateProfileResponse(BaseModel): + """更新资料响应:前端 normalizeUser(response.user) 直接消费""" + + user: UserProfileResponse + + +@router.patch("/me", response_model=UpdateProfileResponse) +async def update_current_user_profile( + request: UpdateProfileRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + user_repository: UserRepository = Depends(get_user_repository), +) -> UpdateProfileResponse: + """更新当前登录用户昵称(微信新用户首次设置昵称后置 profile_completed=True)。""" + user = current_user.user + user.display_name = request.display_name # 已 strip(validator) + if not user.profile_completed: + user.profile_completed = True + user_repository.save(user) + + logger.info("[资料更新] 用户 %s 更新昵称,profile_completed=%s", user.id, user.profile_completed) + # 重新读取,确保返回的是持久化后的最新状态 + fresh = user_repository.find_by_id(user.id) or user + return UpdateProfileResponse(user=_user_profile(fresh)) + + class _NoopSessionStore(NoopSessionStore): pass @@ -507,34 +566,20 @@ class WechatBindCompleteRequest(BaseModel): state: str = "" -class WechatBindUserProfile(BaseModel): - """绑定/解绑后返回的用户信息(字段对齐 /auth/me,前端 normalizeUser 直接消费)""" - - user_id: str - email: str - username: str - display_name: str - email_verified: bool - phone: str = "" - phone_verified: bool = False - binding_complete: bool = False - wechat_bound: bool = False - - class WechatBindCompleteResponse(BaseModel): success: bool - user: WechatBindUserProfile + user: UserProfileResponse class WechatUnbindResponse(BaseModel): success: bool -def _wechat_user_profile(user) -> WechatBindUserProfile: +def _user_profile(user) -> UserProfileResponse: binding_complete = bool( user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email ) - return WechatBindUserProfile( + return UserProfileResponse( user_id=user.id, email=user.email, username=user.username, @@ -544,6 +589,7 @@ def _wechat_user_profile(user) -> WechatBindUserProfile: phone_verified=user.phone_verified, binding_complete=binding_complete, wechat_bound=bool(user.wechat_openid), + profile_completed=user.profile_completed, ) @@ -589,7 +635,7 @@ async def wechat_bind( raise HTTPException(status_code=http_status, detail=error) logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8]) - return WechatBindCompleteResponse(success=True, user=_wechat_user_profile(result.user)) + return WechatBindCompleteResponse(success=True, user=_user_profile(result.user)) @router.delete("/wechat/bind", response_model=WechatUnbindResponse) diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 0ee3e8565..04ff2381d 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -38,6 +38,7 @@ class UserModel(Base): phone = Column(String(32), nullable=True, unique=True, index=True) phone_verified = Column(Boolean, nullable=False, default=False) binding_completed_at = Column(DateTime, nullable=True) + profile_completed = Column(Boolean, nullable=False, default=True, server_default="true") created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) diff --git a/packages/adapters/sqlalchemy_impl/user_repository.py b/packages/adapters/sqlalchemy_impl/user_repository.py index ac9631aa9..4bafb2b60 100755 --- a/packages/adapters/sqlalchemy_impl/user_repository.py +++ b/packages/adapters/sqlalchemy_impl/user_repository.py @@ -38,6 +38,7 @@ class SQLAlchemyUserRepository(UserRepository): model.phone = user.phone model.phone_verified = user.phone_verified model.binding_completed_at = user.binding_completed_at + model.profile_completed = user.profile_completed model.created_at = user.created_at self.session.commit() @@ -113,5 +114,6 @@ class SQLAlchemyUserRepository(UserRepository): phone=model.phone, phone_verified=model.phone_verified or False, binding_completed_at=model.binding_completed_at, + profile_completed=model.profile_completed if model.profile_completed is not None else True, created_at=model.created_at, ) diff --git a/packages/application/auth/wechat_sync_use_case.py b/packages/application/auth/wechat_sync_use_case.py index 7f343eb7b..bb96dc1d5 100644 --- a/packages/application/auth/wechat_sync_use_case.py +++ b/packages/application/auth/wechat_sync_use_case.py @@ -200,6 +200,8 @@ class WechatSyncUseCase: email_verified=True, # 微信登录视为已验证 wechat_openid=request.openid, wechat_unionid=request.unionid or None, + # 微信新建用户首次登录需引导设置昵称 + profile_completed=False, ) self.user_repository.save(user) diff --git a/packages/domain/entities.py b/packages/domain/entities.py index 8d1060c87..b03997a16 100755 --- a/packages/domain/entities.py +++ b/packages/domain/entities.py @@ -57,6 +57,8 @@ class User: phone: str | None = None phone_verified: bool = False binding_completed_at: datetime | None = None + # 资料是否已完善(微信新用户首次设置昵称后置 True;邮箱注册默认 True) + profile_completed: bool = True created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/tests/unit/test_patch_me_profile_1718.py b/tests/unit/test_patch_me_profile_1718.py new file mode 100644 index 000000000..d2444f77b --- /dev/null +++ b/tests/unit/test_patch_me_profile_1718.py @@ -0,0 +1,222 @@ +"""#1718:PATCH /auth/me 资料更新接口测试。 + +覆盖: +- 正常更新昵称并落库 +- strip 生效(前后空白去除) +- 纯空白/超长 -> 422(pydantic 校验) +- 首次设置昵称 profile_completed False->True +- 已完成用户重复提交幂等(仍 True) +- 未登录由 get_current_user 依赖保证 401(框架行为,这里验证路由声明了该依赖) +- 响应结构 {user: {...}} 含 wechat_bound/profile_completed 全字段 +- 微信新建用户 profile_completed 默认 False(wechat_sync _create_wechat_user) +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from pydantic import ValidationError + +os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +from app.api.routes import auth as auth_route # noqa: E402 + +from packages.adapters.in_memory.user_repository import InMemoryUserRepository # noqa: E402 +from packages.domain.entities import User # noqa: E402 + + +def _auth_user(user): + return SimpleNamespace(user=user, session_id="s-1", token_type="user_auth") + + +def _make_user(**kw): + defaults = dict( + id="u-1", + email="user@example.com", + username="user", + display_name="微信用户", + password_hash="x", + email_verified=True, + profile_completed=False, + ) + defaults.update(kw) + return User(**defaults) + + +# ---------- 请求体校验 ---------- + + +def test_display_name_strips_whitespace(): + req = auth_route.UpdateProfileRequest(display_name=" ying123 ") + assert req.display_name == "ying123" + + +def test_display_name_blank_rejected(): + with pytest.raises(ValidationError) as exc: + auth_route.UpdateProfileRequest(display_name=" ") + assert "空白" in str(exc.value) + + +def test_display_name_empty_rejected(): + with pytest.raises(ValidationError): + auth_route.UpdateProfileRequest(display_name="") + + +def test_display_name_too_long_rejected(): + with pytest.raises(ValidationError) as exc: + auth_route.UpdateProfileRequest(display_name="甲" * 21) + assert "1-20" in str(exc.value) + + +def test_display_name_max_length_accepted(): + req = auth_route.UpdateProfileRequest(display_name="甲" * 20) + assert req.display_name == "甲" * 20 + + +# ---------- 路由逻辑 ---------- + + +def test_patch_me_updates_display_name_and_persists(): + user = _make_user() + repo = InMemoryUserRepository() + repo.save(user) + + resp = asyncio.run( + auth_route.update_current_user_profile( + auth_route.UpdateProfileRequest(display_name=" ying123 "), + current_user=_auth_user(user), + user_repository=repo, + ) + ) + assert resp.user.display_name == "ying123" + assert resp.user.profile_completed is True + assert resp.user.wechat_bound is False + # 落库验证 + fresh = repo.find_by_id("u-1") + assert fresh.display_name == "ying123" + assert fresh.profile_completed is True + + +def test_patch_me_first_time_sets_profile_completed_true(): + user = _make_user(profile_completed=False) + repo = InMemoryUserRepository() + repo.save(user) + assert repo.find_by_id("u-1").profile_completed is False + + asyncio.run( + auth_route.update_current_user_profile( + auth_route.UpdateProfileRequest(display_name="小虾"), + current_user=_auth_user(user), + user_repository=repo, + ) + ) + assert repo.find_by_id("u-1").profile_completed is True + + +def test_patch_me_idempotent_for_completed_user(): + user = _make_user(display_name="老名字", profile_completed=True) + repo = InMemoryUserRepository() + repo.save(user) + + resp = asyncio.run( + auth_route.update_current_user_profile( + auth_route.UpdateProfileRequest(display_name="新名字"), + current_user=_auth_user(user), + user_repository=repo, + ) + ) + assert resp.user.profile_completed is True + assert resp.user.display_name == "新名字" + # 再提交一次同样内容,不报错、状态稳定 + resp2 = asyncio.run( + auth_route.update_current_user_profile( + auth_route.UpdateProfileRequest(display_name="新名字"), + current_user=_auth_user(repo.find_by_id("u-1")), + user_repository=repo, + ) + ) + assert resp2.user.profile_completed is True + + +def test_patch_me_response_contains_all_me_fields(): + user = _make_user(wechat_openid="wx-1", phone="13800000000", phone_verified=True) + repo = InMemoryUserRepository() + repo.save(user) + + resp = asyncio.run( + auth_route.update_current_user_profile( + auth_route.UpdateProfileRequest(display_name="昵称"), + current_user=_auth_user(user), + user_repository=repo, + ) + ) + payload = resp.user.model_dump() + for field in ( + "user_id", + "email", + "username", + "display_name", + "email_verified", + "phone", + "phone_verified", + "binding_complete", + "wechat_bound", + "profile_completed", + ): + assert field in payload, f"missing field {field}" + assert payload["wechat_bound"] is True + assert payload["phone"] == "13800000000" + + +def test_get_me_includes_profile_completed_flag(): + # 未完成 + u = _make_user(profile_completed=False) + resp = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(u))) + assert resp.profile_completed is False + assert resp.wechat_bound is False + + # 已完成 + 已绑微信 + u2 = _make_user(profile_completed=True, wechat_openid="wx-9") + resp2 = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(u2))) + assert resp2.profile_completed is True + assert resp2.wechat_bound is True + + +def test_patch_me_requires_auth_dependency(): + # 路由签名必须依赖 get_current_user,未携带 token 时框架返回 401 + params = ( + auth_route.update_current_user_profile.__wrapped__ + if hasattr(auth_route.update_current_user_profile, "__wrapped__") + else auth_route.update_current_user_profile + ) + import inspect + + sig = inspect.signature(params) + dep = sig.parameters.get("current_user") + assert dep is not None + assert dep.default is not None and getattr(dep.default, "dependency", None) is auth_route.get_current_user + + +def test_wechat_new_user_created_with_profile_completed_false(): + # 微信同步建号:新用户 profile_completed=False(需引导设置昵称) + from packages.application.auth.wechat_sync_use_case import ( + WechatSyncRequest, + WechatSyncUseCase, + ) + + repo = InMemoryUserRepository() + # session_store 用 mock,不依赖 redis + use_case = WechatSyncUseCase(user_repository=repo, session_store=MagicMock(), jwt_secret_key="test-secret") + resp, err = use_case.execute(WechatSyncRequest(openid="wx-new-openid", nickname="微信测试", source="web")) + assert err is None + user = repo.find_by_id(resp.user_id) + assert user.profile_completed is False diff --git a/tests/unit/test_wechat_bind_routes_1719.py b/tests/unit/test_wechat_bind_routes_1719.py index 37df5fada..9f07cc43d 100644 --- a/tests/unit/test_wechat_bind_routes_1719.py +++ b/tests/unit/test_wechat_bind_routes_1719.py @@ -38,6 +38,7 @@ def _auth_user(user_id="u-1", openid=None): display_name="用户", phone="", phone_verified=False, + profile_completed=True, ) return SimpleNamespace(user=user, session_id="s-1", token_type="user_auth") @@ -112,6 +113,7 @@ def test_bind_success_returns_user_with_wechat_bound(): display_name="用户", phone="", phone_verified=False, + profile_completed=True, ) import packages.application.auth.wechat_oauth_service as oauth_mod