feat(api): add mini wechat-login and share JWT issuance with web login.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-18 11:14:18 +08:00
1 parent 7ccd1f553e
commit 713aa700ae
5 files changed
+168 -81

No files matched your search

+2
View File
@@ -8,6 +8,7 @@ from app.api.crm import router as crm_router
from app.api.monitor import router as monitor_router
from app.api.bx import bx_router
from app.api.db import db_router
from app.api.mini_auth import router as mini_auth_router
__all__ = [
"public_router",
@@ -19,4 +20,5 @@ __all__ = [
"monitor_router",
"bx_router",
"db_router",
"mini_auth_router",
]
+58
View File
@@ -0,0 +1,58 @@
"""小程序鉴权:微信手机号快捷登录。"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import error_response, success_response
from app.services.web_login_tokens import issue_web_login_tokens
from app.services.wechat_login_service import resolve_wechat_login_user
from app.services.wechat_mini_auth import (
WechatMiniAuthError,
code2session,
get_phone_number,
)
router = APIRouter(prefix="/mini/auth", tags=["小程序鉴权"])
@router.post("/wechat-login", response_model=dict)
def wechat_login(body: dict, db: Session = Depends(get_db)):
"""手机号快捷登录:须 loginCode + phoneCode;匹配 phone_no 并绑定 wx_openid。"""
login_code = (body.get("loginCode") or "").strip()
phone_code = (body.get("phoneCode") or "").strip()
if not phone_code:
return error_response(400, "请授权手机号登录,或使用账号密码登录")
try:
sess = code2session(login_code)
except WechatMiniAuthError as exc:
return error_response(400, str(exc))
try:
phone = get_phone_number(phone_code)
except WechatMiniAuthError as exc:
return error_response(400, str(exc))
user, err_msg, err_code = resolve_wechat_login_user(
db, openid=sess["openid"], phone=phone
)
if err_msg:
return error_response(err_code or 400, err_msg)
unionid = (sess.get("unionid") or "").strip()
if unionid and not (getattr(user, "wx_unionid", None) or "").strip():
user.wx_unionid = unionid
user.update_time = datetime.now()
db.add(user)
db.commit()
db.refresh(user)
try:
data = issue_web_login_tokens(db, user)
except ValueError as exc:
return error_response(403, str(exc))
return success_response(data)
+12 -81
View File
@@ -1,6 +1,4 @@
"""公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/shanxia2.0.sql 基线)"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session
@@ -18,11 +16,9 @@ from app.core.auth import (
revoke_refresh_token,
revoke_session,
store_refresh_token,
store_session,
)
from app.core.response import success_response, error_response
from app.core.config import settings
from app.models.models import SysUser
from app.crud.user import user_crud, change_password
from app.core.utils import generate_uuid
from app.core import login_crypto
@@ -31,56 +27,18 @@ from app.services.web_auth_roles import (
resolve_web_role_for_user,
user_has_bound_roles,
)
from app.services.web_login_tokens import (
_jwt_user_payload,
_web_role_list,
build_user_info_payload,
issue_web_login_tokens,
)
from app.services.mp_webview_ticket import TICKET_TTL_SEC, consume_ticket, issue_ticket
from app.crud.crm.quote import view_by_share_code
router = APIRouter(prefix="", tags=["公共接口"])
def _avatar_url_field(user: SysUser) -> Optional[str]:
raw = getattr(user, "avatar", None)
if not raw:
return None
s = str(raw).strip()
return s if s else None
def _web_role_list(web_role: str) -> list[str]:
"""与 web-admin 静态路由 meta.roles(admin/super_admin)对齐的单元素数组。"""
role = (web_role or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
return [role]
def _build_user_info_payload(user: SysUser, web_role: str) -> dict:
"""与 web-admin `userInfo` 字段约定一致。"""
phone = (getattr(user, "phone_no", None) or "").strip() or None
role = (web_role or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
return {
"id": user.id,
"username": user.user_no,
"realName": user.nick_name,
"phone": phone,
"email": (getattr(user, "email", None) or "").strip() or None,
"role": role,
"roles": _web_role_list(role),
"regionId": user.dept_id,
"regionName": None,
"avatarUrl": _avatar_url_field(user),
}
def _jwt_user_payload(user: SysUser, web_role: str) -> dict:
tenant_no = (getattr(user, "tenant_no", None) or "").strip() or settings.DEFAULT_TENANT_NO
return {
"sub": user.id,
"username": user.user_no,
"role": web_role,
"region_id": user.dept_id,
"real_name": user.nick_name,
"tenant_no": tenant_no,
}
@router.get("/web/auth/login-rsa-public-key", response_model=dict)
def web_login_rsa_public_key():
"""Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致)"""
@@ -112,38 +70,11 @@ def web_login(
if user.status != "1":
return error_response(403, "账号已被停用,请联系管理员")
web_role = resolve_web_role_for_user(db, user.id, user.tenant_no)
session_id = generate_uuid()
access_jti = generate_uuid()
refresh_jti = generate_uuid()
access_token = create_access_token(
data=_jwt_user_payload(user, web_role),
session_id=session_id,
token_jti=access_jti,
)
refresh_token = create_refresh_token(
data={
"sub": user.id,
"username": user.user_no,
"role": web_role,
},
session_id=session_id,
refresh_jti=refresh_jti,
)
store_session(session_id=session_id, user_id=user.id)
store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
return success_response(
{
"token": access_token,
"refreshToken": refresh_token,
"expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
"userInfo": _build_user_info_payload(user, web_role),
}
)
try:
data = issue_web_login_tokens(db, user)
except ValueError as exc:
return error_response(403, str(exc))
return success_response(data)
@router.post("/web/auth/refresh-token", response_model=dict)
@@ -276,7 +207,7 @@ def mp_webview_exchange(body: dict, db: Session = Depends(get_db)):
"token": access_token,
"refreshToken": refresh_token_val,
"expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
"userInfo": _build_user_info_payload(user, web_role),
"userInfo": build_user_info_payload(user, web_role),
}
)
+2
View File
@@ -29,6 +29,7 @@ from app.api import (
monitor_router,
bx_router,
db_router,
mini_auth_router,
)
from app.api.pl import pl_router
from app.api.pl.public_transport import router as public_pl_transport_router
@@ -159,6 +160,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
app.include_router(public_router, prefix=settings.API_V1_PREFIX)
app.include_router(mini_auth_router, prefix=settings.API_V1_PREFIX)
app.include_router(public_pl_transport_router, prefix=settings.API_V1_PREFIX)
app.include_router(public_pl_transport_site_router, prefix=settings.API_V1_PREFIX)
app.include_router(public_pl_site_router, prefix=settings.API_V1_PREFIX)
+94
View File
@@ -0,0 +1,94 @@
"""Web / 小程序共用:为 sys_user 签发 access + refresh JWT。"""
from __future__ import annotations
from typing import Any, Dict
from sqlalchemy.orm import Session
from app.core.auth import (
create_access_token,
create_refresh_token,
store_refresh_token,
store_session,
)
from app.core.config import settings
from app.core.utils import generate_uuid
from app.models.models import SysUser
from app.services.web_auth_roles import resolve_web_role_for_user
def _avatar_url_field(user: SysUser):
raw = getattr(user, "avatar", None)
if not raw:
return None
s = str(raw).strip()
return s if s else None
def _web_role_list(web_role: str) -> list:
role = (web_role or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
return [role]
def build_user_info_payload(user: SysUser, web_role: str) -> dict:
phone = (getattr(user, "phone_no", None) or "").strip() or None
role = (web_role or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
return {
"id": user.id,
"username": user.user_no,
"realName": user.nick_name,
"phone": phone,
"email": (getattr(user, "email", None) or "").strip() or None,
"role": role,
"roles": _web_role_list(role),
"regionId": user.dept_id,
"regionName": None,
"avatarUrl": _avatar_url_field(user),
}
def _jwt_user_payload(user: SysUser, web_role: str) -> dict:
tenant_no = (getattr(user, "tenant_no", None) or "").strip() or settings.DEFAULT_TENANT_NO
return {
"sub": user.id,
"username": user.user_no,
"role": web_role,
"region_id": user.dept_id,
"real_name": user.nick_name,
"tenant_no": tenant_no,
}
def issue_web_login_tokens(db: Session, user: SysUser) -> Dict[str, Any]:
if getattr(user, "status", "1") != "1":
raise ValueError("账号已被停用,请联系管理员")
web_role = resolve_web_role_for_user(db, user.id, user.tenant_no)
session_id = generate_uuid()
access_jti = generate_uuid()
refresh_jti = generate_uuid()
access_token = create_access_token(
data=_jwt_user_payload(user, web_role),
session_id=session_id,
token_jti=access_jti,
)
refresh_token = create_refresh_token(
data={
"sub": user.id,
"username": user.user_no,
"role": web_role,
},
session_id=session_id,
refresh_jti=refresh_jti,
)
store_session(session_id=session_id, user_id=user.id)
store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
return {
"token": access_token,
"refreshToken": refresh_token,
"expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
"userInfo": build_user_info_payload(user, web_role),
}