refactor: 整合仪表盘功能到监控模块,清理冗余代码

- 移除原监控仪表盘独立模块,将相关功能合并到在线监控模块
- 重构租户配置字段名,统一使用logo_url和name替代tenant_logo/tenant_name
- 优化搜索工具函数,移除重复导入
- 调整参数配置模型字段长度限制,移除config_value的max_length约束
- 清理冗余的常量定义和导入语句
- 修复批量状态设置接口的redis依赖注入
- 增强OAuth登录安全性,添加租户默认归属和state一次性消费
- 优化资源目录缓存逻辑,减少重复计算
- 新增API Token模块基础框架
- 完善用户token版本管理,支持主动失效JWT
- 调整AI模型配置缓存过期时间
- 修复菜单类型字段索引,提升查询性能
- 简化前端刷新token调用逻辑
- 新增滑块验证完成接口和忘记密码验证码校验
- 调整系统配置默认值,添加操作日志保留天数和接口白名单配置
- 限制Mock支付回调仅在开发环境可用
- 重构websocket认证方式,支持更安全的subprotocol传参
This commit is contained in:
zhangtao
2026-07-13 01:14:20 +08:00
parent 6a5f8cf0dd
commit cf88ab8897
102 changed files with 4078 additions and 3777 deletions
@@ -37,6 +37,8 @@ from .schema import (
LoginWithTenantsSchema,
SelectTenantOutSchema,
SelectTenantSchema,
SliderCompleteOutSchema,
SliderCompleteSchema,
TenantLookupOutSchema,
TenantOptionSchema,
TenantRegisterOutSchema,
@@ -74,6 +76,25 @@ async def lookup_tenant_by_domain_controller(
return SuccessResponse(data=data, msg="查询成功")
@AuthRouter.get("/tenant-options", summary="获取所有租户选项(登录页下拉选择)", response_model=ResponseSchema[list[TenantOptionSchema]])
async def get_tenant_options_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
"""获取所有活跃租户下拉选项"""
data = await TenantLookupService.list_options(db=db)
return SuccessResponse(data=data, msg="查询成功")
@AuthRouter.get("/tenant-search", summary="搜索租户(按编码/名称模糊匹配)", response_model=ResponseSchema[list[TenantOptionSchema]])
async def search_tenant_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
q: Annotated[str, Query(description="搜索关键字")],
) -> JSONResponse:
"""模糊搜索租户"""
data = await TenantLookupService.search(db=db, q=q)
return SuccessResponse(data=data, msg="查询成功")
@AuthRouter.post("/login", summary="登录", response_model=LoginWithTenantsSchema)
async def login_for_access_token_controller(
request: Request,
@@ -109,6 +130,15 @@ async def get_captcha_for_login_controller(
return SuccessResponse(data=captcha, msg="获取验证码成功")
@AuthRouter.post("/captcha/slider/complete", summary="滑块验证完成", response_model=ResponseSchema[SliderCompleteOutSchema])
async def slider_complete_controller(
redis: Annotated[Redis, Depends(redis_getter)],
body: SliderCompleteSchema,
) -> JSONResponse:
result = await CaptchaService.slider_complete(redis=redis, captcha_key=body.captcha_key)
return SuccessResponse(data=result, msg="滑块验证成功")
@AuthRouter.post("/logout", summary="退出登录", response_model=ResponseSchema[None], dependencies=[Depends(get_current_user)])
async def logout_controller(
redis: Annotated[Redis, Depends(redis_getter)],
@@ -316,6 +316,7 @@ async def ensure_oauth_user(
username=username,
password=secrets.token_urlsafe(24),
name=(display_name or username)[:32],
tenant_id=1, # 系统租户:OAuth 用户未指定业务租户,统一落到 system tenant
role_ids=list(settings.OAUTH_DEFAULT_ROLE_IDS),
)
try:
@@ -344,6 +345,8 @@ async def complete_oauth_login(
) -> tuple[JWTOutSchema, str]:
rc = RedisCURD(redis)
raw = await rc.get(f"{STATE_PREFIX}{state}")
# 安全加固:state 一次性消费(read-then-delete)— 防止重放 / 跨上下文劫持
await rc.delete(f"{STATE_PREFIX}{state}")
if not raw:
raise CustomException(msg="登录状态已失效,请重试")
if isinstance(raw, bytes):
+15 -17
View File
@@ -2,23 +2,8 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from app.core.base_schema import AuthSchema, CoreUserSchema, JWTOutSchema
from app.core.base_schema import JWTOutSchema
__all__ = [
"AuthSchema",
"CoreUserSchema",
"CaptchaOutSchema",
"TenantOptionSchema",
"SelectTenantSchema",
"SelectTenantOutSchema",
"LoginWithTenantsSchema",
"TenantRegisterSchema",
"TenantRegisterOutSchema",
"EnterPlatformOutSchema",
"TenantLookupOutSchema",
"ImpersonateSchema",
"ImpersonateOutSchema",
]
class CaptchaOutSchema(BaseModel):
"""验证码响应模型"""
@@ -27,7 +12,7 @@ class CaptchaOutSchema(BaseModel):
enable: bool = Field(default=True, description="是否启用验证码")
key: str = Field(..., min_length=1, description="验证码唯一标识")
img_base: str = Field(..., min_length=1, description="Base64编码的验证码图片")
img_base: str = Field(default="", description="Base64编码的验证码图片(滑块模式为空字符串)")
class TenantOptionSchema(BaseModel):
@@ -123,3 +108,16 @@ class ImpersonateOutSchema(BaseModel):
expires_in: int = Field(..., gt=0, description="过期时间(秒)")
tenant_id: int = Field(..., description="目标租户ID")
tenant_name: str = Field(..., description="目标租户名称")
class SliderCompleteSchema(BaseModel):
"""滑块验证完成请求"""
captcha_key: str = Field(..., min_length=1, description="验证码唯一标识")
class SliderCompleteOutSchema(BaseModel):
"""滑块验证完成响应"""
captcha_key: str = Field(..., description="验证码唯一标识")
verified: bool = Field(default=True, description="是否验证通过")
+242 -209
View File
@@ -1,31 +1,45 @@
import json
import uuid
from dataclasses import replace
from datetime import datetime, timedelta
from typing import NewType
from typing import Any, NewType
import ua_parser
from fastapi import BackgroundTasks, Request
from redis.asyncio.client import Redis
from sqlalchemy import func, select
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
from app.api.v1.module_system.log.crud import LoginLogCRUD
from app.api.v1.module_system.log.model import LoginLogModel
from app.api.v1.module_system.log.schema import LoginLogCreateSchema
from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel
from app.api.v1.module_system.user.crud import UserCRUD
from app.api.v1.module_system.user.model import UserModel
from app.api.v1.module_system.user.model import UserModel, UserRolesModel
from app.api.v1.module_system.user.schema import UserOutSchema
from app.common.enums import RedisInitKeyConfig
from app.config.setting import settings
from app.core.base_schema import AuthSchema, JWTOutSchema, JWTPayloadSchema
from app.core.database import async_db_session
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.core.redis_crud import RedisCURD
from app.core.request_context import (
RequestContext,
clear_current_tenant,
set_current_tenant,
)
from app.core.security import (
CustomOAuth2PasswordRequestForm,
create_access_token,
decode_access_token,
)
from app.utils.captcha_util import CaptchaUtil
from app.utils.common_util import get_random_character
from app.utils.password_util import PwdUtil
from app.utils.ip_local_util import IpLocalUtil, get_client_ip
from app.utils.password_util import PwdUtil
from .schema import (
CaptchaOutSchema,
@@ -51,10 +65,6 @@ async def _write_login_log(
msg: str | None = None,
) -> int | None:
"""写入登录日志;返回日志 ID(用于后台补全归属地)。"""
from app.api.v1.module_system.log.crud import LoginLogCRUD
from app.api.v1.module_system.log.schema import LoginLogCreateSchema
from app.core.database import async_db_session
try:
async with async_db_session() as session, session.begin():
_auth = AuthSchema(check_data_scope=False)
@@ -83,11 +93,6 @@ async def _async_fill_login_location(redis, login_log_id: int, ip: str | None) -
logger.info(f"异步解析IP归属地结果: ip={ip}, log_id={login_log_id}, location={location}")
if location == "归属地查询中" or not location:
return
from sqlalchemy import update as sa_update
from app.api.v1.module_system.log.model import LoginLogModel
from app.core.database import async_db_session
async with async_db_session() as session, session.begin():
await session.execute(sa_update(LoginLogModel).where(LoginLogModel.id == login_log_id).values(login_location=location))
logger.info(f"登录日志归属地已更新: log_id={login_log_id}, location={location}")
@@ -102,6 +107,47 @@ class LoginService:
self.auth = auth
self.db = db
@staticmethod
def _collect_permissions(
user: UserModel,
) -> tuple[list[str], dict[str, int], list[int], list[int], list[int], list[int]]:
"""收集用户角色下的权限、菜单、数据范围及角色 ID
遍历用户角色,聚合所有关联菜单的 permission、menu_id、
data_scope、自定义部门 ID 和角色 ID 列表。
参数:
- user (UserModel): 用户对象
返回:
- tuple[list[str], dict[str, int], list[int], list[int], list[int], list[int]]:
(permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids)
"""
permissions: list[str] = []
permissions_with_menu: dict[str, int] = {}
menu_ids: list[int] = []
data_scopes: list[int] = []
custom_dept_ids: list[int] = []
role_ids: list[int] = []
if not user.is_superuser and hasattr(user, "roles"):
for role in user.roles:
if role and role.status == 0:
role_ids.append(role.id)
if hasattr(role, "menus"):
for menu in role.menus:
if menu and menu.status == 0:
menu_ids.append(menu.id)
if menu.permission:
permissions.append(menu.permission)
permissions_with_menu[menu.permission] = menu.id
if hasattr(role, "data_scope"):
data_scopes.append(role.data_scope)
if hasattr(role, "depts") and role.depts:
for dept in role.depts:
if dept:
custom_dept_ids.append(dept.id)
return permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids
@classmethod
async def authenticate_user(
cls,
@@ -123,12 +169,12 @@ class LoginService:
request_from_docs = referer.endswith(("docs", "redoc"))
if settings.CAPTCHA_ENABLE and not request_from_docs:
if not login_form.captcha_key or not login_form.captcha:
if not login_form.captcha_key:
raise CustomException(msg="验证码不能为空")
# 滑块模式:slider_complete 已验证身份,此处仅校验状态
await CaptchaService.check_captcha(
redis=redis,
key=login_form.captcha_key,
captcha=login_form.captcha,
)
auth = AuthSchema(check_data_scope=False)
@@ -169,10 +215,6 @@ class LoginService:
)
raise CustomException(msg="用户已被停用")
from sqlalchemy import select
from app.api.v1.module_platform.tenant.model import TenantModel
tenant_stmt = select(TenantModel).where(TenantModel.id == user.tenant_id, TenantModel.status == 0, TenantModel.is_deleted.is_(False)).limit(1)
tenant_result = await db.execute(tenant_stmt)
if not tenant_result.scalar_one_or_none():
@@ -234,56 +276,42 @@ class LoginService:
user_info=user_info,
)
@classmethod
async def create_token(cls, request: Request, redis: Redis, user: UserModel, login_type: str) -> JWTOutSchema:
"""创建访问令牌和刷新令牌"""
session_id = str(uuid.uuid4())
ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
request_ip = get_client_ip(request)
@staticmethod
def _build_session_dict(
user: UserModel,
session_id: str,
permissions: list[str],
permissions_with_menu: dict[str, int],
menu_ids: list[int],
data_scopes: list[int],
custom_dept_ids: list[int],
role_ids: list[int],
request_ip: str,
login_location: str | None,
ua_result: Any,
login_type: str,
) -> dict:
"""构建会话信息字典
login_location = await IpLocalUtil.resolve_location_for_log(redis, request_ip)
from dataclasses import replace
from app.core.request_context import RequestContext
base_ctx = getattr(request.state, "ctx", None) or RequestContext()
request.state.ctx = replace(
base_ctx,
session_id=session_id,
user_username=user.username,
login_location=login_location,
)
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
now = datetime.now()
参数:
- user (UserModel): 用户对象
- session_id (str): 会话ID
- permissions (list[str]): 权限标识列表
- permissions_with_menu (dict[str, int]): 权限与菜单ID映射
- menu_ids (list[int]): 菜单ID列表
- data_scopes (list[int]): 数据范围列表
- custom_dept_ids (list[int]): 自定义部门ID列表
- role_ids (list[int]): 角色ID列表
- request_ip (str): 请求IP
- login_location (str): 登录地点
- ua_result: User-Agent 解析结果
- login_type (str): 登录类型
返回:
- dict: 会话信息字典
"""
tenant_status = getattr(user.tenant, "status", 0) if hasattr(user, "tenant") and user.tenant else 0
permissions = []
permissions_with_menu = {}
menu_ids = []
data_scopes = []
custom_dept_ids = []
if not user.is_superuser and hasattr(user, "roles"):
for role in user.roles:
if role and role.status == 0 and hasattr(role, "menus"):
for menu in role.menus:
if menu and menu.status == 0:
menu_ids.append(menu.id)
if menu.permission:
permissions.append(menu.permission)
permissions_with_menu[menu.permission] = menu.id
if hasattr(role, "data_scope"):
data_scopes.append(role.data_scope)
if hasattr(role, "depts") and role.depts:
for dept in role.depts:
if dept:
custom_dept_ids.append(dept.id)
session_dict = {
return {
"session_id": session_id,
"user_id": user.id,
"tenant_id": user.tenant_id if not user.is_superuser else 0,
@@ -302,6 +330,7 @@ class LoginService:
"menu_ids": menu_ids,
"data_scopes": data_scopes,
"custom_dept_ids": custom_dept_ids,
"role_ids": role_ids,
"ipaddr": request_ip,
"login_location": login_location,
"os": ua_result.os.family if ua_result.os else "Unknown",
@@ -309,6 +338,45 @@ class LoginService:
"login_time": user.last_login,
"login_type": login_type,
}
@classmethod
async def create_token(cls, request: Request, redis: Redis, user: UserModel, login_type: str) -> JWTOutSchema:
"""创建访问令牌和刷新令牌"""
session_id = str(uuid.uuid4())
ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
request_ip = get_client_ip(request)
login_location = await IpLocalUtil.resolve_location_for_log(redis, request_ip)
base_ctx = getattr(request.state, "ctx", None) or RequestContext()
request.state.ctx = replace(
base_ctx,
session_id=session_id,
user_username=user.username,
login_location=login_location,
)
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
now = datetime.now()
permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids = LoginService._collect_permissions(user)
session_dict = LoginService._build_session_dict(
user=user,
session_id=session_id,
permissions=permissions,
permissions_with_menu=permissions_with_menu,
menu_ids=menu_ids,
data_scopes=data_scopes,
custom_dept_ids=custom_dept_ids,
role_ids=role_ids,
request_ip=request_ip,
login_location=login_location,
ua_result=ua_result,
login_type=login_type,
)
session_info = json.dumps(session_dict, default=str)
# 会话信息存 Redis(完整 JSON),JWT sub 仅含 session_id
@@ -450,8 +518,6 @@ class LoginService:
"""获取用户关联的租户列表"""
from sqlalchemy import select
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
user = self.auth.user
if not user:
raise CustomException(msg="未认证用户")
@@ -487,10 +553,6 @@ class LoginService:
tenant_id: int,
) -> SelectTenantOutSchema:
"""选择租户:验证用户归属并签发含租户上下文的新 JWT Token"""
from sqlalchemy import select
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
user = self.auth.user
if not user:
raise CustomException(msg="未认证用户")
@@ -514,57 +576,10 @@ class LoginService:
if not tenant:
raise CustomException(msg="租户不存在或已被禁用")
ctx = getattr(request.state, "ctx", None)
session_id = ctx.session_id if ctx else None
session_info = ctx.session_info if ctx else None
if not session_id or not session_info:
raise CustomException(msg="会话已失效")
# 更新会话中的租户 ID 并写回 Redis
session_info["tenant_id"] = tenant_id
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
from app.core.redis_crud import RedisCURD
from app.core.security import create_access_token
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}",
value=json.dumps(session_info) if isinstance(session_info, dict) else session_info,
expire=int(refresh_expires.total_seconds()),
new_access_token, _new_refresh_token, access_expires = await self._rebuild_tokens(
request, redis, {"tenant_id": tenant_id}
)
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
now = datetime.now()
new_access_token = create_access_token(
payload=JWTPayloadSchema(
sub=session_id,
is_refresh=False,
exp=now + access_expires,
),
)
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
value=new_access_token,
expire=int(access_expires.total_seconds()),
)
new_refresh_token = create_access_token(
payload=JWTPayloadSchema(
sub=session_id,
is_refresh=True,
exp=now + refresh_expires,
),
)
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
value=new_refresh_token,
expire=int(refresh_expires.total_seconds()),
)
from app.core.request_context import set_current_tenant
set_current_tenant(tenant_id)
logger.info(f"用户 {user.username}(id={user.id}) 切换到租户 {tenant.name}(id={tenant_id})")
@@ -575,16 +590,24 @@ class LoginService:
expires_in=int(access_expires.total_seconds()),
)
async def enter_platform(
async def _rebuild_tokens(
self,
request: Request,
redis: Redis,
) -> EnterPlatformOutSchema:
"""进入平台管理模式:清除会话中的 tenant_id,返回平台作用域 JWT"""
user = self.auth.user
if not user:
raise CustomException(msg="未认证用户")
session_updates: dict,
) -> tuple[str, str, timedelta]:
"""从请求上下文重建全套令牌(access + refresh + session
提取会话信息,应用更新后写入 Redis,签发新 JWT。
参数:
- request (Request): FastAPI 请求对象
- redis (Redis): Redis 客户端
- session_updates (dict): 需更新到 session_info 的键值对
返回:
- tuple[str, str, timedelta]: (access_token, refresh_token, access_expires)
"""
ctx = getattr(request.state, "ctx", None)
session_id = ctx.session_id if ctx else None
session_info = ctx.session_info if ctx else None
@@ -592,7 +615,7 @@ class LoginService:
if not session_id or not session_info:
raise CustomException(msg="会话已失效")
session_info["tenant_id"] = 0
session_info.update(session_updates)
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
await RedisCURD(redis).set(
@@ -631,7 +654,21 @@ class LoginService:
expire=int(refresh_expires.total_seconds()),
)
from app.core.request_context import clear_current_tenant
return new_access_token, new_refresh_token, access_expires
async def enter_platform(
self,
request: Request,
redis: Redis,
) -> EnterPlatformOutSchema:
"""进入平台管理模式:清除会话中的 tenant_id,返回平台作用域 JWT"""
user = self.auth.user
if not user:
raise CustomException(msg="未认证用户")
new_access_token, _new_refresh_token, access_expires = await self._rebuild_tokens(
request, redis, {"tenant_id": 0}
)
clear_current_tenant()
@@ -650,10 +687,6 @@ class LoginService:
tenant_id: int,
) -> ImpersonateOutSchema:
"""平台管理员代签入:以指定租户身份登录(仅超级管理员可用)"""
from sqlalchemy import select
from app.api.v1.module_platform.tenant.model import TenantModel
user = self.auth.user
if not user or not user.is_superuser:
raise CustomException(msg="仅平台管理员可执行代签入")
@@ -664,55 +697,10 @@ class LoginService:
if not tenant:
raise CustomException(msg="租户不存在")
ctx = getattr(request.state, "ctx", None)
session_id = ctx.session_id if ctx else None
session_info = ctx.session_info if ctx else None
if not session_id or not session_info:
raise CustomException(msg="会话已失效")
session_info["tenant_id"] = tenant_id
session_info["is_impersonate"] = True
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}",
value=json.dumps(session_info) if isinstance(session_info, dict) else session_info,
expire=int(refresh_expires.total_seconds()),
new_access_token, new_refresh_token, access_expires = await self._rebuild_tokens(
request, redis, {"tenant_id": tenant_id, "is_impersonate": True}
)
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
now = datetime.now()
new_access_token = create_access_token(
payload=JWTPayloadSchema(
sub=session_id,
is_refresh=False,
exp=now + access_expires,
),
)
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
value=new_access_token,
expire=int(access_expires.total_seconds()),
)
new_refresh_token = create_access_token(
payload=JWTPayloadSchema(
sub=session_id,
is_refresh=True,
exp=now + refresh_expires,
),
)
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
value=new_refresh_token,
expire=int(refresh_expires.total_seconds()),
)
from app.core.request_context import set_current_tenant
set_current_tenant(tenant_id)
logger.warning(f"平台管理员 {user.username}(id={user.id}) 代签入租户 {tenant.name}(id={tenant_id})")
@@ -728,43 +716,68 @@ class LoginService:
class CaptchaService:
"""验证码服务"""
"""验证码服务 — 滑块拖动模式"""
@staticmethod
async def get_captcha(redis: Redis) -> CaptchaOutSchema:
"""获取验证码"""
"""获取验证码(滑块模式:仅生成 key,无需算术图片)"""
if not settings.CAPTCHA_ENABLE:
raise CustomException(msg="未开启验证码服务")
captcha_base64, captcha_value = CaptchaUtil.captcha_arithmetic()
captcha_key = get_random_character()
redis_key = f"{RedisInitKeyConfig.CAPTCHA_CODES.key}:{captcha_key}"
# 存储滑块状态:pending(待验证)/ verified(已验证通过)
await RedisCURD(redis).set(
key=redis_key,
value=captcha_value,
value="pending",
expire=settings.CAPTCHA_EXPIRE_SECONDS,
)
return CaptchaOutSchema(
enable=settings.CAPTCHA_ENABLE,
key=CaptchaKey(captcha_key),
img_base=CaptchaBase64(f"data:image/png;base64,{captcha_base64}"),
img_base=CaptchaBase64(""),
)
@staticmethod
async def check_captcha(redis: Redis, key: str, captcha: str) -> bool:
"""校验验证码"""
if not captcha:
raise CustomException(msg="验证码不能为空")
async def slider_complete(redis: Redis, captcha_key: str) -> dict:
"""标记滑块验证完成"""
if not captcha_key:
raise CustomException(msg="验证码标识不能为空")
redis_key = f"{RedisInitKeyConfig.CAPTCHA_CODES.key}:{captcha_key}"
status = await RedisCURD(redis).get(redis_key)
if not status:
raise CustomException(msg="验证码已过期,请刷新")
if isinstance(status, bytes):
status = status.decode()
if status == "verified":
raise CustomException(msg="验证码已使用")
# 标记为已验证
await RedisCURD(redis).set(
key=redis_key,
value="verified",
expire=settings.CAPTCHA_EXPIRE_SECONDS,
)
return {"captcha_key": captcha_key, "verified": True}
@staticmethod
async def check_captcha(redis: Redis, key: str) -> bool:
"""校验滑块验证码:检查 key 状态是否为 verified"""
redis_key = f"{RedisInitKeyConfig.CAPTCHA_CODES.key}:{key}"
captcha_value = await RedisCURD(redis).get(redis_key)
if not captcha_value:
raise CustomException(msg="验证码已过期")
status = await RedisCURD(redis).get(redis_key)
if not status:
raise CustomException(msg="验证码已过期,请刷新")
if captcha.lower() != captcha_value.lower():
raise CustomException(msg="验证码错误")
if isinstance(status, bytes):
status = status.decode()
if status != "verified":
raise CustomException(msg="请先完成滑块验证")
await RedisCURD(redis).delete(redis_key)
return True
@@ -785,14 +798,8 @@ class TenantRegisterService:
tenant_name: str | None = None,
) -> TenantRegisterOutSchema:
"""租户自助注册:一次性创建租户 + 管理员 + owner 角色 + 菜单分配"""
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel
from app.api.v1.module_system.user.model import UserModel, UserRolesModel
exists_stmt = (
select(func.count())
.select_from(UserModel)
@@ -892,10 +899,6 @@ class TenantLookupService:
@staticmethod
async def lookup_by_code(db: AsyncSession, code: str) -> dict:
from sqlalchemy import select
from app.api.v1.module_platform.tenant.model import TenantModel
stmt = select(TenantModel).where(
TenantModel.code == code,
TenantModel.is_deleted.is_(False),
@@ -915,10 +918,6 @@ class TenantLookupService:
@staticmethod
async def lookup_by_domain(db: AsyncSession, domain: str) -> dict:
from sqlalchemy import select
from app.api.v1.module_platform.tenant.model import TenantModel
stmt = select(TenantModel).where(
TenantModel.domain == domain,
TenantModel.is_deleted.is_(False),
@@ -935,3 +934,37 @@ class TenantLookupService:
"login_bg": result.login_bg,
"version": result.version,
}
@staticmethod
async def list_options(db: AsyncSession) -> list[dict]:
"""获取所有活跃租户选项(登录页下拉选择)"""
stmt = (
select(TenantModel)
.where(TenantModel.is_deleted.is_(False), TenantModel.status == 0)
.order_by(TenantModel.id)
)
results = (await db.execute(stmt)).scalars().all()
return [
{"id": r.id, "name": r.name, "code": r.code}
for r in results
]
@staticmethod
async def search(db: AsyncSession, q: str) -> list[dict]:
"""模糊搜索租户(按编码或名称)"""
pattern = f"%{q}%"
stmt = (
select(TenantModel)
.where(
TenantModel.is_deleted.is_(False),
TenantModel.status == 0,
(TenantModel.code.ilike(pattern) | TenantModel.name.ilike(pattern)),
)
.order_by(TenantModel.id)
.limit(20)
)
results = (await db.execute(stmt)).scalars().all()
return [
{"id": r.id, "name": r.name, "code": r.code}
for r in results
]