feat(auth): 统一账号绑定/解绑 + 按手机号自动合并(同一账号不新建)
- 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
This commit is contained in:
+81
-7
@@ -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}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
@@ -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 管理请求/响应
|
||||
|
||||
|
||||
Reference in New Issue
Block a user