From f697fdad37b932534ae81677d81bceded39c2142 Mon Sep 17 00:00:00 2001 From: Pine Date: Wed, 26 Aug 2026 12:15:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E7=BB=9F=E4=B8=80=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=BB=91=E5=AE=9A/=E8=A7=A3=E7=BB=91=20+=20=E6=8C=89?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E8=87=AA=E5=8A=A8=E5=90=88=E5=B9=B6?= =?UTF-8?q?=EF=BC=88=E5=90=8C=E4=B8=80=E8=B4=A6=E5=8F=B7=E4=B8=8D=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - merge_accounts(keeper,absorbed): 身份字段继承、user_identities改挂、audit_logs/任务归属改指keeper、absorbed级联删除(防FK阻断) - POST /auth/bind-phone(验证码,手机号被占用→按手机号合并成keeper并返新令牌); POST /auth/unbind(至少要留一种登录方式); wx-phone 改走合并 - profile 加 wxBound/wxMiniBound; schemas BindPhoneRequest/UnbindRequest --- app/api/routers/auth.py | 88 +++++++++++++++++++++++++++--- app/api/schemas/auth.py | 13 +++++ app/infrastructure/repositories.py | 75 ++++++++++++++++++++++++- 3 files changed, 168 insertions(+), 8 deletions(-) diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 1bc98a5..674bc40 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -34,6 +34,8 @@ from ..schemas.auth import ( WxLoginRequest, WxPhoneRequest, MpQrConfirmRequest, + BindPhoneRequest, + UnbindRequest, ) from ...infrastructure.repositories import Database from ...services import sms, wechat @@ -179,6 +181,8 @@ async def _profile_for( profile["name"] = user.get("nickname") or user.get("username", "") profile["phone"] = user.get("phone", "") profile["phoneBound"] = bool(user.get("phone", "")) + profile["wxBound"] = bool(user.get("wx_openid", "")) + profile["wxMiniBound"] = bool(user.get("wx_mini_openid", "")) profile["status"] = user.get("opc_status", "") profile["topics"] = _parse_topics(user.get("topics", "")) profile["source"] = user.get("source", "") @@ -546,7 +550,7 @@ async def wx_login(req: WxLoginRequest, request: Request, db: Database = Depends @router.post("/wx-phone", summary="微信补绑手机号") async def wx_phone(req: WxPhoneRequest, db: Database = Depends(get_db)): - """给已登录的微信账号补绑手机号(用于兑现政策/报名等需手机的流程)。""" + """给已登录的微信账号补绑手机号。若该手机号被其它账号占用,则按手机号合并(不新增账号)。""" if not config.AUTH_ENABLED: raise HTTPException(status_code=403, detail="认证未开启") try: @@ -557,16 +561,86 @@ async def wx_phone(req: WxPhoneRequest, db: Database = Depends(get_db)): user = await db.users.get_by_wx_openid(req.openid) if user is None: raise HTTPException(status_code=404, detail="微信账号不存在,请先 wx-login") - bound = await db.users.find_by_phone(req.phone) or await db.users.get_by_username(req.phone) - if bound is not None and bound["id"] != user["id"]: - raise HTTPException(status_code=409, detail="该手机号已绑定其它账号") - - fresh = await db.users.set_phone(user["id"], req.phone) + user = await _bind_phone_merge(db, user, req.phone) await db.audit.add( action="bind-phone", resource="auth", resource_id=user["id"], detail=f"wx bind phone {req.phone}", user_id=user["id"], ) - return {**await db.users.to_profile(fresh), "phone": fresh.get("phone", "")} + return await _login_response(db, user, await _ensure_opc_identity(db, user["id"])) + + +# =========================================================================== +# 统一账号:绑定/解绑登录方式 + 按手机号合并(同一账号,绝不新增) +# =========================================================================== + + +def _login_method_count(user: dict) -> int: + """可用登录方式数:手机号 / 微信(网页) / 小程序。用于解绑守卫(至少留一种)。""" + return sum(1 for k in ("phone", "wx_openid", "wx_mini_openid") if (user or {}).get(k)) + + +async def _bind_phone_merge(db: Database, user: dict, phone: str) -> dict: + """给用户绑定手机号;若该手机号已被其它账号占用,则按「手机号持有者=keeper」合并。 + + 返回登录账号(可能是合并后的 keeper)。keeper 已持有该手机号,absorbed=当前账号并入。 + """ + owner = await db.users.find_by_phone(phone) + if owner and owner["id"] != user["id"]: + # keeper=已持有该手机号的账号;当前账号被并入 → 微信等身份挂在 keeper 下 + keeper = await db.users.merge_accounts(owner["id"], user["id"]) + return keeper or owner + fresh = await db.users.set_phone(user["id"], phone) + return fresh or user + + +@router.post("/bind-phone", response_model=LoginResponse, summary="当前账号绑定手机号") +async def bind_phone( + req: BindPhoneRequest, + request: Request, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + """绑定手机号(验证码)。若该手机号已被其它账号占用 → 按手机号合并为同一账号。 + + 合并后当前会话切换为 keeper(返回其新令牌),前端应更新本地令牌。 + """ + if not config.AUTH_ENABLED: + raise HTTPException(status_code=403, detail="认证未开启") + if not _is_phone(req.phone): + raise HTTPException(status_code=400, detail="手机号需为 11 位(1 开头)") + try: + sms.verify(req.phone, req.code) + except sms.SmsError as exc: + raise HTTPException(status_code=401, detail=str(exc)) + merged = await _bind_phone_merge(db, user, req.phone) + identity = await _ensure_opc_identity(db, merged["id"]) + await db.audit.add( + action="bind-phone", resource="auth", resource_id=merged["id"], + detail=f"bind phone {req.phone} (merged)" if merged["id"] != user["id"] else f"bind phone {req.phone}", + user_id=merged["id"], + ) + return await _login_response(db, merged, identity) + + +@router.post("/unbind", summary="解绑登录方式(保留至少一种)") +async def unbind(req: UnbindRequest, db: Database = Depends(get_db), user: dict = Depends(get_current_user)): + """解除已绑定的登录方式:phone / wx / wx_mini。不能解绑到没有任何可用登录方式。""" + if not config.AUTH_ENABLED: + raise HTTPException(status_code=403, detail="认证未开启") + if req.type not in ("phone", "wx", "wx_mini"): + raise HTTPException(status_code=400, detail="type 需为 phone/wx/wx_mini") + # 解绑后仍保留至少一种登录方式 + remaining = _login_method_count(user) - (1 if user.get({ # noqa: E501 + "phone": "phone", "wx": "wx_openid", "wx_mini": "wx_mini_openid", + }[req.type]) else 0) + if remaining <= 0: + raise HTTPException(status_code=409, detail="至少要保留一种登录方式(手机号/微信/小程序)") + fresh = await db.users.clear_binding(user["id"], req.type) + await db.audit.add( + action="unbind", resource="auth", resource_id=user["id"], + detail=f"unbind {req.type}", user_id=user["id"], + ) + return {**await db.users.to_profile(fresh), "unbound": req.type} # =========================================================================== diff --git a/app/api/schemas/auth.py b/app/api/schemas/auth.py index b391041..fcb09a1 100644 --- a/app/api/schemas/auth.py +++ b/app/api/schemas/auth.py @@ -147,5 +147,18 @@ class MpQrConfirmRequest(BaseModel): code: str = Field(description="wx.login 返回的 code") +class BindPhoneRequest(BaseModel): + """当前账号绑定手机号(验证码;若该手机号已被其它账号占用则按手机号合并)。""" + + phone: str + code: str = Field(description="发送到该手机号的验证码") + + +class UnbindRequest(BaseModel): + """解除已绑定的登录方式(保留至少一种登录方式)。""" + + type: str = Field(description="phone | wx | wx_mini") + + # RBAC 管理请求/响应 diff --git a/app/infrastructure/repositories.py b/app/infrastructure/repositories.py index 4d651a4..81b8f75 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -10,7 +10,7 @@ import json import secrets from datetime import datetime, timezone -from sqlalchemy import delete, func, select +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from .. import config @@ -401,6 +401,79 @@ class UserRepository: await self.session.commit() return _user_to_dict(u) + async def clear_binding(self, user_id: str, kind: str) -> dict | None: + """解绑指定登录方式:kind = phone | wx | wx_mini。清空对应字段。""" + u = await self.session.get(User, user_id) + if u is None: + return None + if kind == "phone": + u.phone = "" + elif kind == "wx": + u.wx_openid = "" + elif kind == "wx_mini": + u.wx_mini_openid = "" + else: + return None + u.updated_at = utcnow_iso() + await self.session.commit() + return _user_to_dict(u) + + async def merge_accounts(self, keeper_id: str, absorbed_id: str) -> dict | None: + """把 absorbed 账号并入 keeper(同一逻辑账号,绝不新增)。 + + 触发场景:某微信/小程序账号绑定了已被另一账号占用的手机号 → 按手机号把 + `absorbed`(当前账号) 的微信等身份并入 `keeper`(先持有该手机号的账号), + 再删除 absorbed。规则: + - 登录身份字段(wx_openid/wx_mini_openid/wx_unionid/phone/…)在 keeper 为空时继承 absorbed; + - user_identities 改挂到 keeper; + - audit_logs.user_id 改指 keeper(避免 FK 阻断删除,且保留审计); + - absorbed 其余(sessions/agents/财务/消息等 级联)随之删除;其会话失效,需重新登录。 + 返回合并后的 keeper。 + """ + if not keeper_id or not absorbed_id or keeper_id == absorbed_id: + return await self.get_by_id(keeper_id) + k = await self.session.get(User, keeper_id) + a = await self.session.get(User, absorbed_id) + if k is None or a is None: + return _user_to_dict(k) if k else None + + # 1) 身份字段继承(keeper 为空才覆盖,避免顶掉 keeper 已有身份) + for f in ( + "wx_openid", "wx_mini_openid", "wx_unionid", "phone", "email", + "nickname", "account", "company", "room", "gender", "birthday", + "avatar", "company_avatar", "opc_status", "topics", "park_id", + "park_name", "certification_status", "affiliation", "account_type", + "compute_username", "compute_provisioned", + ): + av = getattr(a, f) + if not getattr(k, f) and av: + setattr(k, f, av) + + # 2) 用户身份改挂 keeper + idents = (await self.session.scalars( + select(UserIdentity).where(UserIdentity.user_id == absorbed_id) + )).all() + for it in idents: + it.user_id = keeper_id + + # 3) 审计改指 keeper(FK 引用,避免删除被阻断) + await self.session.execute( + update(AuditLog).where(AuditLog.user_id == absorbed_id).values(user_id=keeper_id) + ) + # 3b) 任务归属改指 keeper(claimed_by/publisher_id 为 NO ACTION FK,不处理会阻断删除) + await self.session.execute( + update(Task).where(Task.claimed_by == absorbed_id).values(claimed_by=keeper_id) + ) + await self.session.execute( + update(Task).where(Task.publisher_id == absorbed_id).values(publisher_id=keeper_id) + ) + + # 4) 删除 absorbed(sessions/agents/财务/消息等 依赖级联删除;会话随之失效) + await self.session.delete(a) + k.updated_at = utcnow_iso() + await self.session.commit() + return _user_to_dict(k) + async def update_profile(self, user_id: str, fields: dict) -> dict | None: u = await self.session.get(User, user_id) if u is None: