diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 4017afbcb..a7d57af54 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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", ] diff --git a/backend/app/api/mini_auth.py b/backend/app/api/mini_auth.py new file mode 100644 index 000000000..dad3feccf --- /dev/null +++ b/backend/app/api/mini_auth.py @@ -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) diff --git a/backend/app/api/public.py b/backend/app/api/public.py index 255eb47e9..5add447b6 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -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), } ) diff --git a/backend/app/main.py b/backend/app/main.py index 938f132fc..a0e123add 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/services/web_login_tokens.py b/backend/app/services/web_login_tokens.py new file mode 100644 index 000000000..fa205ab0f --- /dev/null +++ b/backend/app/services/web_login_tokens.py @@ -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), + }