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:
Pine
2026-08-26 12:15:22 +08:00
parent 53cb18efe5
commit f697fdad37
3 changed files with 168 additions and 8 deletions
+81 -7
View File
@@ -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}
# ===========================================================================
+13
View File
@@ -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 管理请求/响应