diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index a1c8a3c..99d5c9a 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -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( diff --git a/app/infrastructure/repositories.py b/app/infrastructure/repositories.py index 81b8f75..6432ceb 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -392,6 +392,16 @@ class UserRepository: await self.session.commit() return _user_to_dict(u) + async def set_wx_mini_openid(self, user_id: str, openid: str) -> dict | None: + """给账号绑定小程序 openid(wx_mini_openid)。""" + u = await self.session.get(User, user_id) + if u is None: + return None + u.wx_mini_openid = openid.strip() + u.updated_at = utcnow_iso() + await self.session.commit() + return _user_to_dict(u) + async def set_phone(self, user_id: str, phone: str) -> dict | None: u = await self.session.get(User, user_id) if u is None: diff --git a/app/services/wechat.py b/app/services/wechat.py index a91fd17..07eb420 100644 --- a/app/services/wechat.py +++ b/app/services/wechat.py @@ -191,20 +191,28 @@ class WxQrSessionStore: self._data: dict[str, dict] = {} self._lock = threading.Lock() - def start(self, scene: str) -> None: + def start(self, scene: str, bind_user_id: str | None = None) -> None: with self._lock: self._data[scene] = { "status": "pending", "token": "", "profile": None, + "bind_user_id": bind_user_id, # 绑定态:要把小程序身份绑到哪个平台账号 + "message": "", "expires_at": time.monotonic() + self._TTL, } - def complete(self, scene: str, token: str, profile: dict) -> None: + def complete(self, scene: str, token: str = "", profile: dict | None = None, message: str = "") -> None: with self._lock: if scene in self._data: self._data[scene].update( - {"status": "done", "token": token, "profile": profile}, + {"status": "done", "token": token, "profile": profile, "message": message}, ) + def bind_user(self, scene: str) -> str | None: + """返回绑定态会话要绑定的平台账号 id(非绑定态返回 None)。""" + with self._lock: + rec = self._data.get(scene) + return (rec or {}).get("bind_user_id") if isinstance(rec, dict) else None + def poll(self, scene: str) -> dict: with self._lock: rec = self._data.get(scene) @@ -214,10 +222,9 @@ class WxQrSessionStore: self._data.pop(scene, None) return {"status": "expired"} if rec["status"] == "done": - # 消费一次(防重复取),但保留短暂窗口 rec["status"] = "consumed" - return {"status": "done", "token": rec["token"], "profile": rec["profile"]} - return {"status": rec["status"], "token": "", "profile": None} + return {"status": "done", "token": rec["token"], "profile": rec["profile"], "message": rec["message"]} + return {"status": rec["status"], "token": "", "profile": None, "message": ""} def new_scene() -> str: