From db4ee85db51faf22108411f9a8f1dc228a137264 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 10:02:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(phase1):=20core/=20=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E8=AE=BE=E6=96=BD=E8=BF=81=E7=A7=BB=EF=BC=8C=E6=97=A7=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E6=94=B9=E5=9E=AB=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/__init__.py | 159 +-------- core/__init__.py | 13 + core/app.py | 99 ++++++ core/database.py | 67 ++++ core/deps.py | 29 ++ core/exceptions.py | 27 ++ core/jwt.py | 573 +++++++++++++++++++++++++++++++ core/logger.py | 53 +++ core/middleware.py | 285 +++++++++++++++ core/password.py | 34 ++ core/redis_client.py | 79 +++++ core/response.py | 84 +++++ core/security.py | 24 ++ extensions/__init__.py | 44 +-- extensions/exception.py | 57 +-- extensions/sqlalchemy.py | 118 +------ tests/unit/__init__.py | 0 tests/unit/core/__init__.py | 0 tests/unit/core/test_jwt.py | 29 ++ tests/unit/core/test_password.py | 19 + tests/unit/core/test_request.py | 19 + tests/unit/core/test_response.py | 28 ++ 22 files changed, 1476 insertions(+), 364 deletions(-) create mode 100644 core/__init__.py create mode 100644 core/app.py create mode 100644 core/database.py create mode 100644 core/deps.py create mode 100644 core/exceptions.py create mode 100644 core/jwt.py create mode 100644 core/logger.py create mode 100644 core/middleware.py create mode 100644 core/password.py create mode 100644 core/redis_client.py create mode 100644 core/response.py create mode 100644 core/security.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/core/__init__.py create mode 100644 tests/unit/core/test_jwt.py create mode 100644 tests/unit/core/test_password.py create mode 100644 tests/unit/core/test_request.py create mode 100644 tests/unit/core/test_response.py diff --git a/apps/__init__.py b/apps/__init__.py index 71d044b..9552369 100644 --- a/apps/__init__.py +++ b/apps/__init__.py @@ -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 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..84daaff --- /dev/null +++ b/core/__init__.py @@ -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 diff --git a/core/app.py b/core/app.py new file mode 100644 index 0000000..c876202 --- /dev/null +++ b/core/app.py @@ -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)}, + ) diff --git a/core/database.py b/core/database.py new file mode 100644 index 0000000..15adbfd --- /dev/null +++ b/core/database.py @@ -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() diff --git a/core/deps.py b/core/deps.py new file mode 100644 index 0000000..2a09edb --- /dev/null +++ b/core/deps.py @@ -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() diff --git a/core/exceptions.py b/core/exceptions.py new file mode 100644 index 0000000..3ec067e --- /dev/null +++ b/core/exceptions.py @@ -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 diff --git a/core/jwt.py b/core/jwt.py new file mode 100644 index 0000000..9e2fd6f --- /dev/null +++ b/core/jwt.py @@ -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 diff --git a/core/logger.py b/core/logger.py new file mode 100644 index 0000000..b7cdddb --- /dev/null +++ b/core/logger.py @@ -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 diff --git a/core/middleware.py b/core/middleware.py new file mode 100644 index 0000000..7075723 --- /dev/null +++ b/core/middleware.py @@ -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)}") diff --git a/core/password.py b/core/password.py new file mode 100644 index 0000000..e6f6825 --- /dev/null +++ b/core/password.py @@ -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')) diff --git a/core/redis_client.py b/core/redis_client.py new file mode 100644 index 0000000..ad17e37 --- /dev/null +++ b/core/redis_client.py @@ -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) diff --git a/core/response.py b/core/response.py new file mode 100644 index 0000000..3f2762c --- /dev/null +++ b/core/response.py @@ -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 # 服务器错误 diff --git a/core/security.py b/core/security.py new file mode 100644 index 0000000..bb74fbe --- /dev/null +++ b/core/security.py @@ -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', '') diff --git a/extensions/__init__.py b/extensions/__init__.py index 1a9a2ef..46b7454 100644 --- a/extensions/__init__.py +++ b/extensions/__init__.py @@ -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): diff --git a/extensions/exception.py b/extensions/exception.py index 2d42dac..7af19a0 100644 --- a/extensions/exception.py +++ b/extensions/exception.py @@ -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 diff --git a/extensions/sqlalchemy.py b/extensions/sqlalchemy.py index b0f3613..cb1e3af 100644 --- a/extensions/sqlalchemy.py +++ b/extensions/sqlalchemy.py @@ -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() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/core/__init__.py b/tests/unit/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/core/test_jwt.py b/tests/unit/core/test_jwt.py new file mode 100644 index 0000000..352b405 --- /dev/null +++ b/tests/unit/core/test_jwt.py @@ -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 diff --git a/tests/unit/core/test_password.py b/tests/unit/core/test_password.py new file mode 100644 index 0000000..e1d32a2 --- /dev/null +++ b/tests/unit/core/test_password.py @@ -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 diff --git a/tests/unit/core/test_request.py b/tests/unit/core/test_request.py new file mode 100644 index 0000000..db91042 --- /dev/null +++ b/tests/unit/core/test_request.py @@ -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("张") == "%张%" diff --git a/tests/unit/core/test_response.py b/tests/unit/core/test_response.py new file mode 100644 index 0000000..030c396 --- /dev/null +++ b/tests/unit/core/test_response.py @@ -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"] == "错误信息"