dingdanquanliucheng/backend/app/services/auth_service.py
2026-06-14 16:20:04 +08:00

540 lines
21 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
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib import error, request as urllib_request
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", "ai:parse-order"],
"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",
"ai:parse-order",
"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.SYSTEM_ERROR, message="数据库异常", status_code=500) from exc
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库连接不可用", status_code=500)
def wechat_login(self, code: str, phone_code: str, session: Session | None = None) -> dict:
"""微信手机号一键登录。
用 code 换取 session_key 和 open_id用 phone_code 换取手机号,
匹配系统用户并自动绑定 open_id。
Args:
code: wx.login() 获取的临时凭证
phone_code: wx.getPhoneNumber 返回的授权 code
session: 数据库会话
Returns:
用户信息字典(含 token手机号未匹配时抛出异常
被调用路由: auth.py - POST /auth/wechat-login
"""
if session is None:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库连接不可用", status_code=500)
settings = self.settings
if not settings.wechat_app_id or not settings.wechat_app_secret:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="微信配置未就绪,请联系管理员", status_code=500)
try:
# 1. 用 code 换取 session_key 和 open_id
session_key, open_id = self._code2session(code, settings)
if not session_key or not open_id:
raise AppException(code=ErrorCode.UNAUTHORIZED, message="微信登录凭证无效", status_code=401)
# 2. 用 phone_code 换取手机号
phone_number = self._get_phone_number(phone_code, settings)
if not phone_number:
raise AppException(code=ErrorCode.UNAUTHORIZED, message="手机号获取失败", status_code=401)
# 3. 用手机号匹配系统用户
user = self.repository.get_user_by_mobile(session, phone_number)
if user is None:
raise AppException(code=ErrorCode.NOT_FOUND, message=f"手机号 {phone_number} 未注册系统账号", status_code=404)
# 4. 自动绑定 open_id
user.open_id = open_id
session.add(user)
session.commit()
# 5. 加载角色并返回
role = self.repository.get_role(session, user.role_id)
if role is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="角色不存在", status_code=404)
return self._build_profile(session, user, role, include_token=True)
except AppException:
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库异常", status_code=500) from exc
except Exception as exc:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message=f"微信登录失败: {exc}", status_code=500) from exc
def _code2session(self, code: str, settings) -> tuple[str, str]:
"""用 code 调用微信 code2Session 接口,获取 session_key 和 open_id。"""
url = (
f"https://api.weixin.qq.com/sns/jscode2session"
f"?appid={settings.wechat_app_id}"
f"&secret={settings.wechat_app_secret}"
f"&js_code={code}"
f"&grant_type=authorization_code"
)
req = urllib_request.Request(url=url, method="GET")
try:
with urllib_request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode("utf-8") or "{}")
return result.get("session_key", ""), result.get("openid", "")
except (error.HTTPError, error.URLError, json.JSONDecodeError):
return "", ""
def _get_phone_number(self, phone_code: str, settings) -> str:
"""用 phone_code 调用微信 getuserphonenumber 接口获取手机号。"""
access_token = self._get_access_token(settings)
if not access_token:
return ""
url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={access_token}"
body = json.dumps({"code": phone_code}).encode("utf-8")
req = urllib_request.Request(url=url, data=body, headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib_request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode("utf-8") or "{}")
if result.get("errcode") == 0:
return result.get("phone_info", {}).get("purePhoneNumber", "")
except (error.HTTPError, error.URLError, json.JSONDecodeError):
pass
return ""
def _get_access_token(self, settings) -> str:
"""获取微信 access_token简单缓存"""
now = time.time()
if not hasattr(self, "_token_cache"):
self._token_cache = {"token": "", "expires_at": 0}
if self._token_cache["token"] and self._token_cache["expires_at"] > now + 60:
return self._token_cache["token"]
url = (
f"https://api.weixin.qq.com/cgi-bin/token"
f"?grant_type=client_credential"
f"&appid={settings.wechat_app_id}"
f"&secret={settings.wechat_app_secret}"
)
req = urllib_request.Request(url=url, method="GET")
try:
with urllib_request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode("utf-8") or "{}")
token = result.get("access_token", "")
expires_in = int(result.get("expires_in", 0))
if token and expires_in > 0:
self._token_cache["token"] = token
self._token_cache["expires_at"] = now + expires_in
return token
except (error.HTTPError, error.URLError, json.JSONDecodeError):
pass
return ""
def bind_openid_by_code(self, user_id: int, code: str, session: Session | None = None) -> dict:
"""通过 wx.login code 绑定 open_id 到指定用户。
用于密码登录后自动绑定 open_id为微信订阅消息通知做准备。
Args:
user_id: 用户 ID
code: wx.login() 获取的临时凭证
session: 数据库会话
Returns:
{"success": True}
被调用路由: auth.py - POST /auth/bind-openid-by-code
"""
if session is None:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库连接不可用", status_code=500)
settings = self.settings
if not settings.wechat_app_id or not settings.wechat_app_secret:
return {"success": True, "skipped": True}
_, open_id = self._code2session(code, settings)
if not open_id:
return {"success": True, "skipped": True}
user = self.repository.get_user(session, user_id)
if user is not None:
user.open_id = open_id
session.add(user)
session.commit()
return {"success": True}
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()