feat(phase1): core/ 基础设施迁移,旧路径改垫片
- core/: database/redis_client/jwt/password/response/exceptions/security/logger/deps/middleware/app - create_app 迁移到 core/app.py,中间件注册顺序与异常处理器原样保留 - extensions/ utils/ apps/ 旧文件全部改为 re-export 垫片,import 路径保持兼容 - 新增 core 单元测试(jwt/password/response/request 共 12 个) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+2
-157
@@ -1,157 +1,2 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.logger import logger
|
||||
from starlette import status
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from extensions.exception import AuthorizationException, BusinessException
|
||||
from extensions import register_extends
|
||||
from routers import register_router
|
||||
from apps.middleware import register_middleware, register_cros
|
||||
from apps.middleware.db_session import db_session_middleware
|
||||
from apps.middleware.operation_log import operation_log_middleware
|
||||
from utils.redis_ctx import redis_lifespan, redis_middleware
|
||||
from config.app import FASTAPI_DEBUG
|
||||
|
||||
|
||||
# 创建应用
|
||||
def create_app() -> FastAPI:
|
||||
# # 实例化应用并鉴权
|
||||
app = FastAPI(title="FastAPI",
|
||||
description="FastAPI",
|
||||
version="v1",
|
||||
lifespan=redis_lifespan,
|
||||
)
|
||||
|
||||
# 注册跨域中间件
|
||||
register_cros(app)
|
||||
# 注册Redis上下文注入中间件(先注册→内层:先注入Redis再开DB会话)
|
||||
app.middleware("http")(redis_middleware)
|
||||
# 注册数据库会话中间件(后注册→外层:DB会话包裹业务逻辑,finally保证关闭)
|
||||
app.middleware("http")(db_session_middleware)
|
||||
# 注册操作日志中间件(在DB会话内层,记录写操作日志)
|
||||
app.middleware("http")(operation_log_middleware)
|
||||
# 注册路由
|
||||
register_router(app)
|
||||
# 初始化扩展
|
||||
register_extends(app)
|
||||
# 登录检测
|
||||
register_middleware(app)
|
||||
# 注册全局异常处理
|
||||
register_exception(app)
|
||||
|
||||
# 请求计时(最后注册→最外层,统计完整耗时)
|
||||
@app.middleware("http")
|
||||
async def add_process_time_header(request: Request, call_next):
|
||||
# 开始时间
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
# 过程时间
|
||||
process_time = time.time() - start_time
|
||||
# X- 作为前缀代表专有自定义请求头
|
||||
response.headers["X-Process-Time"] = str(round(process_time, 5))
|
||||
if FASTAPI_DEBUG:
|
||||
logger.info(f"访问记录:{request.method} url:{request.url} 耗时:{str(round(process_time, 5))}")
|
||||
# 放行
|
||||
return response
|
||||
|
||||
# 返回应用
|
||||
return app
|
||||
|
||||
|
||||
# 注册全局异常处理
|
||||
def register_exception(app: FastAPI):
|
||||
"""
|
||||
全局异常捕获
|
||||
:param app:
|
||||
:return:
|
||||
"""
|
||||
|
||||
# 登录认证异常处理执行句柄
|
||||
@app.exception_handler(AuthorizationException)
|
||||
async def unicorn_exception_handler(request: Request, exc: AuthorizationException):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"code": exc.code,
|
||||
"data": None,
|
||||
"msg": exc.msg,
|
||||
"ok": False,
|
||||
},
|
||||
)
|
||||
|
||||
# 业务异常处理执行句柄
|
||||
@app.exception_handler(BusinessException)
|
||||
async def business_exception_handler(request: Request, exc: BusinessException):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"code": exc.code,
|
||||
"data": None,
|
||||
"msg": exc.msg,
|
||||
"ok": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Pydantic 请求参数校验异常处理
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
errors = []
|
||||
for err in exc.errors():
|
||||
# 取字段路径最后一段作为字段名
|
||||
field = err.get("loc", [""])[-1]
|
||||
msg = err.get("msg", "")
|
||||
if field and field != "body":
|
||||
errors.append(f"{field}: {msg}")
|
||||
else:
|
||||
errors.append(msg)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": 1, "data": None, "msg": "/".join(errors)},
|
||||
)
|
||||
# 兼容垫片:应用工厂已迁移到 core/app.py,保留旧导入路径
|
||||
from core.app import create_app
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# | 开源协议: Apache-2.0
|
||||
# +======================================================================
|
||||
|
||||
"""核心基础设施包:数据库、Redis、JWT、密码、响应、中间件、依赖注入。"""
|
||||
|
||||
from core import response as R
|
||||
from core import password
|
||||
from core.database import get_db_session, SessionLocal, commit_independently, Base
|
||||
from core.redis_client import get_redis
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# | 开源协议: Apache-2.0
|
||||
# +======================================================================
|
||||
# 由 apps/__init__.py 迁移而来。
|
||||
|
||||
"""应用工厂:create_app 组装中间件、路由、异常处理与 lifespan。"""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.logger import logger
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from config.app import FASTAPI_DEBUG
|
||||
from core.exceptions import AuthorizationException, BusinessException
|
||||
from core.middleware import (
|
||||
register_middleware,
|
||||
register_cros,
|
||||
db_session_middleware,
|
||||
operation_log_middleware,
|
||||
)
|
||||
from core.redis_client import redis_lifespan, redis_middleware
|
||||
from routers import register_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="FastAPI",
|
||||
description="FastAPI",
|
||||
version="v1",
|
||||
lifespan=redis_lifespan,
|
||||
)
|
||||
|
||||
# 注册跨域中间件
|
||||
register_cros(app)
|
||||
# 注册Redis上下文注入中间件(先注册→内层:先注入Redis再开DB会话)
|
||||
app.middleware("http")(redis_middleware)
|
||||
# 注册数据库会话中间件(后注册→外层:DB会话包裹业务逻辑,finally保证关闭)
|
||||
app.middleware("http")(db_session_middleware)
|
||||
# 注册操作日志中间件(在DB会话内层,记录写操作日志)
|
||||
app.middleware("http")(operation_log_middleware)
|
||||
# 注册路由
|
||||
register_router(app)
|
||||
# 登录检测
|
||||
register_middleware(app)
|
||||
# 注册全局异常处理
|
||||
register_exception(app)
|
||||
|
||||
# 请求计时(最后注册→最外层,统计完整耗时)
|
||||
@app.middleware("http")
|
||||
async def add_process_time_header(request: Request, call_next):
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
process_time = time.time() - start_time
|
||||
response.headers["X-Process-Time"] = str(round(process_time, 5))
|
||||
if FASTAPI_DEBUG:
|
||||
logger.info(f"访问记录:{request.method} url:{request.url} 耗时:{str(round(process_time, 5))}")
|
||||
return response
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# 注册全局异常处理
|
||||
def register_exception(app: FastAPI):
|
||||
"""全局异常捕获。"""
|
||||
|
||||
@app.exception_handler(AuthorizationException)
|
||||
async def unicorn_exception_handler(request: Request, exc: AuthorizationException):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": exc.code, "data": None, "msg": exc.msg, "ok": False},
|
||||
)
|
||||
|
||||
@app.exception_handler(BusinessException)
|
||||
async def business_exception_handler(request: Request, exc: BusinessException):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": exc.code, "data": None, "msg": exc.msg, "ok": False},
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
errors = []
|
||||
for err in exc.errors():
|
||||
field = err.get("loc", [""])[-1]
|
||||
msg = err.get("msg", "")
|
||||
if field and field != "body":
|
||||
errors.append(f"{field}: {msg}")
|
||||
else:
|
||||
errors.append(msg)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": 1, "data": None, "msg": "/".join(errors)},
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# | 开源协议: Apache-2.0
|
||||
# +======================================================================
|
||||
# 由 extensions/sqlalchemy.py 迁移而来。
|
||||
|
||||
"""数据库核心:引擎、会话工厂、请求级会话上下文、声明式基类。"""
|
||||
|
||||
from contextvars import ContextVar
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config import (
|
||||
SQLALCHEMY_MYSQL_URL, FASTAPI_DEBUG,
|
||||
DB_POOL_SIZE, DB_MAX_OVERFLOW, DB_POOL_RECYCLE,
|
||||
DB_POOL_TIMEOUT, DB_CONNECT_TIMEOUT,
|
||||
)
|
||||
|
||||
# 创建一个SQLAlchemy引擎
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_MYSQL_URL,
|
||||
pool_size=DB_POOL_SIZE,
|
||||
pool_recycle=DB_POOL_RECYCLE,
|
||||
pool_timeout=DB_POOL_TIMEOUT,
|
||||
max_overflow=DB_MAX_OVERFLOW,
|
||||
connect_args={"connect_timeout": DB_CONNECT_TIMEOUT},
|
||||
pool_pre_ping=True,
|
||||
pool_use_lifo=True,
|
||||
echo_pool=FASTAPI_DEBUG,
|
||||
)
|
||||
# SessionLocal该类的每个实例将是一个数据库会话。该类本身还不是数据库会话。
|
||||
# 一旦我们创建了SessionLocal该类的实例,该实例将成为实际的数据库会话。
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# 请求级数据库 session 上下文变量
|
||||
_db_ctx: ContextVar = ContextVar('_db_ctx')
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""获取当前请求的数据库 session"""
|
||||
return _db_ctx.get()
|
||||
|
||||
|
||||
def commit_independently(obj):
|
||||
"""
|
||||
使用独立session提交对象,不受请求事务影响
|
||||
|
||||
日志等审计数据无论业务成功或失败都应持久化,
|
||||
因此需要绕过 AOP 事务中间件的 commit/rollback 机制。
|
||||
"""
|
||||
independent_session = SessionLocal()
|
||||
try:
|
||||
independent_session.add(obj)
|
||||
independent_session.commit()
|
||||
except Exception:
|
||||
independent_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
independent_session.close()
|
||||
|
||||
|
||||
# 声明式基类,所有业务模型继承它
|
||||
Base = declarative_base()
|
||||
@@ -0,0 +1,29 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# | 开源协议: Apache-2.0
|
||||
# +======================================================================
|
||||
|
||||
"""全局依赖注入:统一提供当前用户、数据库会话、Redis 客户端。"""
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from core.database import get_db_session
|
||||
from core.redis_client import get_redis
|
||||
from core.security import get_user_id
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> int:
|
||||
"""返回当前登录用户 ID(由 login_required 注入 request.state)。"""
|
||||
return get_user_id(request)
|
||||
|
||||
|
||||
def get_db():
|
||||
"""返回当前请求的数据库会话。"""
|
||||
return get_db_session()
|
||||
|
||||
|
||||
def get_redis_client():
|
||||
"""返回当前请求的 Redis 客户端。"""
|
||||
return get_redis()
|
||||
@@ -0,0 +1,27 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# | 开源协议: Apache-2.0
|
||||
# +======================================================================
|
||||
# 由 extensions/exception.py 迁移而来。
|
||||
|
||||
"""统一异常类型:登录认证异常 + 业务异常。"""
|
||||
|
||||
|
||||
class AuthorizationException(Exception):
|
||||
"""登录认证异常,由 login_required 中间件抛出。"""
|
||||
|
||||
def __init__(self, code: int, msg: str):
|
||||
super().__init__(msg)
|
||||
self.code = code
|
||||
self.msg = msg
|
||||
|
||||
|
||||
class BusinessException(Exception):
|
||||
"""业务异常,业务代码可主动抛出。"""
|
||||
|
||||
def __init__(self, code: int = 1, msg: str = ""):
|
||||
super().__init__(msg)
|
||||
self.code = code
|
||||
self.msg = msg
|
||||
+573
@@ -0,0 +1,573 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 珊瑚云团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 珊瑚云团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
JWT令牌处理模块
|
||||
=============================================================
|
||||
|
||||
该模块提供基于JWT(JSON Web Token)的令牌生成和验证功能。
|
||||
主要用于用户登录认证,生成访问令牌,并在后续请求中验证令牌的有效性。
|
||||
支持令牌过期机制,确保系统的安全性。
|
||||
|
||||
主要功能:
|
||||
1. JWT令牌生成(create_token)- 用于用户登录成功后生成访问令牌
|
||||
2. JWT令牌解析验证(parse_payload)- 验证请求中的令牌有效性
|
||||
3. 统一的异常处理,返回标准格式的验证结果
|
||||
4. 可配置的令牌过期时间
|
||||
|
||||
所属包: utils
|
||||
所在文件: utils/jwt.py
|
||||
依赖模块: jwt - JWT处理第三方库
|
||||
datetime - 时间处理模块
|
||||
hashlib - 摘要算法模块
|
||||
os - 环境变量读取模块
|
||||
logging - 日志记录模块
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
import jwt
|
||||
from jwt import exceptions
|
||||
|
||||
from config import JWT_SALT, JWT_EXPIRE_MINUTES
|
||||
|
||||
# 配置日志记录器
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================
|
||||
# JWT密钥配置
|
||||
# =============================================================
|
||||
# 从 config.auth 统一读取,密钥配置在 .env 文件中管理
|
||||
|
||||
def _validate_key_strength(key: str) -> None:
|
||||
"""
|
||||
启动时校验 JWT 密钥强度
|
||||
|
||||
校验规则:
|
||||
1. 密钥不能为空
|
||||
2. 密钥长度至少 32 字节(256 位)
|
||||
|
||||
校验失败直接抛出 RuntimeError,拒绝启动应用。
|
||||
"""
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"JWT_SALT 未配置!请在 .env 中设置至少32字节的强随机密钥。\n"
|
||||
"生成方式:python -c \"import secrets; print(secrets.token_urlsafe(48))\""
|
||||
)
|
||||
|
||||
key_bytes = len(key.encode('utf-8'))
|
||||
if key_bytes < 32:
|
||||
raise RuntimeError(
|
||||
f"JWT_SALT 长度不足:{key_bytes} 字节,至少需要 32 字节(256位)。\n"
|
||||
"生成方式:python -c \"import secrets; print(secrets.token_urlsafe(48))\""
|
||||
)
|
||||
|
||||
logger.info(f"JWT密钥校验通过,长度:{key_bytes}字节")
|
||||
|
||||
|
||||
# 启动时校验密钥强度
|
||||
_validate_key_strength(JWT_SALT)
|
||||
|
||||
# JWT算法配置
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
|
||||
# 默认令牌过期时间(分钟),从配置读取
|
||||
DEFAULT_TIMEOUT_MINUTES = JWT_EXPIRE_MINUTES
|
||||
|
||||
|
||||
# =============================================================
|
||||
# JWT核心功能函数
|
||||
# =============================================================
|
||||
|
||||
def create_token(payload, timeout=None):
|
||||
"""
|
||||
生成JWT令牌
|
||||
=============================================================
|
||||
|
||||
根据传入的载荷数据生成JWT访问令牌,自动添加过期时间。
|
||||
使用强密钥确保令牌安全性,符合RFC 7518安全规范。
|
||||
|
||||
参数:
|
||||
payload (dict): JWT载荷数据,通常包含用户ID、用户名等信息
|
||||
timeout (int): 令牌过期时间(分钟),默认使用DEFAULT_TIMEOUT_MINUTES
|
||||
|
||||
返回:
|
||||
str: 生成的JWT令牌字符串
|
||||
|
||||
使用示例:
|
||||
>>> payload = {'userId': 1001, 'username': 'admin'}
|
||||
>>> token = create_token(payload, timeout=30)
|
||||
>>> print(token)
|
||||
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
|
||||
|
||||
注意事项:
|
||||
1. 密钥长度必须至少32字节,否则会发出警告
|
||||
2. 生产环境建议通过环境变量设置JWT_SALT
|
||||
3. 令牌过期后需要重新登录获取新令牌
|
||||
"""
|
||||
try:
|
||||
# 设置过期时间
|
||||
# 说明:在载荷中添加exp字段,指定令牌的过期时间点
|
||||
# 计算方式:当前UTC时间 + 指定的分钟数
|
||||
if timeout is None:
|
||||
timeout = DEFAULT_TIMEOUT_MINUTES
|
||||
|
||||
# 创建payload副本,避免修改原始数据
|
||||
payload_copy = payload.copy()
|
||||
|
||||
# 添加过期时间(exp)和签发时间(iat)
|
||||
# exp: 过期时间,超过此时间令牌无效
|
||||
# iat: 签发时间,用于令牌生命周期计算
|
||||
current_time = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
payload_copy['exp'] = current_time + datetime.timedelta(minutes=timeout)
|
||||
payload_copy['iat'] = current_time
|
||||
|
||||
# 声明类型和加密算法
|
||||
# 说明:定义JWT头部信息,包含令牌类型和加密算法
|
||||
headers = {
|
||||
"typ": "JWT", # 令牌类型
|
||||
"alg": JWT_ALGORITHM # 加密算法
|
||||
}
|
||||
|
||||
# 生成JWT令牌
|
||||
# 函数:jwt.encode()
|
||||
# 参数说明:
|
||||
# payload: JWT载荷数据
|
||||
# key: 加密密钥(盐)
|
||||
# algorithm: 加密算法(HS256)
|
||||
# headers: JWT头部信息
|
||||
token = jwt.encode(
|
||||
payload=payload_copy,
|
||||
key=JWT_SALT,
|
||||
algorithm=JWT_ALGORITHM,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
# 记录日志(可选,生产环境建议使用DEBUG级别)
|
||||
logger.debug(f"生成JWT令牌成功,过期时间:{timeout}分钟")
|
||||
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
# 捕获并记录生成令牌时的异常
|
||||
logger.error(f"生成JWT令牌失败: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def parse_payload(token):
|
||||
"""
|
||||
解析验证JWT令牌
|
||||
=============================================================
|
||||
|
||||
验证传入的JWT令牌,检查签名有效性和过期状态。
|
||||
统一处理各种异常情况,返回标准格式的结果对象。
|
||||
|
||||
参数:
|
||||
token (str): 需要验证的JWT令牌字符串
|
||||
|
||||
返回:
|
||||
dict: 验证结果,包含以下字段:
|
||||
code (int): 状态码,0-成功,1-失败
|
||||
data (dict/None): 验证成功时返回载荷数据,失败时为None
|
||||
msg (str): 提示信息
|
||||
|
||||
使用示例:
|
||||
>>> result = parse_payload(token)
|
||||
>>> if result['code'] == 0:
|
||||
... user_id = result['data']['userId']
|
||||
... username = result['data']['username']
|
||||
... else:
|
||||
... error_msg = result['msg']
|
||||
... # 处理令牌失效,跳转登录页
|
||||
|
||||
异常处理说明:
|
||||
- ExpiredSignatureError: 令牌已过期,需要重新登录
|
||||
- DecodeError: 解码失败,令牌格式错误或签名无效
|
||||
- InvalidTokenError: 其他JWT相关异常
|
||||
"""
|
||||
# 初始化返回结果
|
||||
# 说明:定义统一返回格式,默认成功状态
|
||||
result = {
|
||||
"code": 0,
|
||||
"data": None,
|
||||
"msg": "操作成功"
|
||||
}
|
||||
|
||||
# 参数校验
|
||||
if not token:
|
||||
result['code'] = 1
|
||||
result['msg'] = "token不能为空"
|
||||
return result
|
||||
|
||||
try:
|
||||
# 解密验证JWT令牌
|
||||
# 函数:jwt.decode()
|
||||
# 参数说明:
|
||||
# jwt: 令牌字符串
|
||||
# key: 解密密钥(必须与加密时使用的密钥一致)
|
||||
# algorithms: 允许的算法列表
|
||||
#
|
||||
# 说明:jwt.decode会自动完成以下验证:
|
||||
# 1. 签名验证 - 确保令牌未被篡改
|
||||
# 2. 过期时间验证 - 检查exp字段是否大于当前时间
|
||||
# 3. 算法验证 - 确保使用正确的加密算法
|
||||
verified_payload = jwt.decode(
|
||||
token,
|
||||
JWT_SALT,
|
||||
algorithms=[JWT_ALGORITHM]
|
||||
)
|
||||
|
||||
# 验证成功,将载荷数据存入返回结果
|
||||
# 注意:返回的数据中不包含敏感信息(如密码等)
|
||||
result['data'] = verified_payload
|
||||
|
||||
# 可选:记录验证成功的日志(生产环境建议使用DEBUG级别)
|
||||
logger.debug(f"JWT令牌验证成功,用户: {verified_payload.get('username', 'unknown')}")
|
||||
|
||||
except exceptions.ExpiredSignatureError as e:
|
||||
# 令牌已过期异常处理
|
||||
# 说明:令牌超过设置的exp时间,需要重新登录获取新令牌
|
||||
result['code'] = 1
|
||||
result['msg'] = "token已失效,请重新登录"
|
||||
logger.info(f"JWT令牌已过期: {str(e)}")
|
||||
|
||||
except exceptions.DecodeError as e:
|
||||
# 令牌解码失败异常处理
|
||||
# 说明:令牌格式错误或签名验证失败,可能被篡改
|
||||
result['code'] = 1
|
||||
result['msg'] = "token认证失败,无效的令牌格式"
|
||||
logger.warning(f"JWT令牌解码失败: {str(e)}")
|
||||
|
||||
except exceptions.InvalidTokenError as e:
|
||||
# 无效令牌异常处理
|
||||
# 说明:捕获其他所有JWT相关异常,如算法不匹配等
|
||||
result['code'] = 1
|
||||
result['msg'] = "非法的token,请检查令牌有效性"
|
||||
logger.warning(f"无效的JWT令牌: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
# 其他未预期的异常处理
|
||||
result['code'] = 1
|
||||
result['msg'] = "token验证失败,系统异常"
|
||||
logger.error(f"JWT验证系统异常: {str(e)}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def refresh_token(token, extend_minutes=None):
|
||||
"""
|
||||
刷新JWT令牌
|
||||
=============================================================
|
||||
|
||||
验证现有令牌有效性,如果有效则生成新的令牌,延长有效期。
|
||||
|
||||
参数:
|
||||
token (str): 需要刷新的JWT令牌字符串
|
||||
extend_minutes (int): 延长的分钟数,默认使用DEFAULT_TIMEOUT_MINUTES
|
||||
|
||||
返回:
|
||||
dict: 刷新结果,包含以下字段:
|
||||
code (int): 状态码,0-成功,1-失败
|
||||
data (dict/None): 成功时包含新令牌,失败时为None
|
||||
msg (str): 提示信息
|
||||
|
||||
使用示例:
|
||||
>>> result = refresh_token(old_token, extend_minutes=30)
|
||||
>>> if result['code'] == 0:
|
||||
... new_token = result['data']['access_token']
|
||||
... else:
|
||||
... # 令牌无效,需要重新登录
|
||||
... error_msg = result['msg']
|
||||
|
||||
注意事项:
|
||||
1. 令牌只有在有效期内才能刷新
|
||||
2. 刷新后会生成全新的令牌,旧令牌立即失效
|
||||
3. 建议在令牌剩余有效期少于一半时进行刷新
|
||||
"""
|
||||
if extend_minutes is None:
|
||||
extend_minutes = DEFAULT_TIMEOUT_MINUTES
|
||||
|
||||
# 先验证原令牌
|
||||
result = parse_payload(token)
|
||||
|
||||
# 如果验证失败,返回错误信息
|
||||
if result['code'] != 0:
|
||||
return {
|
||||
"code": 1,
|
||||
"data": None,
|
||||
"msg": f"令牌无效,无法刷新: {result['msg']}"
|
||||
}
|
||||
|
||||
try:
|
||||
# 获取原载荷数据(不包含exp、iat等JWT标准字段)
|
||||
payload = result['data']
|
||||
|
||||
# 移除JWT标准字段,避免重复添加
|
||||
# 注意:保留原始业务数据,移除系统字段
|
||||
clean_payload = {k: v for k, v in payload.items()
|
||||
if k not in ['exp', 'iat', 'nbf']}
|
||||
|
||||
# 生成新令牌
|
||||
new_token = create_token(clean_payload, timeout=extend_minutes)
|
||||
|
||||
logger.info(f"JWT令牌刷新成功,延长{extend_minutes}分钟")
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"access_token": new_token,
|
||||
"expires_in": extend_minutes * 60 # 转换为秒
|
||||
},
|
||||
"msg": "令牌刷新成功"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"刷新JWT令牌失败: {str(e)}")
|
||||
return {
|
||||
"code": 1,
|
||||
"data": None,
|
||||
"msg": "令牌刷新失败,请重新登录"
|
||||
}
|
||||
|
||||
|
||||
def get_token_expiration(token):
|
||||
"""
|
||||
获取JWT令牌的过期时间
|
||||
|
||||
验证签名后读取过期时间,确保token未被篡改。
|
||||
不过验证过期状态(verify_exp=False),以便调用方
|
||||
读取已过期token的过期时间用于剩余时间计算等场景。
|
||||
|
||||
Args:
|
||||
token (str): JWT令牌字符串
|
||||
|
||||
Returns:
|
||||
datetime/None: 令牌过期时间(UTC),解析失败返回None
|
||||
"""
|
||||
try:
|
||||
# 验证签名确保token未被篡改,但不验证过期状态
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
JWT_SALT,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
options={"verify_exp": False}
|
||||
)
|
||||
|
||||
exp_timestamp = payload.get('exp')
|
||||
if exp_timestamp:
|
||||
return datetime.datetime.fromtimestamp(exp_timestamp, tz=datetime.timezone.utc)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"获取JWT过期时间失败: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def get_access_token(request):
|
||||
"""
|
||||
从请求头中提取 access_token
|
||||
|
||||
Args:
|
||||
request: Starlette/FastAPI 请求对象
|
||||
|
||||
Returns:
|
||||
tuple: (success, token, message)
|
||||
"""
|
||||
try:
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header:
|
||||
return False, None, '缺少 Authorization 头'
|
||||
|
||||
access_token = auth_header.replace('Bearer ', "").strip()
|
||||
if not access_token:
|
||||
return False, None, 'token 为空'
|
||||
|
||||
return True, access_token, 'success'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取 access_token 异常: {str(e)}")
|
||||
return False, None, f'token 提取异常: {str(e)}'
|
||||
|
||||
|
||||
# token解析工具类
|
||||
def parse_token(request):
|
||||
"""
|
||||
解析请求头中的token,返回解析结果
|
||||
|
||||
Args:
|
||||
request: Starlette/FastAPI 请求对象
|
||||
|
||||
Returns:
|
||||
dict: 包含解析结果和用户信息的字典
|
||||
"""
|
||||
result = {
|
||||
'code': 1,
|
||||
'msg': 'token无效',
|
||||
'data': {
|
||||
'userId': 0,
|
||||
'userName': None
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
success, access_token, msg = get_access_token(request)
|
||||
if not success:
|
||||
result['msg'] = msg
|
||||
return result
|
||||
|
||||
parse_result = parse_payload(access_token)
|
||||
|
||||
if not isinstance(parse_result, dict):
|
||||
result['msg'] = '解析结果格式错误'
|
||||
return result
|
||||
|
||||
code = parse_result.get('code', 1)
|
||||
if code != 0:
|
||||
result['msg'] = parse_result.get('msg', 'token验证失败')
|
||||
return result
|
||||
|
||||
data = parse_result.get('data', {})
|
||||
if not isinstance(data, dict):
|
||||
result['msg'] = '数据格式错误'
|
||||
return result
|
||||
|
||||
result['code'] = 0
|
||||
result['msg'] = 'success'
|
||||
result['data']['userId'] = data.get('userId', 0)
|
||||
result['data']['username'] = data.get('username', '')
|
||||
result['data']['realname'] = data.get('realname', '')
|
||||
|
||||
except Exception as e:
|
||||
result['msg'] = f'token解析异常: {str(e)}'
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================
|
||||
# Token黑名单功能
|
||||
# =============================================================
|
||||
|
||||
# 黑名单Key前缀,用于Redis中存储被注销的Token
|
||||
_TOKEN_BLACKLIST_PREFIX = 'token:blacklist:'
|
||||
|
||||
|
||||
def _token_fingerprint(token: str) -> str:
|
||||
"""
|
||||
生成Token指纹(SHA256摘要)
|
||||
|
||||
避免将完整Token存入Redis,仅存储摘要值,减少泄露风险。
|
||||
|
||||
Args:
|
||||
token: JWT令牌字符串
|
||||
|
||||
Returns:
|
||||
str: SHA256十六进制摘要
|
||||
"""
|
||||
return hashlib.sha256(token.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
async def add_token_to_blacklist(token: str) -> bool:
|
||||
"""
|
||||
将Token加入黑名单
|
||||
|
||||
退出登录时调用,使Token立即失效。黑名单条目的TTL与Token剩余有效期一致,
|
||||
Token自然过期后黑名单条目自动清除,避免Redis内存无限增长。
|
||||
|
||||
Args:
|
||||
token: JWT令牌字符串
|
||||
|
||||
Returns:
|
||||
bool: 是否成功加入黑名单
|
||||
"""
|
||||
try:
|
||||
from core.redis_client import get_redis
|
||||
redis = get_redis()
|
||||
|
||||
# 获取Token剩余有效期
|
||||
expiration = get_token_expiration(token)
|
||||
if expiration is None:
|
||||
# 无法解析过期时间,使用默认过期时间作为TTL
|
||||
ttl_seconds = DEFAULT_TIMEOUT_MINUTES * 60
|
||||
else:
|
||||
ttl_seconds = int((expiration - datetime.datetime.now(tz=datetime.timezone.utc)).total_seconds())
|
||||
if ttl_seconds <= 0:
|
||||
# Token已自然过期,无需加入黑名单
|
||||
return True
|
||||
|
||||
fingerprint = _token_fingerprint(token)
|
||||
key = f'{_TOKEN_BLACKLIST_PREFIX}{fingerprint}'
|
||||
await redis.set(key, '1', ex=ttl_seconds)
|
||||
|
||||
logger.info(f"Token已加入黑名单,TTL={ttl_seconds}秒")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token加入黑名单失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
async def is_token_blacklisted(token: str) -> bool:
|
||||
"""
|
||||
检查Token是否在黑名单中
|
||||
|
||||
在登录验证中间件中调用,拒绝已被注销的Token。
|
||||
|
||||
Args:
|
||||
token: JWT令牌字符串
|
||||
|
||||
Returns:
|
||||
bool: True-已被注销(在黑名单中),False-有效
|
||||
"""
|
||||
try:
|
||||
from core.redis_client import get_redis
|
||||
redis = get_redis()
|
||||
|
||||
fingerprint = _token_fingerprint(token)
|
||||
key = f'{_TOKEN_BLACKLIST_PREFIX}{fingerprint}'
|
||||
return await redis.exists(key) > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token黑名单检查异常: {str(e)}")
|
||||
# Redis不可用时放行,避免阻断所有请求
|
||||
return False
|
||||
@@ -0,0 +1,53 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# | 开源协议: Apache-2.0
|
||||
# +======================================================================
|
||||
|
||||
"""结构化日志配置:控制台 + 文件双输出,日志文件位于 logs/ 目录。"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 日志目录(项目根目录下 logs/)
|
||||
LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO, log_file: str = "app.log") -> None:
|
||||
"""
|
||||
配置全局日志:root logger 输出到控制台与文件。
|
||||
|
||||
Args:
|
||||
level: 日志级别
|
||||
log_file: 日志文件名(位于 logs/ 目录下)
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
# 避免重复添加 handler
|
||||
if any(getattr(h, '_djangoadmin', False) for h in root.handlers):
|
||||
return
|
||||
|
||||
root.setLevel(level)
|
||||
formatter = logging.Formatter(_FORMAT)
|
||||
|
||||
# 控制台
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setFormatter(formatter)
|
||||
console._djangoadmin = True # type: ignore[attr-defined]
|
||||
root.addHandler(console)
|
||||
|
||||
# 文件
|
||||
file_handler = logging.FileHandler(LOG_DIR / log_file, encoding="utf-8")
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler._djangoadmin = True # type: ignore[attr-defined]
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# uvicorn/fastapi 默认日志走同样 handler
|
||||
for name in ("uvicorn", "fastapi"):
|
||||
lg = logging.getLogger(name)
|
||||
lg.handlers.clear()
|
||||
lg.propagate = True
|
||||
@@ -0,0 +1,285 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# | 开源协议: Apache-2.0
|
||||
# +======================================================================
|
||||
# 由 apps/middleware/ 合并迁移而来。
|
||||
|
||||
"""核心中间件与依赖:认证、鉴权、演示拦截、数据库会话、操作日志、CORS。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.requests import Request
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
import config
|
||||
from config.app import CORS_ALLOWED_ORIGINS
|
||||
from core import response as R
|
||||
from core.database import SessionLocal, _db_ctx
|
||||
from core.exceptions import AuthorizationException
|
||||
from core.jwt import get_access_token, is_token_blacklisted, parse_payload
|
||||
from core.security import get_user_id, get_username
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 中间件注册
|
||||
# ============================================================
|
||||
|
||||
def register_middleware(app: FastAPI):
|
||||
"""
|
||||
请求响应拦截 hook(当前为空实现,可自定义业务逻辑)。
|
||||
"""
|
||||
|
||||
@app.middleware("http")
|
||||
async def logger_request(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
def register_cros(app: FastAPI):
|
||||
"""注册 CORS 与 ProxyHeaders 中间件。"""
|
||||
origins_str = CORS_ALLOWED_ORIGINS.strip()
|
||||
if origins_str:
|
||||
origins = [o.strip() for o in origins_str.split(',') if o.strip()]
|
||||
allow_credentials = True
|
||||
allow_headers = [
|
||||
"Authorization", "Content-Type", "Accept",
|
||||
"X-Requested-With", "X-Process-Time",
|
||||
]
|
||||
else:
|
||||
origins = ["*"]
|
||||
allow_credentials = False
|
||||
allow_headers = ["*"]
|
||||
app.add_middleware(ProxyHeadersMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=allow_credentials,
|
||||
allow_methods=['OPTIONS', 'GET', 'POST', 'DELETE', 'PUT'],
|
||||
allow_headers=allow_headers,
|
||||
max_age=3600
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 登录认证(router 级依赖,挂载在 v1 上)
|
||||
# ============================================================
|
||||
|
||||
async def login_required(request: Request):
|
||||
"""登录验证依赖:提取并校验 Token,将用户信息写入 request.state。"""
|
||||
success, access_token, msg = get_access_token(request)
|
||||
if not success:
|
||||
raise AuthorizationException(code=401, msg=msg)
|
||||
|
||||
if await is_token_blacklisted(access_token):
|
||||
raise AuthorizationException(code=401, msg="登录已注销")
|
||||
|
||||
result = parse_payload(access_token)
|
||||
if result['code'] != 0:
|
||||
raise AuthorizationException(code=401, msg=result['msg'])
|
||||
|
||||
data = result['data']
|
||||
request.state.user_id = int(data.get('userId', 0))
|
||||
request.state.username = data.get('username', '')
|
||||
request.state.realname = data.get('realname', '')
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 节点权限鉴权(装饰器)
|
||||
# ============================================================
|
||||
|
||||
def _extract_request(*args, **kwargs):
|
||||
"""从函数参数中提取 Request 对象"""
|
||||
req = kwargs.get('request')
|
||||
if req is not None:
|
||||
return req
|
||||
for arg in args:
|
||||
if isinstance(arg, Request):
|
||||
return arg
|
||||
return None
|
||||
|
||||
|
||||
def permission_required(permission: str):
|
||||
"""节点权限鉴权装饰器,userId==1(管理员)直接放行。"""
|
||||
def decorator(func):
|
||||
def _check(request):
|
||||
"""同步权限校验(直查DB,无Redis依赖),返回 R.failed 或 None(通过)"""
|
||||
userId = get_user_id(request)
|
||||
if userId == 1:
|
||||
return None
|
||||
# 懒加载业务 service,避免 core → apps 顶层依赖(迁移完成后指向 modules)
|
||||
from apps.services import menu
|
||||
permission_list = menu.get_permissions_list_sync(userId)
|
||||
if permission not in permission_list:
|
||||
return R.failed("权限不足")
|
||||
return None
|
||||
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
request = _extract_request(*args, **kwargs)
|
||||
denied = await asyncio.to_thread(_check, request)
|
||||
if denied:
|
||||
return denied
|
||||
return await func(*args, **kwargs)
|
||||
return async_wrapper
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
request = _extract_request(*args, **kwargs)
|
||||
denied = _check(request)
|
||||
if denied:
|
||||
return denied
|
||||
return func(*args, **kwargs)
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 演示模式拦截(装饰器)
|
||||
# ============================================================
|
||||
|
||||
def check_demo(func):
|
||||
"""演示模式拦截装饰器,放在 @permission_required 之后、路由函数之前。"""
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
if config.FASTAPI_DEMO:
|
||||
return R.failed("演示环境,暂无操作权限")
|
||||
return await func(*args, **kwargs)
|
||||
return async_wrapper
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
if config.FASTAPI_DEMO:
|
||||
return R.failed("演示环境,暂无操作权限")
|
||||
return func(*args, **kwargs)
|
||||
return sync_wrapper
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 数据库会话中间件
|
||||
# ============================================================
|
||||
|
||||
def _is_business_success(response) -> bool:
|
||||
"""检查响应体中业务状态码是否为成功(code == 0)。"""
|
||||
try:
|
||||
body = getattr(response, 'body', None)
|
||||
if body:
|
||||
data = json.loads(body)
|
||||
return data.get('code') == 0
|
||||
except Exception:
|
||||
pass
|
||||
return 200 <= response.status_code < 300
|
||||
|
||||
|
||||
async def db_session_middleware(request: Request, call_next):
|
||||
"""请求级 DB 会话:写操作按业务成功与否 commit/rollback,finally 关闭。"""
|
||||
db = SessionLocal()
|
||||
_db_ctx.set(db)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
|
||||
if _is_business_success(response):
|
||||
db.commit()
|
||||
else:
|
||||
db.rollback()
|
||||
return response
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 操作日志中间件
|
||||
# ============================================================
|
||||
|
||||
# 不记录操作日志的路径前缀
|
||||
_EXCLUDE_PATHS = (
|
||||
'/login',
|
||||
'/captcha',
|
||||
'/upload',
|
||||
'/loginlog',
|
||||
'/operationlog',
|
||||
'/index/menu',
|
||||
'/index/user',
|
||||
'/index/logout',
|
||||
)
|
||||
|
||||
|
||||
def _should_log(request: Request) -> bool:
|
||||
"""判断当前请求是否需要记录操作日志"""
|
||||
if request.method not in ('POST', 'PUT', 'DELETE', 'PATCH'):
|
||||
return False
|
||||
path = request.url.path
|
||||
for prefix in _EXCLUDE_PATHS:
|
||||
if prefix in path:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _get_route_summary(request: Request) -> str:
|
||||
"""从路由获取操作标题(summary)"""
|
||||
try:
|
||||
route = request.scope.get('route')
|
||||
if route and hasattr(route, 'summary') and route.summary:
|
||||
return route.summary
|
||||
except Exception:
|
||||
pass
|
||||
return request.method + ' ' + request.url.path
|
||||
|
||||
|
||||
async def operation_log_middleware(request: Request, call_next):
|
||||
"""操作日志中间件:自动拦截写请求并记录操作日志。"""
|
||||
if not _should_log(request):
|
||||
return await call_next(request)
|
||||
|
||||
start_time = time.time()
|
||||
status = 0
|
||||
error_msg = ''
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
try:
|
||||
body = getattr(response, 'body', None)
|
||||
if body:
|
||||
data = json.loads(body)
|
||||
if data.get('code', 0) != 0:
|
||||
status = 1
|
||||
error_msg = data.get('msg', '')
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
except Exception as e:
|
||||
status = 1
|
||||
error_msg = str(e)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
consume_time = int((time.time() - start_time) * 1000)
|
||||
user_id = get_user_id(request)
|
||||
if user_id and user_id > 0:
|
||||
# 懒加载业务 service,避免 core → apps 顶层依赖(迁移完成后指向 modules)
|
||||
from apps.services import operation_log
|
||||
operation_log.record_operation_log(
|
||||
request,
|
||||
title=_get_route_summary(request),
|
||||
method=get_username(request),
|
||||
consume_time=consume_time,
|
||||
status=status,
|
||||
error=error_msg,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"操作日志中间件异常: {str(e)}")
|
||||
@@ -0,0 +1,34 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# | 开源协议: Apache-2.0
|
||||
# +======================================================================
|
||||
# 由 utils/password.py 迁移而来。
|
||||
|
||||
"""密码加密工具:基于 bcrypt 的双重加盐方案。"""
|
||||
|
||||
import secrets
|
||||
|
||||
import bcrypt
|
||||
|
||||
# bcrypt 工作因子,值越大计算越慢越安全,默认12(约250ms)
|
||||
BCRYPT_ROUNDS = 12
|
||||
|
||||
|
||||
def generate_salt(length: int = 10) -> str:
|
||||
"""生成随机盐值"""
|
||||
return secrets.token_urlsafe(length)[:length]
|
||||
|
||||
|
||||
def encrypt_password(password: str, salt: str) -> str:
|
||||
"""使用 bcrypt 加密密码(外部 salt 双重加盐)"""
|
||||
combined = (password + salt).encode('utf-8')
|
||||
hashed = bcrypt.hashpw(combined, bcrypt.gensalt(rounds=BCRYPT_ROUNDS))
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(password: str, salt: str, hashed: str) -> bool:
|
||||
"""验证密码是否正确"""
|
||||
combined = (password + salt).encode('utf-8')
|
||||
return bcrypt.checkpw(combined, hashed.encode('utf-8'))
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Redis客户端核心模块(由 utils/redis_ctx.py 迁移而来)
|
||||
|
||||
封装 Redis 连接管理和请求级上下文注入:
|
||||
- lifespan 中创建连接池,关闭时断开
|
||||
- 每个请求通过中间件将 Redis 客户端注入 contextvars
|
||||
- 业务代码通过 get_redis() 获取当前请求的 Redis 客户端
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from redis.asyncio import Redis
|
||||
from redis import asyncio as aioredis
|
||||
|
||||
from config import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_INDEX, REDIS_AUTH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 请求级 Redis 客户端上下文变量
|
||||
_redis_ctx: ContextVar[Redis] = ContextVar('_redis_ctx')
|
||||
|
||||
|
||||
def get_redis() -> Redis:
|
||||
"""获取当前请求的 Redis 客户端"""
|
||||
return _redis_ctx.get()
|
||||
|
||||
|
||||
def _build_redis_url() -> str:
|
||||
"""构建 Redis 连接 URL"""
|
||||
if REDIS_AUTH and REDIS_PASSWORD:
|
||||
return f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_INDEX}"
|
||||
return f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_INDEX}"
|
||||
|
||||
|
||||
async def create_redis_pool() -> Redis:
|
||||
"""创建 Redis 异步连接池"""
|
||||
redis = await aioredis.from_url(
|
||||
_build_redis_url(),
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
protocol=2
|
||||
)
|
||||
return redis
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def redis_lifespan(app: FastAPI):
|
||||
"""Redis 生命周期管理,替代已废弃的 on_event("startup"/"shutdown")"""
|
||||
# startup: 创建连接池
|
||||
redis = await create_redis_pool()
|
||||
await redis.ping()
|
||||
app.state.redis = redis
|
||||
print(f"Redis 连接成功 --> {REDIS_HOST}:{REDIS_PORT}/{REDIS_INDEX}")
|
||||
logger.info(f"Redis 连接成功: {REDIS_HOST}:{REDIS_PORT}/{REDIS_INDEX}")
|
||||
|
||||
# startup: 初始化定时任务调度器
|
||||
from apps.services.scheduler import init_scheduler, shutdown_scheduler
|
||||
job_count = init_scheduler()
|
||||
logger.info(f"定时任务调度器已启动,加载 {job_count} 个任务")
|
||||
|
||||
yield
|
||||
# shutdown: 关闭调度器
|
||||
shutdown_scheduler()
|
||||
|
||||
# shutdown: 关闭连接
|
||||
await redis.aclose()
|
||||
app.state.redis = None
|
||||
logger.info("Redis 连接已关闭")
|
||||
|
||||
|
||||
async def redis_middleware(request, call_next):
|
||||
"""Redis 上下文注入中间件,每个请求开始时将 Redis 客户端注入 contextvars"""
|
||||
redis = getattr(request.app.state, 'redis', None)
|
||||
if redis is not None:
|
||||
_redis_ctx.set(redis)
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,84 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# | 开源协议: Apache-2.0
|
||||
# +======================================================================
|
||||
# 由 utils/response.py 迁移而来。
|
||||
|
||||
"""统一响应结果模块:所有 API 返回 {code, data, msg, ok} 标准格式。"""
|
||||
|
||||
from typing import Any, Optional, Dict
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
# 默认提示消息(避免从 apps.constants 循环导入)
|
||||
MESSAGE_OK = "操作成功"
|
||||
MESSAGE_FAIL = "操作失败"
|
||||
|
||||
|
||||
def ok(
|
||||
data: Optional[Any] = None,
|
||||
msg: str = MESSAGE_OK,
|
||||
code: int = 0,
|
||||
**kwargs: Any
|
||||
) -> JSONResponse:
|
||||
"""生成成功响应,支持通过 kwargs 添加额外字段(如 count)。"""
|
||||
response_body: Dict[str, Any] = {
|
||||
"code": code,
|
||||
"data": data,
|
||||
"msg": msg,
|
||||
"ok": True
|
||||
}
|
||||
if kwargs:
|
||||
response_body.update(kwargs)
|
||||
return JSONResponse(response_body)
|
||||
|
||||
|
||||
def failed(
|
||||
msg: str = MESSAGE_FAIL,
|
||||
code: int = 1,
|
||||
data: Optional[Any] = None,
|
||||
**kwargs: Any
|
||||
) -> JSONResponse:
|
||||
"""生成失败响应。"""
|
||||
response_body: Dict[str, Any] = {
|
||||
"code": code,
|
||||
"data": data,
|
||||
"msg": msg,
|
||||
"ok": False
|
||||
}
|
||||
if kwargs:
|
||||
response_body.update(kwargs)
|
||||
return JSONResponse(response_body)
|
||||
|
||||
|
||||
def response(
|
||||
data: Optional[Any] = None,
|
||||
msg: str = MESSAGE_OK,
|
||||
code: int = 0,
|
||||
success: bool = True,
|
||||
**kwargs: Any
|
||||
) -> JSONResponse:
|
||||
"""通用响应生成器(高级用法),允许完全自定义字段。"""
|
||||
response_body: Dict[str, Any] = {
|
||||
"code": code,
|
||||
"data": data,
|
||||
"msg": msg,
|
||||
"ok": success
|
||||
}
|
||||
if kwargs:
|
||||
response_body.update(kwargs)
|
||||
return JSONResponse(response_body)
|
||||
|
||||
|
||||
# 常用错误码常量
|
||||
class Codes:
|
||||
"""常用状态码定义"""
|
||||
SUCCESS = 0
|
||||
FAILED = 1
|
||||
UNAUTHORIZED = 401 # 未认证
|
||||
FORBIDDEN = 403 # 无权限
|
||||
NOT_FOUND = 404 # 资源不存在
|
||||
VALIDATE_ERROR = 422 # 验证错误
|
||||
SERVER_ERROR = 500 # 服务器错误
|
||||
@@ -0,0 +1,24 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# | 开源协议: Apache-2.0
|
||||
# +======================================================================
|
||||
# 由 utils/security.py 迁移而来。
|
||||
|
||||
"""安全辅助:从请求上下文获取当前用户信息,并组合 jwt + password 提供安全便捷函数。"""
|
||||
|
||||
|
||||
def get_user_id(request) -> int:
|
||||
"""获取当前请求的用户ID(由 login_required 中间件注入 request.state)"""
|
||||
return getattr(request.state, 'user_id', 0)
|
||||
|
||||
|
||||
def get_username(request) -> str:
|
||||
"""获取当前请求的用户名"""
|
||||
return getattr(request.state, 'username', '')
|
||||
|
||||
|
||||
def get_realname(request) -> str:
|
||||
"""获取当前请求的姓名"""
|
||||
return getattr(request.state, 'realname', '')
|
||||
+2
-42
@@ -1,45 +1,5 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
from extensions.sqlalchemy import get_db_session, commit_independently
|
||||
# 兼容垫片:由 core/ 迁移,保留旧导入路径
|
||||
from core.database import get_db_session, commit_independently
|
||||
|
||||
|
||||
def register_extends(app):
|
||||
|
||||
+2
-55
@@ -1,55 +1,2 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
# 登录认证异常处理类
|
||||
class AuthorizationException(Exception):
|
||||
def __init__(self, code: int, msg: str):
|
||||
super().__init__(msg)
|
||||
self.code = code
|
||||
self.msg = msg
|
||||
|
||||
|
||||
# 业务异常处理类
|
||||
class BusinessException(Exception):
|
||||
def __init__(self, code: int = 1, msg: str = ""):
|
||||
super().__init__(msg)
|
||||
self.code = code
|
||||
self.msg = msg
|
||||
# 兼容垫片:由 core/exceptions.py 迁移,保留旧导入路径
|
||||
from core.exceptions import AuthorizationException, BusinessException
|
||||
|
||||
+8
-110
@@ -1,111 +1,9 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
from contextvars import ContextVar
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config import (
|
||||
SQLALCHEMY_MYSQL_URL, FASTAPI_DEBUG,
|
||||
DB_POOL_SIZE, DB_MAX_OVERFLOW, DB_POOL_RECYCLE,
|
||||
DB_POOL_TIMEOUT, DB_CONNECT_TIMEOUT,
|
||||
# 兼容垫片:由 core/database.py 迁移,保留旧导入路径
|
||||
from core.database import (
|
||||
engine,
|
||||
SessionLocal,
|
||||
_db_ctx,
|
||||
get_db_session,
|
||||
commit_independently,
|
||||
Base,
|
||||
)
|
||||
|
||||
# 创建一个SQLAlchemy引擎
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_MYSQL_URL,
|
||||
pool_size=DB_POOL_SIZE,
|
||||
pool_recycle=DB_POOL_RECYCLE,
|
||||
pool_timeout=DB_POOL_TIMEOUT,
|
||||
max_overflow=DB_MAX_OVERFLOW,
|
||||
connect_args={"connect_timeout": DB_CONNECT_TIMEOUT},
|
||||
pool_pre_ping=True,
|
||||
pool_use_lifo=True,
|
||||
echo_pool=FASTAPI_DEBUG,
|
||||
)
|
||||
# SessionLocal该类的每个实例将是一个数据库会话。该类本身还不是数据库会话。
|
||||
# 一旦我们创建了SessionLocal该类的实例,该实例将成为实际的数据库会话。
|
||||
# 我们SessionLocal将其命名为有别于Session我们从SQLAlchemy导入的名称。
|
||||
# 要创建SessionLocal类,请使用函数sessionmaker:
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# 请求级数据库 session 上下文变量
|
||||
_db_ctx: ContextVar = ContextVar('_db_ctx')
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""获取当前请求的数据库 session"""
|
||||
return _db_ctx.get()
|
||||
|
||||
|
||||
def commit_independently(obj):
|
||||
"""
|
||||
使用独立session提交对象,不受请求事务影响
|
||||
|
||||
日志等审计数据无论业务成功或失败都应持久化,
|
||||
因此需要绕过 AOP 事务中间件的 commit/rollback 机制。
|
||||
|
||||
实现方式:基于现有引擎创建独立 session,将对象添加并提交,
|
||||
完成后关闭 session,不影响请求主 session。
|
||||
|
||||
Args:
|
||||
obj: SQLAlchemy 模型实例(未添加到任何 session)
|
||||
|
||||
Raises:
|
||||
Exception: 数据库操作异常
|
||||
"""
|
||||
independent_session = SessionLocal()
|
||||
try:
|
||||
independent_session.add(obj)
|
||||
independent_session.commit()
|
||||
except Exception:
|
||||
independent_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
independent_session.close()
|
||||
|
||||
|
||||
# 现在,我们将使用declarative_base()返回类的函数。
|
||||
# 稍后,我们将从该类继承以创建每个数据库模型或类(ORM模型)
|
||||
# 返回一个类,后续作为数据库模型的基类(ORM模型)
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""core/jwt.py 纯函数单元测试(不依赖 Redis/DB)。"""
|
||||
|
||||
from core import jwt as core_jwt
|
||||
|
||||
|
||||
def test_create_and_parse_token():
|
||||
token = core_jwt.create_token({"userId": 1, "username": "admin"})
|
||||
assert isinstance(token, str)
|
||||
result = core_jwt.parse_payload(token)
|
||||
assert result["code"] == 0
|
||||
assert result["data"]["username"] == "admin"
|
||||
|
||||
|
||||
def test_parse_invalid_token():
|
||||
result = core_jwt.parse_payload("not.a.valid.token")
|
||||
assert result["code"] == 1
|
||||
|
||||
|
||||
def test_refresh_token():
|
||||
token = core_jwt.create_token({"userId": 1})
|
||||
result = core_jwt.refresh_token(token)
|
||||
assert result["code"] == 0
|
||||
assert result["data"]["access_token"]
|
||||
|
||||
|
||||
def test_get_token_expiration():
|
||||
token = core_jwt.create_token({"userId": 1}, timeout=30)
|
||||
exp = core_jwt.get_token_expiration(token)
|
||||
assert exp is not None
|
||||
@@ -0,0 +1,19 @@
|
||||
"""core/password.py 单元测试。"""
|
||||
|
||||
from core import password
|
||||
|
||||
|
||||
def test_encrypt_and_verify():
|
||||
salt = password.generate_salt()
|
||||
hashed = password.encrypt_password("123456", salt)
|
||||
assert len(hashed) == 60
|
||||
assert password.verify_password("123456", salt, hashed)
|
||||
assert not password.verify_password("wrong", salt, hashed)
|
||||
|
||||
|
||||
def test_same_password_different_hash():
|
||||
"""bcrypt 内置盐随机,相同密码两次加密结果不同。"""
|
||||
salt = password.generate_salt()
|
||||
h1 = password.encrypt_password("pw", salt)
|
||||
h2 = password.encrypt_password("pw", salt)
|
||||
assert h1 != h2
|
||||
@@ -0,0 +1,19 @@
|
||||
"""utils/request.py 工具函数单元测试。"""
|
||||
|
||||
from utils.request import parse_id_list, like_escape, like_contains
|
||||
|
||||
|
||||
def test_parse_id_list():
|
||||
assert parse_id_list("1,2,3") == [1, 2, 3]
|
||||
assert parse_id_list("1,a,3") == [1, 3]
|
||||
assert parse_id_list("") == []
|
||||
assert parse_id_list(None) == []
|
||||
|
||||
|
||||
def test_like_escape():
|
||||
assert like_escape("50%") == "50\\%"
|
||||
assert like_escape("a_b") == "a\\_b"
|
||||
|
||||
|
||||
def test_like_contains():
|
||||
assert like_contains("张") == "%张%"
|
||||
@@ -0,0 +1,28 @@
|
||||
"""core/response.py 响应格式单元测试。"""
|
||||
|
||||
import json
|
||||
|
||||
from core import response as R
|
||||
|
||||
|
||||
def test_ok_shape():
|
||||
resp = R.ok(data={"a": 1})
|
||||
body = json.loads(resp.body)
|
||||
assert body["code"] == 0
|
||||
assert body["ok"] is True
|
||||
assert body["data"] == {"a": 1}
|
||||
assert body["msg"] == "操作成功"
|
||||
|
||||
|
||||
def test_ok_extra_kwargs():
|
||||
resp = R.ok(data=[], count=10)
|
||||
body = json.loads(resp.body)
|
||||
assert body["count"] == 10
|
||||
|
||||
|
||||
def test_failed_shape():
|
||||
resp = R.failed(msg="错误信息")
|
||||
body = json.loads(resp.body)
|
||||
assert body["code"] == 1
|
||||
assert body["ok"] is False
|
||||
assert body["msg"] == "错误信息"
|
||||
Reference in New Issue
Block a user