"""公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/shanxia2.0.sql 基线)""" import base64 from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session from app.core.database import get_db from app.core.security import verify_password from app.core.auth import ( create_access_token, create_refresh_token, decode_token, get_current_user_by_token, extend_session, is_refresh_token_valid, is_session_valid, revoke_refresh_token, revoke_session, store_refresh_token, ) from app.core.response import success_response, error_response from app.core.config import settings from app.crud.user import user_crud, change_password from app.core.utils import generate_uuid from app.core import login_crypto from app.services.web_auth_roles import ( iter_user_menu_perm_codes, 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.services import wx_qr_session from app.services.wechat_mini_auth import WechatMiniAuthError, get_unlimited_wxacode from app.crud.crm.quote import view_by_share_code router = APIRouter(prefix="", tags=["公共接口"]) @router.get("/web/auth/login-rsa-public-key", response_model=dict) def web_login_rsa_public_key(): """Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致)""" login_crypto.ensure_initialized() return success_response({"publicKeyPem": login_crypto.get_public_pem()}) @router.post("/web/auth/login", response_model=dict) def web_login( request: Request, body: dict, db: Session = Depends(get_db), ): """Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。""" login_crypto.ensure_initialized() user_no, password, cred_err = login_crypto.extract_login_credentials(body) if cred_err: return error_response(400, cred_err) user = user_crud.get_by_user_no_for_web_login(db, user_no) if not user: return error_response(400, "用户名或密码错误") pwd_hash = getattr(user, "password", None) or "" if not verify_password(password, pwd_hash): return error_response(400, "用户名或密码错误") if user.status != "1": return error_response(403, "账号已被停用,请联系管理员") 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/wx-qr/create", response_model=dict) async def wx_qr_create(): session_id, short = wx_qr_session.create_session() page = (settings.WX_QR_PAGE or "pages/login/web-qr/index").strip() try: raw = get_unlimited_wxacode( scene=short, page=page, env_version=settings.WX_QR_ENV_VERSION, ) except WechatMiniAuthError as exc: wx_qr_session.invalidate(session_id) return error_response(400, str(exc)) b64 = base64.b64encode(raw).decode("ascii") mime = "image/jpeg" if raw[:2] == b"\xff\xd8" else "image/png" return success_response( { "sessionId": session_id, "expiresIn": settings.WX_QR_SESSION_TTL_SEC, "qrImageBase64": f"data:{mime};base64,{b64}", } ) @router.get("/web/auth/wx-qr/status", response_model=dict) async def wx_qr_status(sessionId: str = ""): sid = (sessionId or "").strip() data = wx_qr_session.get_session(sid) if not data: return success_response({"status": "expired", "message": "二维码已过期,请刷新"}) status = data.get("status") or "expired" if status == "success": tokens = wx_qr_session.consume_success(sid) if not tokens: return success_response({"status": "expired", "message": "登录凭证已失效,请刷新二维码"}) return success_response({"status": "success", **tokens}) if status == "failed": return success_response({"status": "failed", "message": data.get("message") or "登录失败"}) if status in ("expired", "consumed"): return success_response({"status": "expired", "message": "二维码已过期,请刷新"}) return success_response({"status": status}) @router.post("/web/auth/refresh-token", response_model=dict) def refresh_token(body: dict, db: Session = Depends(get_db)): """刷新 Token""" refresh_token_val = body.get("refreshToken") if not refresh_token_val: return error_response(400, "refreshToken不能为空") try: payload = decode_token(refresh_token_val) if payload.get("type") != "refresh": return error_response(401, "无效的refreshToken") session_id = payload.get("sid") refresh_jti = payload.get("jti") if not is_session_valid(session_id=session_id): return error_response(401, "refreshToken已失效,请重新登录") if not is_refresh_token_valid(refresh_jti=refresh_jti): return error_response(401, "无效的refreshToken") user_id = payload.get("sub") user = user_crud.get(db, user_id) if not user or user.del_flag != 0 or user.status != "1": return error_response(401, "用户不存在或已禁用") web_role = resolve_web_role_for_user(db, user.id, user.tenant_no) revoke_refresh_token(refresh_jti=refresh_jti) extend_session(session_id=session_id) new_access_jti = generate_uuid() new_refresh_jti = generate_uuid() new_access_token = create_access_token( data=_jwt_user_payload(user, web_role), session_id=session_id, token_jti=new_access_jti, ) new_refresh_token = create_refresh_token( data={ "sub": user.id, "username": user.user_no, "role": web_role, }, session_id=session_id, refresh_jti=new_refresh_jti, ) store_refresh_token(refresh_jti=new_refresh_jti, user_id=user.id) return success_response( { "token": new_access_token, "refreshToken": new_refresh_token, "expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, } ) except HTTPException: return error_response(401, "refreshToken已过期") @router.post("/web/auth/mp-webview-ticket", response_model=dict) def mp_webview_ticket(request: Request, db: Session = Depends(get_db)): """小程序内嵌 WebView:签发一次性短时 ticket(需 Bearer)。""" authorization = request.headers.get("Authorization", "") if not authorization.startswith("Bearer "): return error_response(401, "未授权") token = authorization.replace("Bearer ", "").strip() try: current_user = get_current_user_by_token(token=token, db=db) payload = decode_token(token) except HTTPException: return error_response(401, "Token无效或已过期") session_id = str(payload.get("sid") or "").strip() if not session_id: return error_response(401, "Token无效或已过期") try: ticket = issue_ticket( user_id=str(current_user.get("user_id") or ""), session_id=session_id, refresh_jti=str(payload.get("jti") or ""), ) except RuntimeError: return error_response(503, "暂时无法打开内嵌页,请稍后重试") return success_response({"ticket": ticket, "expiresIn": TICKET_TTL_SEC}) @router.post("/web/auth/mp-webview-exchange", response_model=dict) def mp_webview_exchange(body: dict, db: Session = Depends(get_db)): """小程序内嵌 WebView:用 ticket 兑换 Web JWT(不吊销原 refresh)。""" ticket = str((body or {}).get("ticket") or "").strip() if not ticket: return error_response(400, "ticket不能为空") try: data = consume_ticket(ticket) except RuntimeError: return error_response(503, "暂时无法打开内嵌页,请稍后重试") if not data: return error_response(401, "ticket无效或已过期") user_id = str(data.get("userId") or "").strip() session_id = str(data.get("sessionId") or "").strip() if not user_id or not session_id: return error_response(401, "ticket无效或已过期") if not is_session_valid(session_id): return error_response(401, "登录已失效,请重新登录") user = user_crud.get(db, user_id) if not user or getattr(user, "del_flag", 0) != 0: return error_response(401, "用户不存在或已删除") if user.status != "1": return error_response(403, "账号已被停用,请联系管理员") web_role = resolve_web_role_for_user(db, user.id, user.tenant_no) 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_val = create_refresh_token( data={"sub": user.id, "username": user.user_no, "role": web_role}, session_id=session_id, refresh_jti=refresh_jti, ) store_refresh_token(refresh_jti=refresh_jti, user_id=user.id) return success_response( { "token": access_token, "refreshToken": refresh_token_val, "expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, "userInfo": build_user_info_payload(user, web_role), } ) @router.post("/web/auth/logout", response_model=dict) def logout(request: Request): """登出:吊销 Redis 会话(若已配置 REDIS_HOST)。无 Token 或已失效时仍返回成功(幂等)。""" authorization = request.headers.get("Authorization", "") if authorization.startswith("Bearer "): token = authorization.replace("Bearer ", "") try: payload = decode_token(token) session_id = payload.get("sid") if session_id: revoke_session(session_id=session_id) except HTTPException: pass return success_response({"message": "登出成功"}) @router.get("/web/auth/current-user", response_model=dict) def get_current_user_profile(request: Request, db: Session = Depends(get_db)): """获取当前用户信息(与 sys_user 字段对应)。""" authorization = request.headers.get("Authorization", "") if not authorization.startswith("Bearer "): return error_response(401, "未授权") token = authorization.replace("Bearer ", "") try: current_user = get_current_user_by_token(token=token, db=db) au = current_user.get("avatar_url") avatar_url = str(au).strip() if au else None web_role = str(current_user.get("role") or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE return success_response( { "id": current_user.get("user_id"), "username": current_user.get("username"), "realName": current_user.get("real_name"), "phone": current_user.get("phone"), "email": current_user.get("email"), "role": web_role, "roles": _web_role_list(web_role), "isSuperAdmin": web_role == "super_admin", "regionId": current_user.get("region_id"), "regionName": None, "regionIds": [], "isActive": current_user.get("is_active"), "avatarUrl": avatar_url if avatar_url else None, } ) except HTTPException: return error_response(401, "Token无效或已过期") @router.get("/web/auth/menu-codes", response_model=dict) def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db)): """获取当前用户菜单权限标识(sys_menu.perms,经角色继承)。""" authorization = request.headers.get("Authorization", "") if not authorization.startswith("Bearer "): return error_response(401, "未授权") token = authorization.replace("Bearer ", "") try: current_user = get_current_user_by_token(token=token, db=db) user_id = current_user.get("user_id") or "" tenant_no = current_user.get("tenant_no") if not user_has_bound_roles(db, user_id, tenant_no): return success_response( { "isSuperAdmin": False, "all": False, "menuCodes": [], } ) role = current_user.get("role") if role == "super_admin": return success_response( { "isSuperAdmin": True, "all": True, "menuCodes": [], } ) try: codes = iter_user_menu_perm_codes(db, user_id, tenant_no) menu_codes = sorted(codes) except OperationalError: menu_codes = [] return success_response( { "isSuperAdmin": False, "all": False, "menuCodes": menu_codes, } ) except HTTPException: return error_response(401, "Token无效或已过期") @router.post("/web/auth/change-password", response_model=dict) def change_current_user_password( request: Request, body: dict, db: Session = Depends(get_db), ): """当前用户修改密码。""" authorization = request.headers.get("Authorization", "") if not authorization.startswith("Bearer "): return error_response(401, "未授权") token = authorization.replace("Bearer ", "") try: current_user = get_current_user_by_token(token=token, db=db) user_id = current_user.get("user_id") if not user_id: return error_response(401, "未授权") old_password = (body.get("oldPassword") or body.get("old_password") or "").strip() new_password = (body.get("newPassword") or body.get("new_password") or "").strip() if not old_password: return error_response(400, "请输入原密码") if len(new_password) < 6: return error_response(400, "新密码长度不能少于6位") change_password(db, user_id, old_password, new_password) return success_response({"message": "密码修改成功"}) except HTTPException: return error_response(401, "Token无效或已过期") except ValueError as exc: return error_response(400, str(exc)) @router.get("/public/crm/quote/share/{share_code}") def public_quote_share( share_code: str, request: Request, password: str = "", db: Session = Depends(get_db), ): """公开分享查看报价单 — Java: GET /share/{shareCode}(无 JWT)""" try: ip_address = request.client.host if request.client else "" user_agent = request.headers.get("user-agent", "") detail = view_by_share_code( db, share_code, password=password or "", ip_address=ip_address, user_agent=user_agent, ) return success_response(detail) except ValueError as e: return error_response(400, str(e)) except Exception as e: return error_response(500, str(e))