feat(auth): 支持通过小程序码「绑定小程序」到当前账号

- WxQrSessionStore 支持 bind_user_id(绑定态)+message; bind_user() 访问器
- POST /auth/mp-qr/bind-start(当前账号发起,返小程序码); confirm 检测绑定态: 把openid绑到目标账号(若该微信属另一账号则按手机号/微信合并)
- poll 返回 message(bound); repositories 加 set_wx_mini_openid
This commit is contained in:
Pine
2026-08-26 12:47:55 +08:00
parent 0cdcdeed26
commit 53521b9438
3 changed files with 72 additions and 10 deletions
+49 -4
View File
@@ -777,13 +777,38 @@ async def mp_qr_start(request: Request, db: Database = Depends(get_db)):
async def mp_qr_poll(scene: str):
result = wx_qr_store.poll(scene)
if result["status"] == "done":
return {"status": "done", "token": result["token"], "profile": result["profile"]}
return {"status": result["status"]}
return {"status": "done", "token": result["token"], "profile": result["profile"], "message": result["message"]}
return {"status": result["status"], "message": ""}
@router.post("/mp-qr/confirm", summary="小程序内确认扫码登录")
@router.get("/mp-qr/bind-start", summary="发起「绑定小程序」")
async def mp_qr_bind_start(request: Request, db: Database = Depends(get_db), user: dict = Depends(get_current_user)):
"""把当前账号与小程序(微信身份)绑定:返回小程序码;微信扫码自动打开小程序确认页。
与登录不同,这是「绑定」——确认后把该微信身份挂到**当前账号**(若该微信已属另一账号则合并)。
"""
if not config.AUTH_WECHAT_LOGIN:
raise HTTPException(status_code=503, detail="小程序登录未开启")
if not config.AUTH_ENABLED:
raise HTTPException(status_code=403, detail="认证未开启")
scene = f"mp_{secrets.token_hex(10)}"
wx_qr_store.start(scene, bind_user_id=user["id"])
try:
png = await wechat.get_wxacode(scene, page="pages/scan-login/index")
b64 = base64.b64encode(png).decode("ascii")
return {"scene": scene, "qr_image": f"data:image/png;base64,{b64}", "mp_enabled": True}
except wechat.WechatError:
qr_url = f"https://opc.pinesound.cn/mp/bind?scene={scene}"
return {"scene": scene, "qr_url": qr_url, "mp_enabled": False}
@router.post("/mp-qr/confirm", summary="小程序内确认扫码登录/绑定")
async def mp_qr_confirm(request: Request, req: MpQrConfirmRequest, db: Database = Depends(get_db)):
"""小程序内 wx.login 后调用:以 scene 关联 web 端会话,回写已登录令牌。"""
"""小程序内 wx.login 后调用:以 scene 关联 Web 端会话
- 登录态(scene 无 bind_user_id):复用登录换取令牌回写 scene;
- 绑定态(scene 有 bind_user_id):把该微信身份绑到 bind_user_id 账号(若该微信属另一账号则合并)。
"""
if not config.AUTH_ENABLED:
raise HTTPException(status_code=403, detail="认证未开启")
try:
@@ -791,6 +816,26 @@ async def mp_qr_confirm(request: Request, req: MpQrConfirmRequest, db: Database
except wechat.WechatError as exc:
raise HTTPException(status_code=401, detail=str(exc))
source = _client_source(request, "mini_program", "wx_openid")
# 绑定态:把 openid 绑到 scene 记录的目标账号
bind_user_id = wx_qr_store.bind_user(req.scene)
if bind_user_id:
target = await db.users.get_by_id(bind_user_id)
if target is None:
raise HTTPException(status_code=404, detail="目标账号不存在,请重新发起绑定")
owner = await db.users.get_by_wx_mini_openid(openid)
if owner and owner["id"] != target["id"]:
# 该微信身份已属另一账号 → 合并到当前账号(同一账号不新建)
merged = await db.users.merge_accounts(target["id"], owner["id"])
if not merged or not merged.get("wx_mini_openid"):
merged = await db.users.set_wx_mini_openid(target["id"], openid)
target = merged
elif not (target.get("wx_mini_openid") or ""):
target = await db.users.set_wx_mini_openid(target["id"], openid)
wx_qr_store.complete(req.scene, message="bound")
return {"ok": True, "bound": True, "username": (target or {}).get("username", "")}
# 登录态:登录换令牌回写 scene
user = await db.users.get_by_wx_mini_openid(openid)
if user is None:
user = await db.users.create(