"""公共接口(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 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, 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 from app.services.web_auth_roles import ( iter_user_menu_perm_codes, resolve_web_role_for_user, user_has_bound_roles, ) 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 一致)""" 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, "账号已被停用,请联系管理员") 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), } ) @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/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))