dingdanquanliucheng/backend/app/services/auth_service.py

540 lines
21 KiB
Python
Raw Normal View History

"""认证授权服务层。
负责用户登录登出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
2026-05-14 13:51:06 +08:00
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 = {
2026-06-01 11:24:20 +08:00
"salesman": ["order:create", "order:list", "order:submit", "customer:list", "ai:parse-order"],
"manager": [
"order:list",
"order:approve",
"order:cancel-approve",
2026-05-19 14:48:47 +08:00
"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",
2026-05-19 14:48:47 +08:00
"logistics:task:list",
"logistics:task:create",
2026-05-26 12:46:58 +08:00
"logistics:task:cancel",
"logistics:trace:list",
"logistics:trace:create",
"ai:recognize",
"ai:correct",
"ai:parse-order",
"order:list",
2026-05-26 12:46:58 +08:00
"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:
用户信息字典 tokenmenuspermissions登录失败抛出异常
被调用路由: 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:
2026-06-14 16:20:04 +08:00
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库异常", status_code=500) from exc
2026-06-14 16:20:04 +08:00
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库连接不可用", status_code=500)
2026-05-14 13:51:06 +08:00
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:
2026-06-14 16:20:04 +08:00
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:
2026-06-14 16:20:04 +08:00
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()
2026-06-14 16:20:04 +08:00
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库异常", status_code=500) from exc
except Exception as exc:
2026-06-14 16:20:04 +08:00
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:
2026-06-14 16:20:04 +08:00
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 依赖注入
"""
2026-05-19 10:37:06 +08:00
if self._is_token_revoked(token):
return None
if session is not None:
try:
payload = decode_access_token(token)
2026-05-19 10:37:06 +08:00
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
2026-05-14 13:51:06 +08:00
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)
2026-05-14 13:51:06 +08:00
auth_service = AuthService()