"""公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线)""" import os from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File 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.core.storage import storage from app.core.avatar_url import extract_avatar_storage_key from app.models.models import SysUser from app.crud.user import user_crud from app.core.utils import generate_uuid from app.core import login_crypto from app.services.web_auth_roles import ( get_user_role_names, iter_user_menu_perm_codes, resolve_web_role_for_user, user_has_active_roles, ) from app.schemas.auth import ProfileUpdateRequest from app.crud.user import update_user, change_password router = APIRouter(prefix="", tags=["公共接口"]) def _avatar_url_field(user: SysUser) -> Optional[str]: """返回库中原始头像路径,由前端按 VITE_MINIO_BASE_URL 拼展示 URL。""" raw = getattr(user, "avatar", None) if not raw: return None s = str(raw).strip() return s if s else None def _build_user_info_payload(user: SysUser, web_role: str, role_names: list = None) -> dict: """与 web-admin `userInfo` 字段约定一致。""" phone = (getattr(user, "phone_no", None) or "").strip() or None return { "id": user.id, "username": user.user_no, "realName": user.nick_name, "phone": phone, "email": (getattr(user, "email", None) or "").strip() or None, "sex": getattr(user, "sex", None) or "0", "remark": (getattr(user, "remark", None) or "").strip() or None, "role": web_role, "roleNames": role_names or [], "regionId": user.dept_id, "regionName": None, "avatarUrl": _avatar_url_field(user), } def _build_current_user_payload(current_user: dict) -> dict: """组装 current-user 接口响应。""" au = current_user.get("avatar_url") avatar_url = str(au).strip() if au else None return { "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"), "sex": current_user.get("sex") or "0", "remark": current_user.get("remark"), "role": current_user.get("role"), "roleNames": current_user.get("role_names") or current_user.get("roleNames") or [], "isSuperAdmin": current_user.get("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, } async def _resolve_current_user_from_request(request: Request, db: Session) -> dict: authorization = request.headers.get("Authorization", "") if not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="未授权") token = authorization.replace("Bearer ", "") return await get_current_user_by_token(token=token, db=db) def _jwt_user_payload(user: SysUser, web_role: str) -> dict: return { "sub": user.id, "username": user.user_no, "role": web_role, "region_id": user.dept_id, "real_name": user.nick_name, } @router.get("/web/auth/login-rsa-public-key", response_model=dict) async 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) async 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(db, user_no) if not user: return error_response(401, "用户名或密码错误") pwd_hash = getattr(user, "password", None) or "" if not verify_password(password, pwd_hash): return error_response(401, "用户名或密码错误") if user.status != "1": return error_response(403, "账号已被停用,请联系管理员") if not user_has_active_roles(db, user.id): return error_response(403, "账号未分配角色,请联系管理员") web_role = resolve_web_role_for_user(db, user.id) role_names = get_user_role_names(db, user.id) 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, ) await store_session(session_id=session_id, user_id=user.id) await 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, role_names), } ) @router.post("/web/auth/refresh-token", response_model=dict) async 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 await is_session_valid(session_id=session_id): return error_response(401, "refreshToken已失效,请重新登录") if not await 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) await revoke_refresh_token(refresh_jti=refresh_jti) await 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, ) await 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) async def logout(request: Request): """登出:吊销 Redis 会话(若已配置 REDIS_HOST)。""" authorization = request.headers.get("Authorization", "") if not authorization.startswith("Bearer "): return error_response(401, "未授权") token = authorization.replace("Bearer ", "") try: payload = decode_token(token) session_id = payload.get("sid") if session_id: await revoke_session(session_id=session_id) except HTTPException: return error_response(401, "Token无效或已过期") return success_response({"message": "登出成功"}) @router.get("/web/auth/current-user", response_model=dict) async def get_current_user_profile(request: Request, db: Session = Depends(get_db)): """获取当前用户信息(与 sys_user 字段对应)。""" try: current_user = await _resolve_current_user_from_request(request, db) return success_response(_build_current_user_payload(current_user)) except HTTPException: return error_response(401, "Token无效或已过期") @router.put("/web/auth/profile", response_model=dict) async def update_current_user_profile( request: Request, body: ProfileUpdateRequest, db: Session = Depends(get_db), ): """当前用户更新个人信息(手机号、邮箱、性别、备注)。""" try: current_user = await _resolve_current_user_from_request(request, db) user_id = current_user.get("user_id") if not user_id: return error_response(401, "未授权") payload = body.model_dump(exclude_unset=True, by_alias=False) update_user( db, user_id, phone_no=payload.get("phone_no"), email=payload.get("email"), sex=payload.get("sex"), remark=payload.get("remark"), avatar=payload.get("avatar"), user_id_updater=user_id, ) user = user_crud.get(db, user_id) if not user: return error_response(404, "用户不存在") web_role = resolve_web_role_for_user(db, user.id) role_names = get_user_role_names(db, user.id) return success_response(_build_user_info_payload(user, web_role, role_names)) except HTTPException: return error_response(401, "Token无效或已过期") except ValueError as exc: return error_response(400, str(exc)) @router.post("/web/auth/change-password", response_model=dict) async def change_current_user_password( request: Request, body: dict, db: Session = Depends(get_db), ): """当前用户修改密码。""" try: current_user = await _resolve_current_user_from_request(request, 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)) _AVATAR_MAX_BYTES = 2 * 1024 * 1024 _AVATAR_ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} @router.post("/web/auth/upload-avatar", response_model=dict) async def upload_current_user_avatar( request: Request, file: UploadFile = File(...), db: Session = Depends(get_db), ): """当前用户上传头像(仅图片,最大 2MB)。""" try: await _resolve_current_user_from_request(request, db) except HTTPException: return error_response(401, "Token无效或已过期") if not file or not file.filename: return error_response(400, "请选择图片") ext = os.path.splitext(file.filename)[1].lower() if ext not in _AVATAR_ALLOWED_EXT: return error_response(400, "仅支持 JPG、PNG、GIF、WEBP、BMP 格式") content_type = (file.content_type or "").lower() if content_type and not content_type.startswith("image/"): return error_response(400, "仅支持上传图片文件") try: file_content = await file.read() if not file_content: return error_response(400, "文件内容为空") if len(file_content) > _AVATAR_MAX_BYTES: return error_response(400, "头像大小不能超过 2MB") url = storage.upload_file(file_content, file.filename, "avatar") stored_url = extract_avatar_storage_key(url) return success_response({"url": stored_url}) except Exception as exc: return error_response(500, f"上传失败: {str(exc)}") @router.get("/web/auth/menu-codes", response_model=dict) async 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 = await get_current_user_by_token(token=token, db=db) 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, current_user.get("user_id") or "") 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无效或已过期")