checkpoint: 架构治理四件套——统一 async / 测试真 PG / Redis 降级可选 / DPB 扶正
- A1 业务层统一 async:gencode 表内省改 run_sync 异步(不再阻塞事件循环)、 check_db 改 async_engine、dict_util 启动预热异步载入;psycopg 降为 APScheduler SQLAlchemyJobStore 专用同步孤岛(注释标注) - A2 测试真 PG 化:conftest 弃 SQLite + mock Redis,改连真实 PG16(dpb_test)+ Redis(number_gen advisory lock 路径真被测);新增 docker/test-compose.yaml 测试依赖栈(postgres:16:5433 + redis:7:6380);run_ci 前置探测 + 容器兜底, pytest -m pg 10 套正式 tc 全过(真实 PG16 + Redis) - B1 Redis 启动不强依赖:redis_connect 失败降级启动,缓存类回源 DB、存储类 (会话/AI 配置/调度)经 require_redis 守卫返回 503;真机 3 阶段降级测试 PASS=11 FAIL=0(独立 Redis 6390 + 后端 8091,不动共享 dev 服务) - C 扶正 DPB:后端横幅/日志(dpb.log)/README/pyproject/ai_factory agent 名、 前端 package.json/署名注释/链接文案/deploy.sh/docker 注释全部去 fastapiadmin; grep backend/app + frontend/web/src 零残留 - 验证:全量回归 pass=72 fail=0
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Inspector, inspect, select, text
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config.setting import settings
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
from app.core.database import engine
|
||||
from app.core.database import async_engine
|
||||
from app.core.logger import logger
|
||||
from app.utils.common_util import search_to_dict
|
||||
|
||||
@@ -25,6 +24,12 @@ if TYPE_CHECKING:
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
|
||||
async def _run_inspector(func, *args):
|
||||
"""在异步引擎连接上以 run_sync 执行同步 Inspector 操作,避免阻塞事件循环。"""
|
||||
async with async_engine.connect() as conn:
|
||||
return await conn.run_sync(func, *args)
|
||||
|
||||
|
||||
class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
|
||||
"""代码生成业务表模块数据库操作层"""
|
||||
|
||||
@@ -142,19 +147,21 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
|
||||
database_name = settings.DATABASE_NAME
|
||||
database_type = settings.DATABASE_TYPE
|
||||
|
||||
inspector: Inspector = inspect(engine)
|
||||
table_names = inspector.get_table_names()
|
||||
def _introspect(sync_conn) -> list[tuple[str, str]]:
|
||||
insp = inspect(sync_conn)
|
||||
out = []
|
||||
for table_name in insp.get_table_names():
|
||||
try:
|
||||
table_comment = insp.get_table_comment(table_name)
|
||||
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else table_comment
|
||||
out.append((table_name, comment or ""))
|
||||
except Exception as e:
|
||||
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
|
||||
out.append((table_name, ""))
|
||||
return out
|
||||
|
||||
dict_data = []
|
||||
for table_name in table_names:
|
||||
try:
|
||||
table_comment = inspector.get_table_comment(table_name)
|
||||
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else table_comment
|
||||
table_comment = comment or ""
|
||||
except Exception as e:
|
||||
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
|
||||
table_comment = ""
|
||||
|
||||
for table_name, table_comment in await _run_inspector(_introspect):
|
||||
# 统一处理 search 为 None 的情况,避免重复判断
|
||||
if search:
|
||||
# 表名过滤:忽略大小写,支持模糊匹配
|
||||
@@ -296,26 +303,30 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
|
||||
database_name = settings.DATABASE_NAME
|
||||
database_type = settings.DATABASE_TYPE
|
||||
|
||||
inspector: Inspector = inspect(engine)
|
||||
all_table_names = set(inspector.get_table_names())
|
||||
def _introspect(sync_conn, names: list[str]) -> list[tuple[str, str]]:
|
||||
insp = inspect(sync_conn)
|
||||
all_table_names = set(insp.get_table_names())
|
||||
out = []
|
||||
for table_name in names:
|
||||
if table_name not in all_table_names:
|
||||
continue
|
||||
try:
|
||||
table_comment = insp.get_table_comment(table_name)
|
||||
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
|
||||
comment = ""
|
||||
out.append((table_name, comment or ""))
|
||||
return out
|
||||
|
||||
results = []
|
||||
for table_name in table_names:
|
||||
if table_name not in all_table_names:
|
||||
continue
|
||||
try:
|
||||
table_comment = inspector.get_table_comment(table_name)
|
||||
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
|
||||
comment = ""
|
||||
|
||||
for table_name, comment in await _run_inspector(_introspect, table_names):
|
||||
results.append(
|
||||
GenDBTableSchema(
|
||||
database_name=database_name,
|
||||
table_name=table_name,
|
||||
table_type=database_type,
|
||||
table_comment=comment or "",
|
||||
table_comment=comment,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -330,8 +341,7 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
|
||||
返回:
|
||||
- bool: 如果表存在返回True,否则返回False。
|
||||
"""
|
||||
inspector: Inspector = inspect(engine)
|
||||
return inspector.has_table(table_name)
|
||||
return await _run_inspector(lambda sync_conn: inspect(sync_conn).has_table(table_name))
|
||||
|
||||
async def get_db_table_comment(self, table_name: str) -> str:
|
||||
"""获取数据库中指定表的注释(用于主子表场景下从库中加载子表元信息)。
|
||||
@@ -342,16 +352,19 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
|
||||
返回:
|
||||
- str: 表注释;表不存在或失败时为空字符串。
|
||||
"""
|
||||
inspector: Inspector = inspect(engine)
|
||||
if not inspector.has_table(table_name):
|
||||
return ""
|
||||
try:
|
||||
table_comment = inspector.get_table_comment(table_name)
|
||||
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "")
|
||||
return comment or ""
|
||||
except Exception as e:
|
||||
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
|
||||
return ""
|
||||
def _get_comment(sync_conn) -> str:
|
||||
insp = inspect(sync_conn)
|
||||
if not insp.has_table(table_name):
|
||||
return ""
|
||||
try:
|
||||
table_comment = insp.get_table_comment(table_name)
|
||||
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "")
|
||||
return comment or ""
|
||||
except Exception as e:
|
||||
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
|
||||
return ""
|
||||
|
||||
return await _run_inspector(_get_comment)
|
||||
|
||||
async def execute_sql(self, sql: str) -> bool:
|
||||
"""执行SQL语句。
|
||||
@@ -384,8 +397,8 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen
|
||||
super().__init__(model=GenTableColumnModel, auth=auth, db=db)
|
||||
|
||||
@staticmethod
|
||||
def _sync_get_table_columns(database_type: str, table_name: str) -> list[dict]:
|
||||
"""同步函数:获取数据库表的列信息
|
||||
async def _get_table_columns(database_type: str, table_name: str) -> list[dict]:
|
||||
"""在异步引擎连接上内省数据库表的列信息(不阻塞事件循环)。
|
||||
|
||||
参数:
|
||||
- database_type: 数据库类型
|
||||
@@ -394,62 +407,65 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen
|
||||
返回:
|
||||
- list: 列信息列表
|
||||
"""
|
||||
# 使用SQLAlchemy Inspector获取表列信息
|
||||
inspector: Inspector = inspect(engine)
|
||||
def _introspect(sync_conn) -> list[dict]:
|
||||
# 使用SQLAlchemy Inspector获取表列信息
|
||||
inspector = inspect(sync_conn)
|
||||
|
||||
# 获取列信息
|
||||
columns = inspector.get_columns(table_name)
|
||||
# 获取列信息
|
||||
columns = inspector.get_columns(table_name)
|
||||
|
||||
# 获取主键信息
|
||||
try:
|
||||
pk_constraint = inspector.get_pk_constraint(table_name)
|
||||
primary_keys = set(pk_constraint.get("constrained_columns", [])) if pk_constraint else set()
|
||||
except Exception:
|
||||
primary_keys = set()
|
||||
# 获取主键信息
|
||||
try:
|
||||
pk_constraint = inspector.get_pk_constraint(table_name)
|
||||
primary_keys = set(pk_constraint.get("constrained_columns", [])) if pk_constraint else set()
|
||||
except Exception:
|
||||
primary_keys = set()
|
||||
|
||||
# 获取唯一约束信息
|
||||
unique_columns = set()
|
||||
unique_constraints = inspector.get_unique_constraints(table_name)
|
||||
for constraint in unique_constraints:
|
||||
unique_columns.update(constraint.get("column_names", []))
|
||||
# 获取唯一约束信息
|
||||
unique_columns = set()
|
||||
unique_constraints = inspector.get_unique_constraints(table_name)
|
||||
for constraint in unique_constraints:
|
||||
unique_columns.update(constraint.get("column_names", []))
|
||||
|
||||
# 处理列信息
|
||||
columns_list = []
|
||||
for idx, column in enumerate(columns):
|
||||
# 获取列的基本信息
|
||||
column_name = column["name"]
|
||||
column_type = str(column["type"])
|
||||
is_nullable = column.get("nullable", True)
|
||||
column_default = column.get("default", None)
|
||||
# 获取列注释(如果有的话)
|
||||
column_comment = column.get("comment", "")
|
||||
# 判断是否为主键
|
||||
is_pk = column_name in primary_keys
|
||||
# 判断是否为唯一约束
|
||||
is_unique = column_name in unique_columns
|
||||
# 判断是否为自增列(基于数据库类型和列类型)
|
||||
is_increment = column.get("autoincrement", False) in (True, "auto")
|
||||
# 获取列长度(如果适用)
|
||||
col_len = getattr(column["type"], "length", None)
|
||||
column_length = str(col_len) if col_len is not None else ""
|
||||
# 处理列信息
|
||||
columns_list = []
|
||||
for idx, column in enumerate(columns):
|
||||
# 获取列的基本信息
|
||||
column_name = column["name"]
|
||||
column_type = str(column["type"])
|
||||
is_nullable = column.get("nullable", True)
|
||||
column_default = column.get("default", None)
|
||||
# 获取列注释(如果有的话)
|
||||
column_comment = column.get("comment", "")
|
||||
# 判断是否为主键
|
||||
is_pk = column_name in primary_keys
|
||||
# 判断是否为唯一约束
|
||||
is_unique = column_name in unique_columns
|
||||
# 判断是否为自增列(基于数据库类型和列类型)
|
||||
is_increment = column.get("autoincrement", False) in (True, "auto")
|
||||
# 获取列长度(如果适用)
|
||||
col_len = getattr(column["type"], "length", None)
|
||||
column_length = str(col_len) if col_len is not None else ""
|
||||
|
||||
# 构造列信息字典
|
||||
column_info = {
|
||||
"column_name": column_name,
|
||||
"column_comment": column_comment or "",
|
||||
"column_type": column_type,
|
||||
"column_length": column_length or "",
|
||||
"column_default": str(column_default) if column_default is not None else "",
|
||||
"sort": idx + 1, # 序号从1开始
|
||||
"is_pk": bool(is_pk),
|
||||
"is_increment": bool(is_increment),
|
||||
"is_nullable": bool(is_nullable),
|
||||
"is_unique": bool(is_unique),
|
||||
}
|
||||
# 构造列信息字典
|
||||
column_info = {
|
||||
"column_name": column_name,
|
||||
"column_comment": column_comment or "",
|
||||
"column_type": column_type,
|
||||
"column_length": column_length or "",
|
||||
"column_default": str(column_default) if column_default is not None else "",
|
||||
"sort": idx + 1, # 序号从1开始
|
||||
"is_pk": bool(is_pk),
|
||||
"is_increment": bool(is_increment),
|
||||
"is_nullable": bool(is_nullable),
|
||||
"is_unique": bool(is_unique),
|
||||
}
|
||||
|
||||
columns_list.append(column_info)
|
||||
columns_list.append(column_info)
|
||||
|
||||
return columns_list
|
||||
return columns_list
|
||||
|
||||
return await _run_inspector(_introspect)
|
||||
|
||||
async def get_gen_table_column_by_id(self, id: int, preload: list | None = None) -> GenTableColumnModel | None:
|
||||
"""根据业务表字段ID获取业务表字段信息。
|
||||
@@ -507,12 +523,7 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen
|
||||
raise ValueError("数据表名称不能为空")
|
||||
|
||||
try:
|
||||
# 在线程池中执行同步 inspect 操作,避免阻塞事件循环
|
||||
columns_info = await asyncio.to_thread(
|
||||
GenTableColumnCRUD._sync_get_table_columns,
|
||||
settings.DATABASE_TYPE,
|
||||
table_name,
|
||||
)
|
||||
columns_info = await GenTableColumnCRUD._get_table_columns(settings.DATABASE_TYPE, table_name)
|
||||
|
||||
# 转换为GenTableColumnOutSchema对象列表
|
||||
columns_list = [GenTableColumnOutSchema(**column_info) for column_info in columns_info]
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.api.v1.module_system.log.model import LoginLogModel
|
||||
from app.api.v1.module_system.log.schema import LoginLogCreateSchema
|
||||
from app.api.v1.module_system.user.crud import UserCRUD
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
from app.common.enums import RedisInitKeyConfig
|
||||
from app.common.enums import RET, RedisInitKeyConfig
|
||||
from app.config.setting import settings
|
||||
from app.core.base_schema import AuthSchema, JWTOutSchema, JWTPayloadSchema
|
||||
from app.core.database import async_db_session
|
||||
@@ -165,6 +165,13 @@ class LoginService:
|
||||
"""用户认证"""
|
||||
ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
|
||||
request_ip = get_client_ip(request)
|
||||
if redis is None:
|
||||
# 会话存储不可用:登录即使成功也无法建立可用会话,明确 503
|
||||
raise CustomException(
|
||||
msg="Redis 服务不可用,无法建立登录会话",
|
||||
code=RET.SERVICE_UNAVAILABLE.code,
|
||||
status_code=503,
|
||||
)
|
||||
login_location = await IpLocalUtil.resolve_location_for_log(redis, request_ip)
|
||||
|
||||
# 暴力破解防护:先检查该用户名+IP 是否已被锁定(Redis 故障时跳过,避免锁死系统)
|
||||
@@ -329,6 +336,13 @@ class LoginService:
|
||||
@classmethod
|
||||
async def create_token(cls, request: Request, redis: Redis, user: UserModel, login_type: str) -> JWTOutSchema:
|
||||
"""创建访问令牌和刷新令牌"""
|
||||
if redis is None:
|
||||
# 会话信息仅存 Redis;降级模式下建出的 token 无法通过后续会话校验,直接 503
|
||||
raise CustomException(
|
||||
msg="Redis 服务不可用,无法建立登录会话",
|
||||
code=RET.SERVICE_UNAVAILABLE.code,
|
||||
status_code=503,
|
||||
)
|
||||
session_id = str(uuid.uuid4())
|
||||
ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
|
||||
request_ip = get_client_ip(request)
|
||||
@@ -406,6 +420,12 @@ class LoginService:
|
||||
token_payload: JWTPayloadSchema = decode_access_token(token=refresh_token)
|
||||
if not token_payload.is_refresh:
|
||||
raise CustomException(msg="非法凭证,请传入刷新令牌")
|
||||
if redis is None:
|
||||
raise CustomException(
|
||||
msg="Redis 服务不可用,无法刷新会话",
|
||||
code=RET.SERVICE_UNAVAILABLE.code,
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
session_id = token_payload.sub
|
||||
session_info = await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
|
||||
|
||||
@@ -379,17 +379,27 @@ class DictDataService:
|
||||
logger.error(f"❌️ 字典初始化过程发生错误: {e}")
|
||||
raise CustomException(msg="字典数据初始化失败") from e
|
||||
|
||||
@staticmethod
|
||||
async def _load_dict_type_from_db(dict_type: str) -> list[dict]:
|
||||
"""Redis 不可用时直接从数据库读取字典数据(序列化与 init_cache 一致)。"""
|
||||
async with async_db_session() as session, session.begin():
|
||||
init_auth = AuthSchema()
|
||||
data_list = await DictDataCRUD(init_auth, session).get_list(search={"dict_type": dict_type})
|
||||
return [DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in data_list if row]
|
||||
|
||||
@staticmethod
|
||||
async def get_init_cache(redis: Redis, dict_type: str) -> list[dict]:
|
||||
"""从缓存获取字典数据列表信息(无 auth)。
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis客户端
|
||||
- redis (Redis): Redis客户端(降级模式下为 None,直接回源数据库)
|
||||
- dict_type (str): 字典类型
|
||||
|
||||
返回:
|
||||
- list[dict]: 字典数据列表
|
||||
"""
|
||||
if redis is None:
|
||||
return await DictDataService._load_dict_type_from_db(dict_type)
|
||||
|
||||
def _parse(data: str | list | None) -> list[dict] | None:
|
||||
"""尝试反序列化 Redis 返回的字典缓存数据"""
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.config.path_conf import ENV_DIR, STATIC_DIR
|
||||
|
||||
|
||||
# JWT 开发默认密钥:仅允许 DEV 环境使用,生产命中即拒绝启动
|
||||
_DEV_DEFAULT_SECRET_KEY = "fastapiadmin-dev-secret-key-do-not-use-in-production"
|
||||
_DEV_DEFAULT_SECRET_KEY = "dpb-dev-secret-key-do-not-use-in-production"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -49,9 +49,9 @@ class Settings(BaseSettings):
|
||||
# ******************* API文档配置 ****************** #
|
||||
# ================================================= #
|
||||
DEBUG: bool = True # 调试模式
|
||||
TITLE: str = "🎉 FastapiAdmin 🎉 " # 文档标题
|
||||
TITLE: str = "DPB 桃育种系统" # 文档标题
|
||||
VERSION: str = "3.0.0" # 版本号
|
||||
DESCRIPTION: str = "一个基于fastapi、sqlalchemy、redis实现的轻量化框架" # 文档描述
|
||||
DESCRIPTION: str = "DPB 桃树育种管理系统:基于 FastAPI、SQLAlchemy、PostgreSQL、Redis 与 numpy 统计引擎的育种数据管理与遗传评估平台" # 文档描述
|
||||
SUMMARY: str = "接口汇总" # 文档概述
|
||||
DOCS_URL: str = "/docs" # Swagger UI路径
|
||||
REDOC_URL: str = "/redoc" # ReDoc路径
|
||||
@@ -205,7 +205,8 @@ class Settings(BaseSettings):
|
||||
# ================================================= #
|
||||
# ******************* 安全中间件配置 ****************** #
|
||||
# ================================================= #
|
||||
ALLOWED_HOSTS: list[str] = ["service.fastapiadmin.com", "*.fastapiadmin.com"] # 允许访问的主机名列表
|
||||
# 允许访问的主机名列表(仅 PROD 经 TrustedHostMiddleware 生效;内网部署请按实际访问域名补充)
|
||||
ALLOWED_HOSTS: list[str] = ["localhost", "127.0.0.1", "0.0.0.0"]
|
||||
|
||||
# 操作日志保留天数(调度器按此天数定期清理过期日志)
|
||||
OPERATION_LOG_RETENTION_DAYS: int = 90
|
||||
|
||||
@@ -177,6 +177,10 @@ class SchedulerUtil:
|
||||
|
||||
@classmethod
|
||||
def shutdown(cls, wait: bool = False) -> None:
|
||||
# Redis 不可达时 start()/add_job 可能失败导致调度器未真正运行:
|
||||
# 对未运行实例调用 shutdown 会抛 SchedulerNotRunningError,须先判状态
|
||||
if not scheduler.running:
|
||||
return
|
||||
scheduler.shutdown(wait=wait)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -13,6 +13,9 @@ from app.core.logger import logger
|
||||
def create_engine_and_session(db_url: str = settings.DB_URI) -> tuple[Engine, sessionmaker]:
|
||||
"""创建同步数据库引擎和会话工厂。
|
||||
|
||||
仅 APScheduler SQLAlchemyJobStore(同步库接口,apscheduler==3.11.0)消费;
|
||||
业务层一律使用异步 create_async_engine_and_session,不得再引入同步引擎。
|
||||
|
||||
参数:
|
||||
- db_url (str): 数据库连接URL,默认从配置中获取。
|
||||
|
||||
@@ -84,6 +87,7 @@ def create_async_engine_and_session(db_url: str = settings.ASYNC_DB_URI) -> tupl
|
||||
return async_engine, AsyncSessionLocal
|
||||
|
||||
|
||||
# 同步引擎/会话仅 APScheduler SQLAlchemyJobStore 消费(同步库接口);业务层统一用 async_engine
|
||||
engine, db_session = create_engine_and_session()
|
||||
async_engine, async_db_session = create_async_engine_and_session()
|
||||
|
||||
@@ -91,7 +95,7 @@ async def check_db() -> None:
|
||||
"""检查数据库连接是否正常。"""
|
||||
|
||||
try:
|
||||
with engine.connect():
|
||||
async with async_engine.connect():
|
||||
pass
|
||||
logger.info("✅ 数据库连接正常")
|
||||
except Exception as e:
|
||||
@@ -138,6 +142,7 @@ async def redis_connect(app: FastAPI, status: bool) -> Redis | None:
|
||||
- Redis | None: Redis连接实例,如果连接失败则返回None。
|
||||
"""
|
||||
if status:
|
||||
app.state.redis = None # 先置不可用;连接失败时应用以降级模式启动,不再中断
|
||||
try:
|
||||
rd = await Redis.from_url(
|
||||
url=settings.REDIS_URI,
|
||||
@@ -148,18 +153,20 @@ async def redis_connect(app: FastAPI, status: bool) -> Redis | None:
|
||||
max_connections=settings.POOL_SIZE,
|
||||
socket_timeout=settings.POOL_TIMEOUT,
|
||||
)
|
||||
app.state.redis = rd
|
||||
if await rd.ping(): # pyright: ignore[reportGeneralTypeIssues]
|
||||
app.state.redis = rd
|
||||
return rd
|
||||
except exceptions.AuthenticationError as e:
|
||||
logger.error(f"❌ 数据库 Redis 认证失败: {e}")
|
||||
return None
|
||||
except exceptions.TimeoutError as e:
|
||||
logger.error(f"❌ 数据库 Redis 连接超时: {e}")
|
||||
return None
|
||||
except exceptions.RedisError as e:
|
||||
logger.error(f"❌ 数据库 Redis 连接错误: {e}")
|
||||
raise
|
||||
logger.error("❌ 数据库 Redis ping 失败")
|
||||
await rd.aclose()
|
||||
except (exceptions.RedisError, OSError) as e:
|
||||
logger.error(f"❌ 数据库 Redis 连接失败: {e}")
|
||||
return None
|
||||
else:
|
||||
await app.state.redis.close()
|
||||
rd = app.state.redis
|
||||
if rd is not None:
|
||||
try:
|
||||
await rd.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
app.state.redis = None
|
||||
logger.info("✅️ Redis连接已关闭")
|
||||
|
||||
@@ -34,11 +34,27 @@ async def redis_getter(request: Request) -> Redis:
|
||||
- request (Request): 请求对象
|
||||
|
||||
返回:
|
||||
- Redis: Redis连接
|
||||
- Redis: Redis连接(启动降级模式下为 None)
|
||||
"""
|
||||
return request.app.state.redis
|
||||
|
||||
|
||||
def require_redis(redis: Redis | None) -> Redis:
|
||||
"""Redis 不可用时显式 503(存储类接口守卫)。
|
||||
|
||||
Redis 是认证会话、AI 模型配置、在线监控与调度任务 jobstore 的唯一存储。
|
||||
启动降级模式下这些接口无法提供服务,应明确返回 503,而非误导性的
|
||||
401/500 或静默空数据。
|
||||
"""
|
||||
if redis is None:
|
||||
raise CustomException(
|
||||
msg="Redis 服务不可用,请稍后重试",
|
||||
code=RET.SERVICE_UNAVAILABLE.code,
|
||||
status_code=503,
|
||||
)
|
||||
return redis
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
db: AsyncSession = Depends(db_getter),
|
||||
redis: Redis = Depends(redis_getter),
|
||||
@@ -69,6 +85,12 @@ async def _authenticate(
|
||||
session_id = payload.sub
|
||||
if not session_id:
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401)
|
||||
if redis is None:
|
||||
raise CustomException(
|
||||
msg="Redis 服务不可用,无法校验登录会话",
|
||||
code=RET.SERVICE_UNAVAILABLE.code,
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
raw = await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
|
||||
if not raw:
|
||||
|
||||
@@ -52,7 +52,7 @@ def setup_logger() -> None:
|
||||
LOG_FMT = "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>{extra[ctx]}"
|
||||
logger.add(sys.stdout, format=LOG_FMT, backtrace=True, diagnose=True, catch=True, level=settings.LOGGER_LEVEL)
|
||||
logger.add(
|
||||
sink=str(LOG_DIR / "fastapiadmin.log"),
|
||||
sink=str(LOG_DIR / "dpb.log"),
|
||||
format=LOG_FMT,
|
||||
level=settings.LOGGER_LEVEL,
|
||||
backtrace=True,
|
||||
|
||||
+32
-9
@@ -26,22 +26,45 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
|
||||
|
||||
await InitializeData().init_db()
|
||||
logger.info("✅ {}数据库初始化完成", settings.DATABASE_TYPE)
|
||||
|
||||
# Redis 启动不强依赖:连接失败降级启动(参数/字典读接口回源数据库,
|
||||
# 登录会话/在线监控/AI配置/调度任务等存储类接口返回 503),不再中断应用。
|
||||
await redis_connect(app, status=True)
|
||||
logger.info("✅ Redis 连接初始化完成")
|
||||
await ParamsService.init_cache(redis=app.state.redis)
|
||||
logger.info("✅ Redis系统参数初始化完成")
|
||||
await DictDataService.init_cache(redis=app.state.redis)
|
||||
logger.info("✅ Redis数据字典初始化完成")
|
||||
await SchedulerUtil.init_scheduler(redis=app.state.redis)
|
||||
logger.info("✅ 定时任务调度器初始化完成")
|
||||
redis_ready = app.state.redis is not None
|
||||
if redis_ready:
|
||||
logger.info("✅ Redis 连接初始化完成")
|
||||
await ParamsService.init_cache(redis=app.state.redis)
|
||||
logger.info("✅ Redis系统参数初始化完成")
|
||||
await DictDataService.init_cache(redis=app.state.redis)
|
||||
logger.info("✅ Redis数据字典初始化完成")
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Redis 不可用,以降级模式启动:参数/字典读接口回源数据库;"
|
||||
"登录会话/在线监控/AI配置/调度任务接口将返回 503"
|
||||
)
|
||||
|
||||
from app.utils.dict_util import preload_value2label_cache
|
||||
|
||||
await preload_value2label_cache()
|
||||
logger.info("✅ 数据字典导出映射预载完成")
|
||||
|
||||
scheduler_ready = False
|
||||
try:
|
||||
await SchedulerUtil.init_scheduler(redis=app.state.redis)
|
||||
scheduler_ready = SchedulerUtil.is_running()
|
||||
logger.info("✅ 定时任务调度器初始化完成")
|
||||
except Exception as e:
|
||||
# Redis 不可达时默认 jobstore 连不上:不阻断启动,任务接口降级为 503
|
||||
SchedulerUtil.shutdown(wait=False)
|
||||
logger.error("❌ 定时任务调度器初始化失败(继续启动,任务接口将返回 503): {}", e)
|
||||
|
||||
console_start(
|
||||
host=settings.SERVER_HOST,
|
||||
port=settings.SERVER_PORT,
|
||||
reload=settings.DEBUG,
|
||||
database_ready=True,
|
||||
redis_ready=True,
|
||||
scheduler_ready=SchedulerUtil.is_running(),
|
||||
redis_ready=redis_ready,
|
||||
scheduler_ready=scheduler_ready,
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
@@ -54,16 +54,16 @@ class AgnoFactory:
|
||||
temperature = float(model_config["temperature"])
|
||||
|
||||
# 创建 Agent
|
||||
fastapiadmin_agent = Agent(
|
||||
dpb_agent = Agent(
|
||||
id=user_id,
|
||||
name="fastapiadmin_agent",
|
||||
name="dpb_agent",
|
||||
role="You are a helpful AI assistant",
|
||||
description=self.AGENT_DESCRIPTION,
|
||||
tools=[],
|
||||
)
|
||||
|
||||
# 创建 Team
|
||||
fastapiadmin_team = Team(
|
||||
dpb_team = Team(
|
||||
id=team_id,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
@@ -74,7 +74,7 @@ class AgnoFactory:
|
||||
temperature=temperature,
|
||||
timeout=self.REQUEST_TIMEOUT,
|
||||
),
|
||||
members=[fastapiadmin_agent],
|
||||
members=[dpb_agent],
|
||||
instructions=self.AGENT_INSTRUCTIONS,
|
||||
expected_output=self.AGENT_EXPECTED_OUTPUT,
|
||||
add_datetime_to_context=True,
|
||||
@@ -88,4 +88,4 @@ class AgnoFactory:
|
||||
db=db,
|
||||
)
|
||||
|
||||
return fastapiadmin_team
|
||||
return dpb_team
|
||||
|
||||
@@ -3,7 +3,7 @@ from app.config.path_conf import BANNER_FILE
|
||||
|
||||
def worship() -> str:
|
||||
"""读取启动 Banner(优先 `banner.txt`)。
|
||||
获取地址:https://patorjk.com/software/taag/#p=testall&f=Fire+Font-k&t=fastapiadmin%0A&x=none&v=4&h=4&w=80&we=false
|
||||
获取地址:https://patorjk.com/software/taag/#p=testall&f=Fire+Font-k&t=dpb&x=none&v=4&h=4&w=80&we=false
|
||||
|
||||
返回:
|
||||
- str: banner 文本。
|
||||
|
||||
@@ -91,7 +91,7 @@ def console_start(
|
||||
|
||||
result = Panel(
|
||||
renderable=final_content,
|
||||
title=f"[bold purple]🚀 FastapiAdmin v{settings.VERSION}[/]",
|
||||
title=f"[bold purple]🚀 DPB 桃育种系统 v{settings.VERSION}[/]",
|
||||
border_style="green",
|
||||
box=box.HEAVY,
|
||||
padding=(0, 2),
|
||||
@@ -108,7 +108,7 @@ def console_end() -> None:
|
||||
"""
|
||||
shutdown_content = Text()
|
||||
shutdown_content.append("🛑 ", style="bold red")
|
||||
shutdown_content.append("FastapiAdmin 服务关闭")
|
||||
shutdown_content.append("DPB 桃育种系统服务关闭")
|
||||
shutdown_content.append(f"\n⏰ {datetime.now().strftime('%H:%M:%S')}")
|
||||
shutdown_content.append("\n👋 感谢使用!", style="dim")
|
||||
|
||||
|
||||
@@ -20,12 +20,44 @@ from app.core.exceptions import CustomException
|
||||
_value2label_cache: dict[str, dict[str, str]] | None = None
|
||||
|
||||
|
||||
def _rows_to_mapping(rows) -> dict[str, dict[str, str]]:
|
||||
mapping: dict[str, dict[str, str]] = {}
|
||||
for dt, dv, dl in rows:
|
||||
mapping.setdefault(dt, {})[dv] = dl
|
||||
return mapping
|
||||
|
||||
|
||||
async def preload_value2label_cache() -> None:
|
||||
"""应用启动时用异步引擎预载全量字典映射(value→label)。
|
||||
|
||||
batch_export 均为同步 staticmethod(控制器同步调用、bytes 序列化),无法 await,
|
||||
故启动时预热一次进程内缓存,导出请求路径只做内存查找、不触达同步引擎。
|
||||
"""
|
||||
global _value2label_cache
|
||||
if _value2label_cache is not None:
|
||||
return
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_system.dict.model import DictDataModel
|
||||
from app.core.database import async_db_session
|
||||
|
||||
async with async_db_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(DictDataModel.dict_type, DictDataModel.dict_value, DictDataModel.dict_label)
|
||||
.where(DictDataModel.is_deleted.is_(False))
|
||||
)
|
||||
).all()
|
||||
_value2label_cache = _rows_to_mapping(rows)
|
||||
|
||||
|
||||
def dict_value_to_label(dict_type: str, value: Any) -> Any:
|
||||
"""把英码 dict_value 翻译为中文 dict_label(导出用)。
|
||||
|
||||
与 DictLabelResolver 方向相反:导出时把库内的英码还原为中文,便于用户阅读;
|
||||
值空或无法识别时原样返回,保证"导出的文件再导入"仍可解析。
|
||||
使用同步引擎读取 sys_dict_data 并进程内缓存,避免每次导出都查库。
|
||||
应用启动经 preload_value2label_cache 预热后纯内存查找;脚本等未走 lifespan 的
|
||||
上下文兜底用同步会话载入(见 create_engine_and_session 的调度器孤岛注释)。
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
@@ -39,15 +71,12 @@ def dict_value_to_label(dict_type: str, value: Any) -> Any:
|
||||
from app.api.v1.module_system.dict.model import DictDataModel
|
||||
from app.core.database import db_session
|
||||
|
||||
mapping: dict[str, dict[str, str]] = {}
|
||||
with db_session() as session:
|
||||
rows = session.execute(
|
||||
select(DictDataModel.dict_type, DictDataModel.dict_value, DictDataModel.dict_label)
|
||||
.where(DictDataModel.is_deleted.is_(False))
|
||||
).all()
|
||||
for dt, dv, dl in rows:
|
||||
mapping.setdefault(dt, {})[dv] = dl
|
||||
_value2label_cache = mapping
|
||||
_value2label_cache = _rows_to_mapping(rows)
|
||||
return _value2label_cache.get(dict_type, {}).get(text, value)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user