dingdanquanliucheng/backend/app/services/auth_service.py
taiyi 24bcf68d8e feat: 融合司机端和管理层端为统一小程序 mini-app
将 mini-driver 和 mini-manager 两个独立小程序合并为 frontend/mini-app,
通过自定义 tabBar 根据登录角色动态切换界面:
- 后端登录接口 role_type 改为可选,支持自动识别角色
- 统一登录页根据 role_code 自动跳转对应首页
- 统一"我的"页通过 wx:if 切换司机/管理层内容
- 自定义 tabBar 组件按角色渲染不同 tab 项
- 提取公共 utils(auth/request/status)消除重复代码
- 原有两个项目保留不动,待验证后清理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:04:06 +08:00

384 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""认证授权服务层。
负责用户登录、登出、Token 管理、权限菜单加载等认证授权业务逻辑。
依赖 SystemRepository 进行用户和角色数据查询,
依赖 security 模块进行密码验证和 JWT Token 生成/解析。
支持将已吊销的 Token 持久化到本地文件,实现跨进程的登出生效。
"""
import json
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from backend.app.core.config import get_settings
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.core.security import create_access_token, decode_access_token, verify_password
from backend.app.repositories.system_repository import SystemRepository
class AuthService:
"""认证服务封装登录、登出、Token 验证和权限加载逻辑。
依赖:
SystemRepository - 用户、角色、菜单数据访问
security 模块 - 密码验证、JWT Token 生成/解析
settings - Token 存储路径等配置
类属性:
DEFAULT_ROLE_MENUS - 各角色的默认菜单配置(数据库无菜单数据时的兜底)
DEFAULT_ROLE_PERMISSIONS - 各角色的默认权限编码配置(兜底)
"""
DEFAULT_ROLE_MENUS = {
"salesman": [{"menu_name": "我的订单", "menu_path": "/orders"}],
"manager": [{"menu_name": "审批中心", "menu_path": "/pages/approve-list"}],
"driver": [{"menu_name": "我的任务", "menu_path": "/pages/task-list"}],
"admin": [{"menu_name": "后台首页", "menu_path": "/dashboard"}],
}
DEFAULT_ROLE_PERMISSIONS = {
"salesman": ["order:create", "order:list", "order:submit", "customer:list"],
"manager": [
"order:list",
"order:approve",
"order:cancel-approve",
"logistics:task:list",
"logistics:task:create",
"logistics:trace:list",
"logistics:trace:create",
"report:performance:view",
"report:performance:export",
"ai:recognize",
"ai:correct",
"master-data:list",
"master-data:update",
],
"driver": [
"driver:task:list",
"driver:task:accept",
"driver:task:pickup",
"driver:task:deliver",
"logistics:trace:list",
],
"admin": [
"system:view",
"system:user:list",
"system:user:create",
"system:user:update",
"system:user:reset-password",
"system:role:list",
"system:role:create",
"system:role:update",
"system:role:assign-menus",
"system:menu:list",
"system:menu:create",
"system:menu:update",
"report:performance:view",
"report:performance:export",
"logistics:task:list",
"logistics:task:create",
"logistics:task:cancel",
"logistics:trace:list",
"logistics:trace:create",
"ai:recognize",
"ai:correct",
"order:list",
"order:create",
"order:update",
"order:submit",
"order:approve",
"order:cancel-approve",
"order:supplier-text",
"customer:list",
"customer:create",
"customer:update",
"master-data:list",
"master-data:update",
],
}
def __init__(self) -> None:
self.repository = SystemRepository()
self.settings = get_settings()
self.token_store_path = Path(self.settings.auth_token_store_path)
self.token_store_path.parent.mkdir(parents=True, exist_ok=True)
self.revoked_tokens = self._load_revoked_tokens()
def login(self, username: str, password: str, role_type: str, session: Session | None = None) -> dict | None:
"""用户登录。
验证用户名密码、校验角色匹配和账号状态,生成 JWT Token
加载菜单和权限信息。
Args:
username: 用户名
password: 密码(明文)
role_type: 登录的角色类型salesman/manager/driver/admin
session: 数据库会话
Returns:
用户信息字典(含 token、menus、permissions登录失败抛出异常
被调用路由: auth.py - POST /auth/login
"""
if session is not None:
try:
user = self.repository.get_user_by_username(session, username)
if user is None or not verify_password(password, user.password_hash):
raise AppException(
code=ErrorCode.UNAUTHORIZED,
message="账号不存在或密码错误",
status_code=401,
)
role = self.repository.get_role(session, user.role_id)
if role is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="角色不存在", status_code=404)
if user.status != 1:
raise AppException(code=ErrorCode.FORBIDDEN, message="账号已停用", status_code=403)
if role.status != 1 or (role_type and role.role_code != role_type):
raise AppException(code=ErrorCode.FORBIDDEN, message="登录角色与账号不匹配", status_code=403)
return self._build_profile(session, user, role, include_token=True)
except AppException:
raise
except SQLAlchemyError as exc:
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
def get_me(self, token: str, session: Session | None = None) -> dict | None:
"""根据 Token 获取当前用户信息(无需重新登录)。
解析 JWT Token 获取用户 ID 和角色,验证 Token 未被吊销,
重新加载最新的用户信息和权限。
Args:
token: JWT Token 字符串
session: 数据库会话
Returns:
用户信息字典(不含 token验证失败返回 None
被调用路由: deps.py - get_current_user 依赖注入
"""
if self._is_token_revoked(token):
return None
if session is not None:
try:
payload = decode_access_token(token)
if payload is not None:
user_id = int(payload["user_id"])
role_code = str(payload["role_code"])
user = self.repository.get_user(session, user_id)
if user is None or user.status != 1:
return None
role = self.repository.get_role(session, user.role_id)
if role is None or role.status != 1 or role.role_code != role_code:
return None
return self._build_profile(session, user, role, include_token=False)
except (SQLAlchemyError, ValueError, KeyError, TypeError):
pass
return None
def logout(self, token: str) -> dict:
"""用户登出。
将当前 Token 加入吊销列表并持久化到本地文件,确保跨进程生效。
Args:
token: 当前 JWT Token
Returns:
{"success": True}
被调用路由: auth.py - POST /auth/logout
"""
if token:
# token 失效需要跨进程持久化,避免服务重启后已退出的 token 又重新可用。
self._revoke_token(token)
return {"success": True}
def _build_profile(self, session: Session, user, role, include_token: bool) -> dict:
"""构建用户信息字典(含菜单和权限)。
Args:
session: 数据库会话
user: 用户 ORM 对象
role: 角色 ORM 对象
include_token: 是否生成 JWT Token登录时为 Trueget_me 时为 False
Returns:
用户信息字典
"""
menus = self._load_role_menus(session, role.role_code)
permissions = self._load_role_permissions(session, role.role_code)
return {
"user_id": user.id,
"username": user.username,
"real_name": user.real_name,
"mobile": user.mobile,
"role_id": role.id,
"role_name": role.role_name,
"role_code": role.role_code,
"token": create_access_token({"user_id": user.id, "role_code": role.role_code}) if include_token else None,
"menus": menus,
"permissions": permissions,
}
def _load_role_menus(self, session: Session, role_code: str) -> list[dict]:
"""加载角色对应的菜单列表。
admin 角色返回所有菜单,其他角色通过 role_menu 关系表过滤。
若数据库无菜单数据则使用 DEFAULT_ROLE_MENUS 兜底。
Args:
session: 数据库会话
role_code: 角色编码
Returns:
菜单列表,每项包含 menu_name 和 menu_path
"""
menus = self.repository.list_menus(session, {"status": 1})
if not menus:
return self.DEFAULT_ROLE_MENUS.get(role_code, [])
if role_code == "admin":
return [{"menu_name": menu.menu_name, "menu_path": menu.menu_path} for menu in menus]
role = self.repository.get_role_by_code(session, role_code)
if role is not None:
relations = self.repository.list_role_menu_relations(session, role.id)
if relations:
allowed_menu_ids = {item.menu_id for item in relations}
filtered = [
{"menu_name": menu.menu_name, "menu_path": menu.menu_path}
for menu in menus
if menu.id in allowed_menu_ids and menu.menu_path
]
if filtered:
return filtered
allowed_paths = {item["menu_path"] for item in self.DEFAULT_ROLE_MENUS.get(role_code, [])}
filtered = [
{"menu_name": menu.menu_name, "menu_path": menu.menu_path}
for menu in menus
if menu.menu_path in allowed_paths
]
return filtered or self.DEFAULT_ROLE_MENUS.get(role_code, [])
def _load_role_permissions(self, session: Session, role_code: str) -> list[str]:
"""加载角色对应的权限编码列表。
admin 角色返回所有权限,其他角色通过 role_menu 关系表过滤。
若数据库无菜单数据则使用 DEFAULT_ROLE_PERMISSIONS 兜底。
Args:
session: 数据库会话
role_code: 角色编码
Returns:
权限编码字符串列表
"""
menus = self.repository.list_menus(session, {"status": 1})
if not menus:
return self.DEFAULT_ROLE_PERMISSIONS.get(role_code, [])
if role_code == "admin":
permissions = [menu.permission_code for menu in menus if menu.permission_code]
default_permissions = self.DEFAULT_ROLE_PERMISSIONS.get(role_code, [])
merged = list(dict.fromkeys([*default_permissions, *permissions]))
return merged or default_permissions
role = self.repository.get_role_by_code(session, role_code)
if role is not None:
relations = self.repository.list_role_menu_relations(session, role.id)
if relations:
allowed_menu_ids = {item.menu_id for item in relations}
permissions = [
menu.permission_code
for menu in menus
if menu.id in allowed_menu_ids and menu.permission_code
]
if permissions:
return permissions
allowed = set(self.DEFAULT_ROLE_PERMISSIONS.get(role_code, []))
permissions = [menu.permission_code for menu in menus if menu.permission_code in allowed]
return permissions or self.DEFAULT_ROLE_PERMISSIONS.get(role_code, [])
def _load_revoked_tokens(self) -> dict[str, int]:
"""从本地文件加载已吊销的 Token 列表。
自动清理已过期的 Token 条目。
Returns:
Token 到过期时间戳的映射字典
"""
if not self.token_store_path.exists():
return {}
try:
payload = json.loads(self.token_store_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
now_ts = int(datetime.now(timezone.utc).timestamp())
tokens = {
token: int(exp)
for token, exp in payload.items()
if isinstance(token, str) and isinstance(exp, int) and exp > now_ts
}
if len(tokens) != len(payload):
self._save_revoked_tokens(tokens)
return tokens
def _save_revoked_tokens(self, tokens: dict[str, int]) -> None:
"""将已吊销的 Token 列表持久化到本地文件。
Args:
tokens: Token 到过期时间戳的映射字典
"""
self.token_store_path.write_text(json.dumps(tokens, ensure_ascii=False, indent=2), encoding="utf-8")
def _is_token_revoked(self, token: str) -> bool:
"""检查 Token 是否已被吊销。
同时清理已过期的吊销条目。
Args:
token: JWT Token 字符串
Returns:
True 表示已吊销
"""
if not token:
return False
now_ts = int(datetime.now(timezone.utc).timestamp())
expired_tokens = [item for item, exp in self.revoked_tokens.items() if exp <= now_ts]
if expired_tokens:
for item in expired_tokens:
self.revoked_tokens.pop(item, None)
self._save_revoked_tokens(self.revoked_tokens)
return token in self.revoked_tokens
def _revoke_token(self, token: str) -> None:
"""吊销指定 Token 并持久化。
解析 Token 的过期时间,存入吊销列表并写入本地文件。
Args:
token: 需要吊销的 JWT Token
"""
payload = decode_access_token(token)
expire_at = int(payload.get("exp", 0)) if payload else int(datetime.now(timezone.utc).timestamp()) + 60
self.revoked_tokens[token] = expire_at
self._save_revoked_tokens(self.revoked_tokens)
auth_service = AuthService()