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:
Claude
2026-08-07 10:02:01 +08:00
co-authored by Claude
parent fe1d191e55
commit db4ee85db5
22 changed files with 1476 additions and 364 deletions
+2 -157
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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)},
)
+67
View File
@@ -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()
+29
View File
@@ -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()
+27
View File
@@ -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
View File
@@ -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
+53
View File
@@ -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
+285
View File
@@ -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)}")
+34
View File
@@ -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'))
+79
View File
@@ -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)
+84
View File
@@ -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 # 服务器错误
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
View File
View File
+29
View File
@@ -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
+19
View File
@@ -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
+19
View File
@@ -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("张") == "%张%"
+28
View File
@@ -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"] == "错误信息"