| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629 |
- """公共接口(登录等)"""
- 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, SysUserMenuScope, SysUserRegionScope
- 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
- region_scope_rows = (
- db.query(SysUserRegionScope.region_id)
- .filter(
- SysUserRegionScope.user_id == current_user.get("user_id"),
- SysUserRegionScope.is_del == 0,
- )
- .all()
- )
- region_ids = sorted({str(row[0]).strip() for row in region_scope_rows if row and row[0]})
- 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"),
- "isSuperAdmin": current_user.get("role") == "super_admin",
- "regionId": current_user.get("region_id"),
- "regionName": region_name,
- "regionIds": region_ids,
- "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)
- async def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db)):
- """获取当前用户可见菜单 code 列表。super_admin 永远全菜单。"""
- 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": []
- })
- rows = (
- db.query(SysUserMenuScope.menu_code)
- .filter(
- SysUserMenuScope.user_id == current_user.get("user_id"),
- SysUserMenuScope.is_del == 0,
- )
- .all()
- )
- menu_codes = sorted({str(row[0]).strip() for row in rows if row and row[0]})
- return success_response({
- "isSuperAdmin": False,
- "all": False,
- "menuCodes": menu_codes
- })
- 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无效或已过期")
|