"""公共接口(登录等)""" import random import string from datetime import timedelta import httpx from typing import Optional, Tuple from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from app.core.database import get_db from app.core.security import verify_password, get_password_hash from app.core.auth import ( create_access_token, create_refresh_token, decode_token, get_client_ip, get_current_user, 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.config import settings from app.core.response import success_response, error_response from app.models.models import SysUser from app.crud.user import user_crud from app.crud.region import region_crud from app.crud.password_history import password_history_crud from app.core.utils import generate_uuid from app.core import login_crypto router = APIRouter(prefix="", tags=["公共接口"]) def _avatar_url_field(user: SysUser) -> Optional[str]: raw = getattr(user, "avatar_url", None) if not raw: return None s = str(raw).strip() return s if s else None async def _wx_mini_code_to_openid(js_code: str) -> Tuple[Optional[str], Optional[str]]: """调用微信 jscode2session,成功返回 (openid, None),失败返回 (None, 错误信息)。""" appid = settings.WXPAY_MINI_APPID secret = settings.WXPAY_SECRET if not appid or not secret: return None, "后端未配置小程序 AppID/Secret" async with httpx.AsyncClient() as client: r = await client.get( "https://api.weixin.qq.com/sns/jscode2session", params={ "appid": appid, "secret": secret, "js_code": js_code, "grant_type": "authorization_code", }, timeout=10.0, ) data = r.json() if data.get("errcode"): return None, data.get("errmsg") or "微信登录失败" openid = data.get("openid") if not openid: return None, "未获取到 openid" return openid, None @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.get("/mini/auth/login-rsa-public-key", response_model=dict) async def mini_login_rsa_public_key(): """小程序端登录用 RSA 公钥""" 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端登录(默认仅接受 encryptedLogin;可配置 LOGIN_ALLOW_PLAINTEXT_LOGIN 明文排障)""" login_crypto.ensure_initialized() username, password, cred_err = login_crypto.extract_login_credentials(body) if cred_err: return error_response(400, cred_err) user = user_crud.get_by_username(db, username) if not user: return error_response(401, "用户名或密码错误") if not verify_password(password, user.password_hash): return error_response(401, "用户名或密码错误") if not user.is_active: return error_response(403, "账号已被禁用,请联系管理员") region_name = None if user.region_id: region = region_crud.get(db, user.region_id) region_name = region.name if region else None # 创建 Redis 会话(用于强制登出/吊销) session_id = generate_uuid() access_jti = generate_uuid() refresh_jti = generate_uuid() access_token = create_access_token( data={ "sub": user.id, "username": user.username, "role": user.role, "region_id": user.region_id, "real_name": user.real_name }, session_id=session_id, token_jti=access_jti, ) refresh_token = create_refresh_token( data={ "sub": user.id, "username": user.username, "role": user.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": 7200, "userInfo": { "id": user.id, "username": user.username, "realName": user.real_name, "phone": user.phone, "role": user.role, "regionId": user.region_id, "regionName": region_name, "avatarUrl": _avatar_url_field(user), } }) @router.post("/web/auth/refresh-token", response_model=dict) async def refresh_token( body: dict, db: Session = Depends(get_db) ): """刷新Token""" refresh_token = body.get("refreshToken") if not refresh_token: return error_response(400, "refreshToken不能为空") from app.core.auth import decode_token try: payload = decode_token(refresh_token) 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 not user.is_active: return error_response(401, "用户不存在或已禁用") # refreshToken 只允许使用一次:先吊销旧的 jti await revoke_refresh_token(refresh_jti=refresh_jti) # 同步延长会话 TTL(避免刷新后会话过期) await extend_session(session_id=session_id) new_access_jti = generate_uuid() new_refresh_jti = generate_uuid() new_access_token = create_access_token( data={ "sub": user.id, "username": user.username, "role": user.role, "region_id": user.region_id, "real_name": user.real_name }, session_id=session_id, token_jti=new_access_jti, ) new_refresh_token = create_refresh_token( data={ "sub": user.id, "username": user.username, "role": user.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": 7200 }) except HTTPException: return error_response(401, "refreshToken已过期") @router.post("/web/auth/logout", response_model=dict) async def logout(request: Request): """登出""" 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(request: Request, 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 = await get_current_user_by_token(token=token, db=db) region_name = None if current_user.get("region_id"): region = region_crud.get(db, current_user.get("region_id")) region_name = region.name if region else None au = current_user.get("avatar_url") avatar_url = str(au).strip() if au else None 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"), "role": current_user.get("role"), "regionId": current_user.get("region_id"), "regionName": region_name, "isActive": current_user.get("is_active"), "avatarUrl": avatar_url if avatar_url else None, }) except HTTPException: return error_response(401, "Token无效或已过期") # ============ 小程序端接口 ============ @router.post("/mini/auth/login", response_model=dict) async def mini_login( request: Request, body: dict, db: Session = Depends(get_db) ): """小程序端登录(默认仅接受 encryptedLogin)""" login_crypto.ensure_initialized() username, password, cred_err = login_crypto.extract_login_credentials(body) if cred_err: return error_response(400, cred_err) user = user_crud.get_by_username(db, username) if not user: return error_response(401, "用户名或密码错误") if not verify_password(password, user.password_hash): return error_response(401, "用户名或密码错误") if not user.is_active: return error_response(403, "账号已被禁用,请联系管理员") # 小程序端只允许 supplier/client/deliver 登录 if user.role == "admin": return error_response(403, "运营账号不允许在小程序登录,请使用Web端") region_name = None if user.region_id: region = region_crud.get(db, user.region_id) region_name = region.name if region else None session_id = generate_uuid() access_jti = generate_uuid() refresh_jti = generate_uuid() access_token = create_access_token( data={ "sub": user.id, "username": user.username, "role": user.role, "region_id": user.region_id, "real_name": user.real_name }, session_id=session_id, token_jti=access_jti, ) refresh_token = create_refresh_token( data={ "sub": user.id, "username": user.username, "role": user.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": 7200, "userInfo": { "id": user.id, "username": user.username, "realName": user.real_name, "phone": user.phone, "role": user.role, "regionId": user.region_id, "regionName": region_name, "avatarUrl": _avatar_url_field(user), } }) @router.post("/mini/auth/sms-login", response_model=dict) async def mini_sms_login( request: Request, body: dict, db: Session = Depends(get_db) ): """微信小程序 wx.login 返回的 code 登录(需已在系统中绑定 wx_mini_openid)""" code = body.get("code") if not code: return error_response(400, "code不能为空") openid, wx_err = await _wx_mini_code_to_openid(code) if wx_err: return error_response(400, wx_err) user = user_crud.get_by_wx_mini_openid(db, openid) if not user: return error_response(401, "未绑定微信,请先使用账号密码登录并绑定") if not user.is_active: return error_response(403, "账号已被禁用,请联系管理员") if user.role == "admin": return error_response(403, "运营账号不允许在小程序登录,请使用Web端") region_name = None if user.region_id: region = region_crud.get(db, user.region_id) region_name = region.name if region else None session_id = generate_uuid() access_jti = generate_uuid() refresh_jti = generate_uuid() access_token = create_access_token( data={ "sub": user.id, "username": user.username, "role": user.role, "region_id": user.region_id, "real_name": user.real_name }, session_id=session_id, token_jti=access_jti, ) refresh_token = create_refresh_token( data={ "sub": user.id, "username": user.username, "role": user.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": 7200, "userInfo": { "id": user.id, "username": user.username, "realName": user.real_name, "phone": user.phone, "role": user.role, "regionId": user.region_id, "regionName": region_name, "avatarUrl": _avatar_url_field(user), } }) @router.post("/mini/auth/bind-wechat", response_model=dict) async def mini_bind_wechat( body: dict, db: Session = Depends(get_db), current_user: dict = Depends(get_current_user), ): """将当前登录账号与微信 openid 绑定(绑定后可使用 sms-login)""" code = body.get("code") if not code: return error_response(400, "code不能为空") openid, wx_err = await _wx_mini_code_to_openid(code) if wx_err: return error_response(400, wx_err) if current_user.get("role") == "admin": return error_response(403, "运营账号不允许绑定小程序") existing = user_crud.get_by_wx_mini_openid(db, openid) if existing and existing.id != current_user.get("user_id"): return error_response(400, "该微信已绑定其他账号") user_crud.update_with_history( db, current_user.get("user_id"), {"wx_mini_openid": openid}, {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}, ) return success_response({"ok": True}) @router.post("/mini/auth/code-openid", response_model=dict) async def mini_code_openid(body: dict): """wx.login 换取 openid(用于调起微信支付等,code 仅单次有效)""" code = body.get("code") if not code: return error_response(400, "code不能为空") openid, wx_err = await _wx_mini_code_to_openid(code) if wx_err: return error_response(400, wx_err) return success_response({"openid": openid}) @router.get("/mini/auth/current-user", response_model=dict) async def get_mini_current_user(request: Request, 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 = await get_current_user_by_token(token=token, db=db) region_name = None if current_user.get("region_id"): region = region_crud.get(db, current_user.get("region_id")) region_name = region.name if region else None au = current_user.get("avatar_url") avatar_url = str(au).strip() if au else None 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"), "role": current_user.get("role"), "regionId": current_user.get("region_id"), "regionName": region_name, "isActive": current_user.get("is_active"), "avatarUrl": avatar_url if avatar_url else None, }) except HTTPException: return error_response(401, "Token无效或已过期") @router.post("/mini/auth/logout", response_model=dict) async def mini_logout(request: Request): """小程序端登出""" 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.post("/mini/user/change-password", response_model=dict) async def mini_change_password( body: dict, request: Request, db: Session = Depends(get_db) ): """小程序端修改密码""" old_password = body.get("oldPassword") new_password = body.get("newPassword") if not old_password or not new_password: return error_response(400, "旧密码和新密码都不能为空") if len(new_password) < 6: return error_response(400, "新密码长度不能少于6位") 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) user_id = current_user.get("user_id") user = user_crud.get(db, user_id) if not user: return error_response(404, "用户不存在") if not verify_password(old_password, user.password_hash): return error_response(400, "旧密码错误") user.password_hash = get_password_hash(new_password) db.commit() ip_address = get_client_ip(request) password_history_crud.create_password_history( db, user_id=user.id, operation_type="modify", operator_id=user.id, operator_name=user.real_name, ip_address=ip_address ) return success_response({"message": "密码修改成功"}) except HTTPException: return error_response(401, "Token无效或已过期")