mini_auth.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. """小程序鉴权:微信登录 / 绑定 openid。"""
  2. from __future__ import annotations
  3. from datetime import datetime
  4. from fastapi import APIRouter, Depends, HTTPException, Request
  5. from sqlalchemy.orm import Session
  6. from app.core.auth import get_current_user_by_token
  7. from app.core.database import get_db
  8. from app.core.response import error_response, success_response
  9. from app.crud.user import user_crud
  10. from app.services import wx_qr_session
  11. from app.services.issue_user_tokens import issue_tokens_for_user
  12. from app.services.wechat_login_service import (
  13. resolve_wechat_login_by_openid_only,
  14. resolve_wechat_login_user,
  15. )
  16. from app.services.wechat_mini_auth import (
  17. WechatMiniAuthError,
  18. code2session,
  19. get_phone_number,
  20. )
  21. from app.services.wechat_mp_auth import WechatMpAuthError, oauth_code_to_openid
  22. router = APIRouter(prefix="/mini/auth", tags=["小程序鉴权"])
  23. async def _resolve_user_id(request: Request, db: Session) -> str:
  24. authorization = request.headers.get("Authorization", "")
  25. if not authorization.startswith("Bearer "):
  26. raise HTTPException(status_code=401, detail="未授权")
  27. token = authorization.replace("Bearer ", "")
  28. current = await get_current_user_by_token(token=token, db=db)
  29. user_id = current.get("user_id") or current.get("sub")
  30. if not user_id:
  31. raise HTTPException(status_code=401, detail="未授权")
  32. return user_id
  33. @router.post("/wechat-login", response_model=dict)
  34. async def wechat_login(body: dict, db: Session = Depends(get_db)):
  35. """微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。"""
  36. login_code = (body.get("loginCode") or "").strip()
  37. phone_code = (body.get("phoneCode") or "").strip() or None
  38. try:
  39. sess = code2session(login_code)
  40. except WechatMiniAuthError as exc:
  41. return error_response(400, str(exc))
  42. if not phone_code:
  43. user, err_msg, err_code = resolve_wechat_login_by_openid_only(
  44. db, openid=sess["openid"]
  45. )
  46. if err_msg:
  47. return error_response(err_code or 400, err_msg)
  48. else:
  49. try:
  50. phone = get_phone_number(phone_code)
  51. except WechatMiniAuthError as exc:
  52. return error_response(400, str(exc))
  53. user, err_msg, err_code = resolve_wechat_login_user(
  54. db, openid=sess["openid"], phone=phone
  55. )
  56. if err_msg:
  57. return error_response(err_code or 400, err_msg)
  58. unionid = (sess.get("unionid") or "").strip()
  59. if unionid and not (getattr(user, "wx_unionid", None) or "").strip():
  60. user.wx_unionid = unionid
  61. user.update_time = datetime.now()
  62. db.add(user)
  63. db.commit()
  64. db.refresh(user)
  65. try:
  66. data = await issue_tokens_for_user(db, user)
  67. except ValueError as exc:
  68. return error_response(403, str(exc))
  69. return success_response(data)
  70. @router.post("/wechat-bind", response_model=dict)
  71. async def wechat_bind(
  72. request: Request,
  73. body: dict,
  74. db: Session = Depends(get_db),
  75. ):
  76. """密码登录后绑定当前微信 openid(需 Bearer)。"""
  77. try:
  78. user_id = await _resolve_user_id(request, db)
  79. except HTTPException:
  80. return error_response(401, "未授权")
  81. login_code = (body.get("loginCode") or "").strip()
  82. try:
  83. sess = code2session(login_code)
  84. except WechatMiniAuthError as exc:
  85. return error_response(400, str(exc))
  86. openid = sess["openid"]
  87. user = user_crud.get(db, user_id)
  88. if not user or user.del_flag != 0:
  89. return error_response(401, "用户不存在")
  90. other = user_crud.get_by_wx_openid(db, openid)
  91. if other and other.id != user.id:
  92. return error_response(409, "该微信已绑定其他账号")
  93. existing_oid = (getattr(user, "wx_openid", None) or "").strip()
  94. if existing_oid == openid:
  95. return success_response({"bound": True, "message": "已绑定"})
  96. if existing_oid and existing_oid != openid:
  97. return error_response(409, "该账号已绑定其他微信,请联系管理员")
  98. user.wx_openid = openid
  99. user.update_time = datetime.now()
  100. db.add(user)
  101. db.commit()
  102. return success_response({"bound": True, "message": "绑定成功"})
  103. @router.get("/mp-bind-status", response_model=dict)
  104. async def mp_bind_status(request: Request, db: Session = Depends(get_db)):
  105. """当前用户是否已绑定微信服务号 openid。"""
  106. try:
  107. user_id = await _resolve_user_id(request, db)
  108. except HTTPException:
  109. return error_response(401, "未授权")
  110. user = user_crud.get(db, user_id)
  111. bound = bool(user and (getattr(user, "wx_mp_openid", None) or "").strip())
  112. return success_response({"bound": bound})
  113. @router.post("/mp-bind", response_model=dict)
  114. async def mp_bind(request: Request, body: dict, db: Session = Depends(get_db)):
  115. """服务号网页授权 code 绑定当前用户(需 Bearer)。"""
  116. try:
  117. user_id = await _resolve_user_id(request, db)
  118. except HTTPException:
  119. return error_response(401, "未授权")
  120. code = (body.get("code") or "").strip()
  121. try:
  122. info = oauth_code_to_openid(code)
  123. except WechatMpAuthError as exc:
  124. return error_response(400, str(exc))
  125. mp_openid = info["openid"]
  126. other = user_crud.get_by_wx_mp_openid(db, mp_openid)
  127. if other and other.id != user_id:
  128. return error_response(409, "该微信服务号已绑定其他账号")
  129. user = user_crud.get(db, user_id)
  130. if not user or user.del_flag != 0:
  131. return error_response(401, "用户不存在")
  132. user.wx_mp_openid = mp_openid
  133. if info.get("unionid"):
  134. user.wx_unionid = info["unionid"]
  135. user.wx_mp_bind_time = datetime.now()
  136. user.update_time = datetime.now()
  137. db.add(user)
  138. db.commit()
  139. return success_response({"bound": True})
  140. @router.post("/web-qr-scanned", response_model=dict)
  141. async def web_qr_scanned(body: dict):
  142. raw = (body.get("sessionId") or body.get("scene") or "").strip()
  143. sid = wx_qr_session.resolve_session_id(raw) or raw
  144. data = wx_qr_session.get_session(sid)
  145. if not data or data.get("status") in ("expired", "consumed"):
  146. return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
  147. wx_qr_session.mark_scanned(sid)
  148. return success_response({"ok": True})
  149. @router.post("/web-qr-confirm", response_model=dict)
  150. async def web_qr_confirm(body: dict, db: Session = Depends(get_db)):
  151. raw = (body.get("sessionId") or body.get("scene") or "").strip()
  152. sid = wx_qr_session.resolve_session_id(raw) or raw
  153. data = wx_qr_session.get_session(sid)
  154. if not data or data.get("status") in ("expired", "consumed", "failed"):
  155. return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
  156. if data.get("status") == "success":
  157. return success_response({"ok": True, "message": "电脑端即将登录"})
  158. wx_qr_session.mark_scanned(sid)
  159. login_code = (body.get("loginCode") or "").strip()
  160. phone_code = (body.get("phoneCode") or "").strip()
  161. if not login_code or not phone_code:
  162. wx_qr_session.mark_failed(sid, "需要授权手机号才能完成扫码登录")
  163. return error_response(400, "需要授权手机号才能完成扫码登录")
  164. try:
  165. sess = code2session(login_code)
  166. phone = get_phone_number(phone_code)
  167. except WechatMiniAuthError as exc:
  168. wx_qr_session.mark_failed(sid, str(exc))
  169. return error_response(400, str(exc))
  170. user, err_msg, err_code = resolve_wechat_login_user(
  171. db, openid=sess["openid"], phone=phone
  172. )
  173. if err_msg:
  174. wx_qr_session.mark_failed(sid, err_msg)
  175. return error_response(err_code or 400, err_msg)
  176. unionid = (sess.get("unionid") or "").strip()
  177. if unionid and not (getattr(user, "wx_unionid", None) or "").strip():
  178. user.wx_unionid = unionid
  179. user.update_time = datetime.now()
  180. db.add(user)
  181. db.commit()
  182. db.refresh(user)
  183. try:
  184. tokens = await issue_tokens_for_user(db, user)
  185. except ValueError as exc:
  186. wx_qr_session.mark_failed(sid, str(exc))
  187. return error_response(403, str(exc))
  188. if not wx_qr_session.mark_success(sid, tokens):
  189. return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
  190. return success_response({"ok": True, "message": "电脑端即将登录,可关闭本页"})