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:
34047007@qq.com
2026-08-07 09:06:19 +08:00
parent e937fc930c
commit 86126ea16f
64 changed files with 546 additions and 359 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# ============================================ # ============================================
# FastapiAdmin .dockerignore # DPB .dockerignore
# 用于优化 Docker 构建上下文,提升构建速度 # 用于优化 Docker 构建上下文,提升构建速度
# ============================================ # ============================================
+4 -4
View File
@@ -1,6 +1,6 @@
# FastApiAdmin - Backend # DPB 桃育种系统后端
基于 FastAPI 框架构建的企业级后端架构,为前端 Vue3 管理系统提供完整的 API 服务支持。 基于 FastAPI 框架自研的桃树育种管理系统后端,为前端 Vue3 管理界面提供完整的 API 服务支持。
> **与仓库根文档的关系**:项目总览、一键前后端启动、演示账号、Docker 部署、架构图与默认端口等请以 [根目录 README.md](../README.md) 为准;**本文档**侧重 `backend/` 目录结构、迁移命令与后端开发约定。 > **与仓库根文档的关系**:项目总览、一键前后端启动、演示账号、Docker 部署、架构图与默认端口等请以 [根目录 README.md](../README.md) 为准;**本文档**侧重 `backend/` 目录结构、迁移命令与后端开发约定。
@@ -60,14 +60,14 @@ module_*/
└── param.py # 参数模型 - 请求参数 └── param.py # 参数模型 - 请求参数
``` ```
分包理念按业务竖切 vs 按技术层次分包)详见 [项目概述](https://service.fastapiadmin.com/guide/overview) 分包理念按业务竖切module_bre 育种 / module_system 系统 / module_monitor 监控 / module_ai AI)而非按技术层次分包,每个业务模块内部再按 controller/service/crud/model/schema 分层
## 快速开始 ## 快速开始
### 环境要求 ### 环境要求
- **Python**: 3.12+ - **Python**: 3.12+
- **数据库**: PostgreSQL 16(连接串在 `env/.env.dev`;测试套件使用 SQLite - **数据库**: PostgreSQL 16(连接串在 `env/.env.dev`;测试套件连独立测试库 `dpb_test`,同为真实 PostgreSQL16,见 `run_ci.py`
- **Redis**: 与 `.env.dev` 中配置一致 - **Redis**: 与 `.env.dev` 中配置一致
### 第一次在本机跑起来 ### 第一次在本机跑起来
@@ -1,14 +1,13 @@
import asyncio
from collections.abc import Sequence from collections.abc import Sequence
from typing import TYPE_CHECKING 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 sqlalchemy.ext.asyncio import AsyncSession
from app.config.setting import settings from app.config.setting import settings
from app.core.base_crud import CRUDBase from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema 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.core.logger import logger
from app.utils.common_util import search_to_dict from app.utils.common_util import search_to_dict
@@ -25,6 +24,12 @@ if TYPE_CHECKING:
from sqlalchemy.engine.reflection import Inspector 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]): class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
"""代码生成业务表模块数据库操作层""" """代码生成业务表模块数据库操作层"""
@@ -142,19 +147,21 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
database_name = settings.DATABASE_NAME database_name = settings.DATABASE_NAME
database_type = settings.DATABASE_TYPE database_type = settings.DATABASE_TYPE
inspector: Inspector = inspect(engine) def _introspect(sync_conn) -> list[tuple[str, str]]:
table_names = inspector.get_table_names() insp = inspect(sync_conn)
out = []
dict_data = [] for table_name in insp.get_table_names():
for table_name in table_names:
try: try:
table_comment = inspector.get_table_comment(table_name) table_comment = insp.get_table_comment(table_name)
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else table_comment comment = table_comment.get("text", "") if isinstance(table_comment, dict) else table_comment
table_comment = comment or "" out.append((table_name, comment or ""))
except Exception as e: except Exception as e:
logger.warning(f"获取表 {table_name} 的注释失败: {e}") logger.warning(f"获取表 {table_name} 的注释失败: {e}")
table_comment = "" out.append((table_name, ""))
return out
dict_data = []
for table_name, table_comment in await _run_inspector(_introspect):
# 统一处理 search 为 None 的情况,避免重复判断 # 统一处理 search 为 None 的情况,避免重复判断
if search: if search:
# 表名过滤:忽略大小写,支持模糊匹配 # 表名过滤:忽略大小写,支持模糊匹配
@@ -296,26 +303,30 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
database_name = settings.DATABASE_NAME database_name = settings.DATABASE_NAME
database_type = settings.DATABASE_TYPE database_type = settings.DATABASE_TYPE
inspector: Inspector = inspect(engine) def _introspect(sync_conn, names: list[str]) -> list[tuple[str, str]]:
all_table_names = set(inspector.get_table_names()) insp = inspect(sync_conn)
all_table_names = set(insp.get_table_names())
results = [] out = []
for table_name in table_names: for table_name in names:
if table_name not in all_table_names: if table_name not in all_table_names:
continue continue
try: try:
table_comment = inspector.get_table_comment(table_name) table_comment = insp.get_table_comment(table_name)
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "") comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "")
except Exception as e: except Exception as e:
logger.warning(f"获取表 {table_name} 的注释失败: {e}") logger.warning(f"获取表 {table_name} 的注释失败: {e}")
comment = "" comment = ""
out.append((table_name, comment or ""))
return out
results = []
for table_name, comment in await _run_inspector(_introspect, table_names):
results.append( results.append(
GenDBTableSchema( GenDBTableSchema(
database_name=database_name, database_name=database_name,
table_name=table_name, table_name=table_name,
table_type=database_type, table_type=database_type,
table_comment=comment or "", table_comment=comment,
), ),
) )
@@ -330,8 +341,7 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
返回: 返回:
- bool: 如果表存在返回True,否则返回False。 - bool: 如果表存在返回True,否则返回False。
""" """
inspector: Inspector = inspect(engine) return await _run_inspector(lambda sync_conn: inspect(sync_conn).has_table(table_name))
return inspector.has_table(table_name)
async def get_db_table_comment(self, table_name: str) -> str: async def get_db_table_comment(self, table_name: str) -> str:
"""获取数据库中指定表的注释(用于主子表场景下从库中加载子表元信息)。 """获取数据库中指定表的注释(用于主子表场景下从库中加载子表元信息)。
@@ -342,17 +352,20 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
返回: 返回:
- str: 表注释;表不存在或失败时为空字符串。 - str: 表注释;表不存在或失败时为空字符串。
""" """
inspector: Inspector = inspect(engine) def _get_comment(sync_conn) -> str:
if not inspector.has_table(table_name): insp = inspect(sync_conn)
if not insp.has_table(table_name):
return "" return ""
try: try:
table_comment = inspector.get_table_comment(table_name) table_comment = insp.get_table_comment(table_name)
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "") comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "")
return comment or "" return comment or ""
except Exception as e: except Exception as e:
logger.warning(f"获取表 {table_name} 的注释失败: {e}") logger.warning(f"获取表 {table_name} 的注释失败: {e}")
return "" return ""
return await _run_inspector(_get_comment)
async def execute_sql(self, sql: str) -> bool: async def execute_sql(self, sql: str) -> bool:
"""执行SQL语句。 """执行SQL语句。
@@ -384,8 +397,8 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen
super().__init__(model=GenTableColumnModel, auth=auth, db=db) super().__init__(model=GenTableColumnModel, auth=auth, db=db)
@staticmethod @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: 数据库类型 - database_type: 数据库类型
@@ -394,8 +407,9 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen
返回: 返回:
- list: 列信息列表 - list: 列信息列表
""" """
def _introspect(sync_conn) -> list[dict]:
# 使用SQLAlchemy Inspector获取表列信息 # 使用SQLAlchemy Inspector获取表列信息
inspector: Inspector = inspect(engine) inspector = inspect(sync_conn)
# 获取列信息 # 获取列信息
columns = inspector.get_columns(table_name) columns = inspector.get_columns(table_name)
@@ -451,6 +465,8 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen
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: async def get_gen_table_column_by_id(self, id: int, preload: list | None = None) -> GenTableColumnModel | None:
"""根据业务表字段ID获取业务表字段信息。 """根据业务表字段ID获取业务表字段信息。
@@ -507,12 +523,7 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen
raise ValueError("数据表名称不能为空") raise ValueError("数据表名称不能为空")
try: try:
# 在线程池中执行同步 inspect 操作,避免阻塞事件循环 columns_info = await GenTableColumnCRUD._get_table_columns(settings.DATABASE_TYPE, table_name)
columns_info = await asyncio.to_thread(
GenTableColumnCRUD._sync_get_table_columns,
settings.DATABASE_TYPE,
table_name,
)
# 转换为GenTableColumnOutSchema对象列表 # 转换为GenTableColumnOutSchema对象列表
columns_list = [GenTableColumnOutSchema(**column_info) for column_info in columns_info] 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.log.schema import LoginLogCreateSchema
from app.api.v1.module_system.user.crud import UserCRUD from app.api.v1.module_system.user.crud import UserCRUD
from app.api.v1.module_system.user.model import UserModel from app.api.v1.module_system.user.model import UserModel
from app.common.enums import RedisInitKeyConfig from app.common.enums import RET, RedisInitKeyConfig
from app.config.setting import settings from app.config.setting import settings
from app.core.base_schema import AuthSchema, JWTOutSchema, JWTPayloadSchema from app.core.base_schema import AuthSchema, JWTOutSchema, JWTPayloadSchema
from app.core.database import async_db_session 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 "") ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
request_ip = get_client_ip(request) 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) login_location = await IpLocalUtil.resolve_location_for_log(redis, request_ip)
# 暴力破解防护:先检查该用户名+IP 是否已被锁定(Redis 故障时跳过,避免锁死系统) # 暴力破解防护:先检查该用户名+IP 是否已被锁定(Redis 故障时跳过,避免锁死系统)
@@ -329,6 +336,13 @@ class LoginService:
@classmethod @classmethod
async def create_token(cls, request: Request, redis: Redis, user: UserModel, login_type: str) -> JWTOutSchema: 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()) session_id = str(uuid.uuid4())
ua_result = ua_parser.parse(request.headers.get("user-agent") or "") ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
request_ip = get_client_ip(request) request_ip = get_client_ip(request)
@@ -406,6 +420,12 @@ class LoginService:
token_payload: JWTPayloadSchema = decode_access_token(token=refresh_token) token_payload: JWTPayloadSchema = decode_access_token(token=refresh_token)
if not token_payload.is_refresh: if not token_payload.is_refresh:
raise CustomException(msg="非法凭证,请传入刷新令牌") 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_id = token_payload.sub
session_info = await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}") session_info = await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
@@ -379,17 +379,27 @@ class DictDataService:
logger.error(f"❌️ 字典初始化过程发生错误: {e}") logger.error(f"❌️ 字典初始化过程发生错误: {e}")
raise CustomException(msg="字典数据初始化失败") from 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 @staticmethod
async def get_init_cache(redis: Redis, dict_type: str) -> list[dict]: async def get_init_cache(redis: Redis, dict_type: str) -> list[dict]:
"""从缓存获取字典数据列表信息(无 auth)。 """从缓存获取字典数据列表信息(无 auth)。
参数: 参数:
- redis (Redis): Redis客户端 - redis (Redis): Redis客户端(降级模式下为 None,直接回源数据库)
- dict_type (str): 字典类型 - dict_type (str): 字典类型
返回: 返回:
- list[dict]: 字典数据列表 - 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: def _parse(data: str | list | None) -> list[dict] | None:
"""尝试反序列化 Redis 返回的字典缓存数据""" """尝试反序列化 Redis 返回的字典缓存数据"""
+5 -4
View File
@@ -12,7 +12,7 @@ from app.config.path_conf import ENV_DIR, STATIC_DIR
# JWT 开发默认密钥:仅允许 DEV 环境使用,生产命中即拒绝启动 # 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): class Settings(BaseSettings):
@@ -49,9 +49,9 @@ class Settings(BaseSettings):
# ******************* API文档配置 ****************** # # ******************* API文档配置 ****************** #
# ================================================= # # ================================================= #
DEBUG: bool = True # 调试模式 DEBUG: bool = True # 调试模式
TITLE: str = "🎉 FastapiAdmin 🎉 " # 文档标题 TITLE: str = "DPB 桃育种系统" # 文档标题
VERSION: str = "3.0.0" # 版本号 VERSION: str = "3.0.0" # 版本号
DESCRIPTION: str = "一个基于fastapi、sqlalchemy、redis实现的轻量化框架" # 文档描述 DESCRIPTION: str = "DPB 桃树育种管理系统:基于 FastAPI、SQLAlchemy、PostgreSQL、Redis 与 numpy 统计引擎的育种数据管理与遗传评估平台" # 文档描述
SUMMARY: str = "接口汇总" # 文档概述 SUMMARY: str = "接口汇总" # 文档概述
DOCS_URL: str = "/docs" # Swagger UI路径 DOCS_URL: str = "/docs" # Swagger UI路径
REDOC_URL: str = "/redoc" # ReDoc路径 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 OPERATION_LOG_RETENTION_DAYS: int = 90
+4
View File
@@ -177,6 +177,10 @@ class SchedulerUtil:
@classmethod @classmethod
def shutdown(cls, wait: bool = False) -> None: def shutdown(cls, wait: bool = False) -> None:
# Redis 不可达时 start()/add_job 可能失败导致调度器未真正运行:
# 对未运行实例调用 shutdown 会抛 SchedulerNotRunningError,须先判状态
if not scheduler.running:
return
scheduler.shutdown(wait=wait) scheduler.shutdown(wait=wait)
@classmethod @classmethod
+18 -11
View File
@@ -13,6 +13,9 @@ from app.core.logger import logger
def create_engine_and_session(db_url: str = settings.DB_URI) -> tuple[Engine, sessionmaker]: 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,默认从配置中获取。 - 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 return async_engine, AsyncSessionLocal
# 同步引擎/会话仅 APScheduler SQLAlchemyJobStore 消费(同步库接口);业务层统一用 async_engine
engine, db_session = create_engine_and_session() engine, db_session = create_engine_and_session()
async_engine, async_db_session = create_async_engine_and_session() async_engine, async_db_session = create_async_engine_and_session()
@@ -91,7 +95,7 @@ async def check_db() -> None:
"""检查数据库连接是否正常。""" """检查数据库连接是否正常。"""
try: try:
with engine.connect(): async with async_engine.connect():
pass pass
logger.info("✅ 数据库连接正常") logger.info("✅ 数据库连接正常")
except Exception as e: except Exception as e:
@@ -138,6 +142,7 @@ async def redis_connect(app: FastAPI, status: bool) -> Redis | None:
- Redis | None: Redis连接实例,如果连接失败则返回None。 - Redis | None: Redis连接实例,如果连接失败则返回None。
""" """
if status: if status:
app.state.redis = None # 先置不可用;连接失败时应用以降级模式启动,不再中断
try: try:
rd = await Redis.from_url( rd = await Redis.from_url(
url=settings.REDIS_URI, url=settings.REDIS_URI,
@@ -148,18 +153,20 @@ async def redis_connect(app: FastAPI, status: bool) -> Redis | None:
max_connections=settings.POOL_SIZE, max_connections=settings.POOL_SIZE,
socket_timeout=settings.POOL_TIMEOUT, socket_timeout=settings.POOL_TIMEOUT,
) )
app.state.redis = rd
if await rd.ping(): # pyright: ignore[reportGeneralTypeIssues] if await rd.ping(): # pyright: ignore[reportGeneralTypeIssues]
app.state.redis = rd
return rd return rd
except exceptions.AuthenticationError as e: logger.error("❌ 数据库 Redis ping 失败")
logger.error(f"❌ 数据库 Redis 认证失败: {e}") await rd.aclose()
except (exceptions.RedisError, OSError) as e:
logger.error(f"❌ 数据库 Redis 连接失败: {e}")
return None 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
else: 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连接已关闭") logger.info("✅️ Redis连接已关闭")
+23 -1
View File
@@ -34,11 +34,27 @@ async def redis_getter(request: Request) -> Redis:
- request (Request): 请求对象 - request (Request): 请求对象
返回: 返回:
- Redis: Redis连接 - Redis: Redis连接(启动降级模式下为 None
""" """
return request.app.state.redis 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( async def get_current_user(
db: AsyncSession = Depends(db_getter), db: AsyncSession = Depends(db_getter),
redis: Redis = Depends(redis_getter), redis: Redis = Depends(redis_getter),
@@ -69,6 +85,12 @@ async def _authenticate(
session_id = payload.sub session_id = payload.sub
if not session_id: if not session_id:
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401) 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}") raw = await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
if not raw: if not raw:
+1 -1
View File
@@ -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]}" 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(sys.stdout, format=LOG_FMT, backtrace=True, diagnose=True, catch=True, level=settings.LOGGER_LEVEL)
logger.add( logger.add(
sink=str(LOG_DIR / "fastapiadmin.log"), sink=str(LOG_DIR / "dpb.log"),
format=LOG_FMT, format=LOG_FMT,
level=settings.LOGGER_LEVEL, level=settings.LOGGER_LEVEL,
backtrace=True, backtrace=True,
+25 -2
View File
@@ -26,22 +26,45 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
await InitializeData().init_db() await InitializeData().init_db()
logger.info("{}数据库初始化完成", settings.DATABASE_TYPE) logger.info("{}数据库初始化完成", settings.DATABASE_TYPE)
# Redis 启动不强依赖:连接失败降级启动(参数/字典读接口回源数据库,
# 登录会话/在线监控/AI配置/调度任务等存储类接口返回 503),不再中断应用。
await redis_connect(app, status=True) await redis_connect(app, status=True)
redis_ready = app.state.redis is not None
if redis_ready:
logger.info("✅ Redis 连接初始化完成") logger.info("✅ Redis 连接初始化完成")
await ParamsService.init_cache(redis=app.state.redis) await ParamsService.init_cache(redis=app.state.redis)
logger.info("✅ Redis系统参数初始化完成") logger.info("✅ Redis系统参数初始化完成")
await DictDataService.init_cache(redis=app.state.redis) await DictDataService.init_cache(redis=app.state.redis)
logger.info("✅ 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) await SchedulerUtil.init_scheduler(redis=app.state.redis)
scheduler_ready = SchedulerUtil.is_running()
logger.info("✅ 定时任务调度器初始化完成") logger.info("✅ 定时任务调度器初始化完成")
except Exception as e:
# Redis 不可达时默认 jobstore 连不上:不阻断启动,任务接口降级为 503
SchedulerUtil.shutdown(wait=False)
logger.error("❌ 定时任务调度器初始化失败(继续启动,任务接口将返回 503): {}", e)
console_start( console_start(
host=settings.SERVER_HOST, host=settings.SERVER_HOST,
port=settings.SERVER_PORT, port=settings.SERVER_PORT,
reload=settings.DEBUG, reload=settings.DEBUG,
database_ready=True, database_ready=True,
redis_ready=True, redis_ready=redis_ready,
scheduler_ready=SchedulerUtil.is_running(), scheduler_ready=scheduler_ready,
) )
yield yield
+5 -5
View File
@@ -54,16 +54,16 @@ class AgnoFactory:
temperature = float(model_config["temperature"]) temperature = float(model_config["temperature"])
# 创建 Agent # 创建 Agent
fastapiadmin_agent = Agent( dpb_agent = Agent(
id=user_id, id=user_id,
name="fastapiadmin_agent", name="dpb_agent",
role="You are a helpful AI assistant", role="You are a helpful AI assistant",
description=self.AGENT_DESCRIPTION, description=self.AGENT_DESCRIPTION,
tools=[], tools=[],
) )
# 创建 Team # 创建 Team
fastapiadmin_team = Team( dpb_team = Team(
id=team_id, id=team_id,
user_id=user_id, user_id=user_id,
session_id=session_id, session_id=session_id,
@@ -74,7 +74,7 @@ class AgnoFactory:
temperature=temperature, temperature=temperature,
timeout=self.REQUEST_TIMEOUT, timeout=self.REQUEST_TIMEOUT,
), ),
members=[fastapiadmin_agent], members=[dpb_agent],
instructions=self.AGENT_INSTRUCTIONS, instructions=self.AGENT_INSTRUCTIONS,
expected_output=self.AGENT_EXPECTED_OUTPUT, expected_output=self.AGENT_EXPECTED_OUTPUT,
add_datetime_to_context=True, add_datetime_to_context=True,
@@ -88,4 +88,4 @@ class AgnoFactory:
db=db, db=db,
) )
return fastapiadmin_team return dpb_team
+1 -1
View File
@@ -3,7 +3,7 @@ from app.config.path_conf import BANNER_FILE
def worship() -> str: def worship() -> str:
"""读取启动 Banner(优先 `banner.txt`)。 """读取启动 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 文本。 - str: banner 文本。
+2 -2
View File
@@ -91,7 +91,7 @@ def console_start(
result = Panel( result = Panel(
renderable=final_content, renderable=final_content,
title=f"[bold purple]🚀 FastapiAdmin v{settings.VERSION}[/]", title=f"[bold purple]🚀 DPB 桃育种系统 v{settings.VERSION}[/]",
border_style="green", border_style="green",
box=box.HEAVY, box=box.HEAVY,
padding=(0, 2), padding=(0, 2),
@@ -108,7 +108,7 @@ def console_end() -> None:
""" """
shutdown_content = Text() shutdown_content = Text()
shutdown_content.append("🛑 ", style="bold red") 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(f"\n{datetime.now().strftime('%H:%M:%S')}")
shutdown_content.append("\n👋 感谢使用!", style="dim") shutdown_content.append("\n👋 感谢使用!", style="dim")
+34 -5
View File
@@ -20,12 +20,44 @@ from app.core.exceptions import CustomException
_value2label_cache: dict[str, dict[str, str]] | None = None _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: def dict_value_to_label(dict_type: str, value: Any) -> Any:
"""把英码 dict_value 翻译为中文 dict_label(导出用)。 """把英码 dict_value 翻译为中文 dict_label(导出用)。
与 DictLabelResolver 方向相反:导出时把库内的英码还原为中文,便于用户阅读; 与 DictLabelResolver 方向相反:导出时把库内的英码还原为中文,便于用户阅读;
值空或无法识别时原样返回,保证"导出的文件再导入"仍可解析。 值空或无法识别时原样返回,保证"导出的文件再导入"仍可解析。
使用同步引擎读取 sys_dict_data 并进程内缓存,避免每次导出都查库。 应用启动经 preload_value2label_cache 预热后纯内存查找;脚本等未走 lifespan 的
上下文兜底用同步会话载入(见 create_engine_and_session 的调度器孤岛注释)。
""" """
if value is None: if value is None:
return value 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.api.v1.module_system.dict.model import DictDataModel
from app.core.database import db_session from app.core.database import db_session
mapping: dict[str, dict[str, str]] = {}
with db_session() as session: with db_session() as session:
rows = session.execute( rows = session.execute(
select(DictDataModel.dict_type, DictDataModel.dict_value, DictDataModel.dict_label) select(DictDataModel.dict_type, DictDataModel.dict_value, DictDataModel.dict_label)
.where(DictDataModel.is_deleted.is_(False)) .where(DictDataModel.is_deleted.is_(False))
).all() ).all()
for dt, dv, dl in rows: _value2label_cache = _rows_to_mapping(rows)
mapping.setdefault(dt, {})[dv] = dl
_value2label_cache = mapping
return _value2label_cache.get(dict_type, {}).get(text, value) return _value2label_cache.get(dict_type, {}).get(text, value)
+6 -6
View File
@@ -18,7 +18,7 @@ from app.common.enums import EnvironmentEnum
from app.config.setting import settings from app.config.setting import settings
from app.utils.banner import worship from app.utils.banner import worship
fastapiadmin_cli = typer.Typer() dpb_cli = typer.Typer()
alembic_cfg = Config("alembic.ini") alembic_cfg = Config("alembic.ini")
@@ -48,9 +48,9 @@ def create_app() -> FastAPI:
# typer.Option是非必填;typer.Argument是必填 # typer.Option是非必填;typer.Argument是必填
@fastapiadmin_cli.command( @dpb_cli.command(
name="run", name="run",
help="启动 FastapiAdmin 服务, 运行 uv run main.py run --env=dev 不加参数默认 dev 环境", help="启动 DPB 桃育种系统服务, 运行 uv run main.py run --env=dev 不加参数默认 dev 环境",
) )
def run( def run(
env: Annotated[EnvironmentEnum, typer.Option("--env", help="运行环境 (dev, prod)")] = EnvironmentEnum.DEV, env: Annotated[EnvironmentEnum, typer.Option("--env", help="运行环境 (dev, prod)")] = EnvironmentEnum.DEV,
@@ -83,7 +83,7 @@ def run(
) )
@fastapiadmin_cli.command( @dpb_cli.command(
name="revision", name="revision",
help="生成新的 Alembic 迁移脚本, 运行 python main.py revision --env=dev", help="生成新的 Alembic 迁移脚本, 运行 python main.py revision --env=dev",
) )
@@ -106,7 +106,7 @@ def revision(
typer.echo("迁移脚本已生成") typer.echo("迁移脚本已生成")
@fastapiadmin_cli.command( @dpb_cli.command(
name="upgrade", name="upgrade",
help="应用最新的 Alembic 迁移, 运行 python main.py upgrade --env=dev", help="应用最新的 Alembic 迁移, 运行 python main.py upgrade --env=dev",
) )
@@ -130,4 +130,4 @@ def upgrade(
if __name__ == "__main__": if __name__ == "__main__":
fastapiadmin_cli() dpb_cli()
+2 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "backend" name = "backend"
version = "2.0.0" version = "2.0.0"
description = "fastapiadmin后端工程" description = "DPB 桃育种系统后端"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -33,7 +33,7 @@ dependencies = [
"uvicorn==0.49.0", # uvicorn web 框架 "uvicorn==0.49.0", # uvicorn web 框架
"websockets>=16.0,<17.0", # websocket 通信 "websockets>=16.0,<17.0", # websocket 通信
"asyncpg==0.31.0", # PostgreSQL 异步驱动 "asyncpg==0.31.0", # PostgreSQL 异步驱动
"psycopg[binary]==3.3.2", # PostgreSQL 同步驱动(含预编译 libpq "psycopg[binary]==3.3.2", # PostgreSQL 同步驱动:仅 APScheduler SQLAlchemyJobStore(同步库接口)使用;业务层统一 asyncpg,不得再引入同步引擎
"openai==2.46.0", "openai==2.46.0",
"pydantic[email]>=2.12.5", "pydantic[email]>=2.12.5",
] ]
+1 -1
View File
@@ -2,7 +2,7 @@
-- 桃育种系统 - 亲本资源表 (bre_germplasm) -- 桃育种系统 - 亲本资源表 (bre_germplasm)
-- ------------------------------------------------------------ -- ------------------------------------------------------------
-- 依据: doc/桃育种系统业务功能规划报告.md (2026-07-28) 模块1 -- 依据: doc/桃育种系统业务功能规划报告.md (2026-07-28) 模块1
-- 对齐: FastapiAdmin 框架审计字段约定 (app/core/base_model.py) -- 对齐: DPB 框架审计字段约定 (app/core/base_model.py)
-- id / uuid / is_deleted / created_time / updated_time / -- id / uuid / is_deleted / created_time / updated_time /
-- deleted_time / created_id / updated_id / deleted_id -- deleted_time / created_id / updated_id / deleted_id
-- 用途: 建表后由「代码生成器」导入,一键生成前后端 CRUD -- 用途: 建表后由「代码生成器」导入,一键生成前后端 CRUD
+2 -2
View File
@@ -50,7 +50,7 @@
{ {
"config_name": "版权信息", "config_name": "版权信息",
"config_key": "copyright", "config_key": "copyright",
"config_value": "Copyright © 2025-2027 service.fastapiadmin.com 版权所有", "config_value": "Copyright © 2026 DPB 桃育种系统 版权所有",
"config_type": true, "config_type": true,
"status": 0, "status": 0,
"description": "页面底部版权信息" "description": "页面底部版权信息"
@@ -106,7 +106,7 @@
{ {
"config_name": "系统名称", "config_name": "系统名称",
"config_key": "sys_name", "config_key": "sys_name",
"config_value": "FastapiAdmin系统", "config_value": "DPB桃育种系统",
"config_type": true, "config_type": true,
"status": 0, "status": 0,
"description": "平台系统名称,用于登录页等界面展示" "description": "平台系统名称,用于登录页等界面展示"
+85 -132
View File
@@ -1,187 +1,138 @@
"""conftest — 模块化 API 接口测试共享 fixture。 """conftest — 模块化 API 接口测试共享 fixture(真实 PostgreSQL + Redis
A2 架构治理:冒烟层不再用 SQLite + mock Redis,改为连真实 PostgreSQL16(专用
测试库 dpb_testnumber_gen 的 pg_advisory_xact_lock 路径真被执行,不再静默降级)
+ 真实 Redis,与统计引擎 tc 套件同一套基础设施(可被 TC_CI_* 环境变量覆盖,供 CI
容器指定宿主)。
提供: 提供:
- test_client: FastAPI TestClient 实例 (session 级复用) - test_client: FastAPI TestClient 实例 (session 级复用)
- assert_route: 验证接口路由存在 (status_code != 404) - assert_route: 验证接口路由存在 (status_code != 404)
注意: 本 conftest 强制 DATABASE_TYPE=sqlite + mock Redis,仅作用于 backend/tests/ 目录。
backend/scripts/ 下的 test_bre_analysis_*_tc.py 是独立进程的真实 PG16+Redis 套件 backend/scripts/ 下的 test_bre_analysis_*_tc.py 是独立进程的真实 PG16+Redis 套件
(由 tests/test_stat_analysis_tc.py 以 subprocess 调用),此处用 collect_ignore_glob (由 tests/test_stat_analysis_tc.py 以 subprocess 调用),此处用 collect_ignore_glob
排除,避免 pytest 收集时 import 它们污染 sqlite 环境 排除,避免 pytest 收集时 import 它们。
""" """
import os import os
collect_ignore_glob = ["../scripts/test_*.py"] collect_ignore_glob = ["../scripts/test_*.py"]
import asyncio
import sys import sys
import tempfile
from collections.abc import AsyncGenerator, Generator from collections.abc import AsyncGenerator, Generator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
# ============================================================
# 测试环境变量 —— 必须在 import app 之前设置(引擎在 app.core.database
# 模块级按 settings 构建,settings 读 ENVIRONMENT 对应的 .env.dev + os.environ
# ============================================================
os.environ.setdefault("ENVIRONMENT", "dev")
os.environ["DATABASE_TYPE"] = "postgres"
os.environ["DATABASE_NAME"] = os.environ.get("TC_CI_DB_NAME", "dpb_test")
os.environ["DATABASE_HOST"] = os.environ.get("TC_CI_DB_HOST", "localhost")
os.environ["DATABASE_PORT"] = os.environ.get("TC_CI_DB_PORT", "5432")
os.environ["DATABASE_USER"] = os.environ.get("TC_CI_DB_USER", "dpb")
os.environ["DATABASE_PASSWORD"] = os.environ.get("TC_CI_DB_PASSWORD", "dpb")
for ci_key, app_key in {
"TC_CI_REDIS_HOST": "REDIS_HOST",
"TC_CI_REDIS_PORT": "REDIS_PORT",
"TC_CI_REDIS_DB": "REDIS_DB_NAME",
}.items():
if os.environ.get(ci_key):
os.environ[app_key] = os.environ[ci_key]
os.environ["CAPTCHA_ENABLE"] = "False" # 测试环境关闭验证码
def _ensure_test_db() -> None:
"""确保测试库存在(连接维护库 postgres 按需 CREATE DATABASE,已存在则跳过)。
测试库默认 dpb_test,与开发库 dpb 隔离;TC_CI_DB_NAME 可指向容器库。
"""
dbname = os.environ["DATABASE_NAME"]
if dbname in {"postgres", "template0", "template1"}:
return
import asyncpg
host = os.environ["DATABASE_HOST"]
port = int(os.environ["DATABASE_PORT"])
user = os.environ["DATABASE_USER"]
password = os.environ.get("DATABASE_PASSWORD") or None
async def _go() -> None:
admin = await asyncpg.connect(
host=host, port=port, user=user, password=password, database="postgres", timeout=5
)
try:
exists = await admin.fetchval("SELECT 1 FROM pg_database WHERE datname = $1", dbname)
if not exists:
await admin.execute(f'CREATE DATABASE "{dbname}"')
finally:
await admin.close()
asyncio.run(_go())
_ensure_test_db()
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
# ============================================================
# 测试环境变量
# ============================================================
_TEST_DB_PATH = tempfile.NamedTemporaryFile(suffix=".db", delete=False).name
os.environ["DATABASE_TYPE"] = "sqlite"
os.environ["DATABASE_NAME"] = _TEST_DB_PATH
os.environ["POOL_SIZE"] = "1"
os.environ["MAX_OVERFLOW"] = "1"
from app.config.setting import settings from app.config.setting import settings
settings.DATABASE_TYPE = "sqlite"
settings.DATABASE_NAME = _TEST_DB_PATH
settings.POOL_SIZE = 1
settings.MAX_OVERFLOW = 1
settings.CAPTCHA_ENABLE = False # 测试环境关闭验证码 settings.CAPTCHA_ENABLE = False # 测试环境关闭验证码
# ============================================================ # ============================================================
# Mock Redis — dict 存储,支持 get/set/delete/exists/keys/ttl/expire # 跨事件循环安全 —— TestClient 的 lifespan 跑在 portal 线程 loop,而 pytest-asyncio
# 登录成功后写入的 session 数据可在后续请求中正确读取 # 的 async 测试跑在函数级新 loopasyncpg 连接 loop 绑定,复用池化连接会
# RuntimeError。改用 NullPool:每个 session 在当前 loop 新开连接、同 loop 关闭,
# 永不跨 loop 复用(冒烟层 session 级仅一处 async 直连用例,代价可忽略)。
# ============================================================ # ============================================================
_mock_redis_store: dict[bytes, bytes] = {} from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
import app.core.database as _database
def _redis_get(name: bytes) -> bytes | None: _test_engine = create_async_engine(settings.ASYNC_DB_URI, poolclass=NullPool)
return _mock_redis_store.get(name) _database.async_engine = _test_engine
_database.async_db_session = async_sessionmaker(
bind=_test_engine, class_=AsyncSession, expire_on_commit=settings.EXPIRE_ON_COMMIT
)
# ============================================================
# 调度器 mock —— 冒烟层不启动后台调度线程(真实调度由 tc 套件经真实 lifespan 覆盖)
# ============================================================
async def _redis_set(name: bytes, value: bytes, ex: int | None = None, nx: bool = False) -> bool | None:
if nx and name in _mock_redis_store:
return None
_mock_redis_store[name] = value
return True
async def _redis_delete(*names: bytes) -> int:
count = 0
for n in names:
if _mock_redis_store.pop(n, None) is not None:
count += 1
return count
def _redis_keys(pattern: bytes | None = None) -> list[bytes]:
if pattern == b"*" or pattern is None:
return list(_mock_redis_store.keys())
return [k for k in _mock_redis_store if pattern == b"*" or k.startswith(pattern.replace(b"*", b""))]
def _redis_exists(*names: bytes) -> int:
return sum(1 for n in names if n in _mock_redis_store)
def _redis_ttl(name: bytes) -> int:
return 3600 if name in _mock_redis_store else -2
async def _redis_expire(name: bytes, time: int) -> bool:
return name in _mock_redis_store
async def _redis_flushall(asynchronous: bool = False) -> bool:
_mock_redis_store.clear()
return True
async def _redis_flushdb(asynchronous: bool = False) -> bool:
_mock_redis_store.clear()
return True
async def _redis_close() -> None:
pass
async def _redis_aclose() -> None:
pass
async def _redis_hmget(name: bytes, keys: list[bytes]) -> list[bytes | None]:
return [_mock_redis_store.get(name + b":" + k) for k in keys]
async def _redis_hset(name: bytes, key: bytes, value: bytes) -> int:
_mock_redis_store[name + b":" + key] = value
return 1
async def _redis_hgetall(name: bytes) -> dict[bytes, bytes]:
prefix = name + b":"
return {k[len(prefix) :]: v for k, v in _mock_redis_store.items() if k.startswith(prefix)}
async def _redis_hdel(name: bytes, *keys: bytes) -> int:
count = 0
for k in keys:
if _mock_redis_store.pop(name + b":" + k, None) is not None:
count += 1
return count
def _redis_info(section: str | None = None) -> dict:
return {}
def _redis_dbsize() -> int:
return len(_mock_redis_store)
_mock_redis = AsyncMock()
_mock_redis.ping = AsyncMock(return_value=True)
_mock_redis.get = AsyncMock(side_effect=_redis_get)
_mock_redis.set = AsyncMock(side_effect=_redis_set)
_mock_redis.delete = AsyncMock(side_effect=_redis_delete)
_mock_redis.keys = AsyncMock(side_effect=_redis_keys)
_mock_redis.exists = AsyncMock(side_effect=_redis_exists)
_mock_redis.ttl = AsyncMock(side_effect=_redis_ttl)
_mock_redis.expire = AsyncMock(side_effect=_redis_expire)
_mock_redis.flushall = AsyncMock(side_effect=_redis_flushall)
_mock_redis.flushdb = AsyncMock(side_effect=_redis_flushdb)
_mock_redis.close = AsyncMock(side_effect=_redis_close)
_mock_redis.aclose = AsyncMock(side_effect=_redis_aclose)
_mock_redis.hmget = AsyncMock(side_effect=_redis_hmget)
_mock_redis.hset = AsyncMock(side_effect=_redis_hset)
_mock_redis.hgetall = AsyncMock(side_effect=_redis_hgetall)
_mock_redis.hdel = AsyncMock(side_effect=_redis_hdel)
_mock_redis.info = AsyncMock(side_effect=_redis_info)
_mock_redis.dbsize = AsyncMock(side_effect=_redis_dbsize)
patch("redis.asyncio.Redis.from_url", return_value=_mock_redis).start()
# slowapi 限流器由中间件处理,测试中无需 mock
patch("app.core.ap_scheduler.SchedulerUtil.init_scheduler", new=AsyncMock()).start() patch("app.core.ap_scheduler.SchedulerUtil.init_scheduler", new=AsyncMock()).start()
patch("app.core.ap_scheduler.SchedulerUtil.shutdown", new=AsyncMock()).start() patch("app.core.ap_scheduler.SchedulerUtil.shutdown", new=AsyncMock()).start()
# slowapi 通过中间件限流,测试无需 mock
# ============================================================ # ============================================================
# 精简 lifespan — 仅做数据库初始化 # 精简 lifespan — 真实 PG + Redis,仅跳过调度器
# ============================================================ # ============================================================
@asynccontextmanager @asynccontextmanager
async def _test_lifespan(app) -> AsyncGenerator[Any, None]: async def _test_lifespan(app) -> AsyncGenerator[Any, None]:
from app.api.v1.module_system.dict.service import DictDataService
from app.api.v1.module_system.params.service import ParamsService
from app.scripts.initialize import InitializeData from app.scripts.initialize import InitializeData
from app.core.database import async_db_session, redis_connect
await InitializeData().init_db() await InitializeData().init_db()
app.state.redis = _mock_redis await redis_connect(app, status=True)
await ParamsService.init_cache(redis=app.state.redis)
await DictDataService.init_cache(redis=app.state.redis)
# 将 admin 密码重置为已知密码 "admin123" # 将 admin 密码重置为已知密码 "admin123"
from sqlalchemy import update from sqlalchemy import update
from app.api.v1.module_system.user.model import UserModel from app.api.v1.module_system.user.model import UserModel
from app.core.database import async_db_session
from app.utils.password_util import PwdUtil from app.utils.password_util import PwdUtil
async with async_db_session() as db: async with async_db_session() as db:
@@ -190,6 +141,8 @@ async def _test_lifespan(app) -> AsyncGenerator[Any, None]:
yield yield
await redis_connect(app, status=False)
from main import create_app from main import create_app
+2 -2
View File
@@ -2,11 +2,11 @@
set -euo pipefail set -euo pipefail
# ==================== 配置 ==================== # ==================== 配置 ====================
PROJECT_NAME="FastapiAdmin" PROJECT_NAME="dpb"
WORK_DIR="$(cd "$(dirname "$0")" && pwd)" WORK_DIR="$(cd "$(dirname "$0")" && pwd)"
DOCKER_DIR="${WORK_DIR}/docker" DOCKER_DIR="${WORK_DIR}/docker"
ENV_FILE="${DOCKER_DIR}/.env" ENV_FILE="${DOCKER_DIR}/.env"
GIT_REPO="https://gitee.com/fastapiadmin/${PROJECT_NAME}.git" GIT_REPO="http://123.207.9.209:3000/gonsun/dpb.git"
COLOR_GREEN='\033[0;32m'; COLOR_BLUE='\033[0;34m'; COLOR_YELLOW='\033[0;33m'; COLOR_RED='\033[0;31m'; COLOR_RESET='\033[0m' COLOR_GREEN='\033[0;32m'; COLOR_BLUE='\033[0;34m'; COLOR_YELLOW='\033[0;33m'; COLOR_RED='\033[0;31m'; COLOR_RESET='\033[0m'
+1 -1
View File
@@ -54,4 +54,4 @@ HTTPS_PORT=443
# ── 部署 ── # ── 部署 ──
DEPLOY_ENV=prod DEPLOY_ENV=prod
PROJECT_NAME=FastapiAdmin PROJECT_NAME=dpb
+1 -1
View File
@@ -1,5 +1,5 @@
# ============================================ # ============================================
# FastapiAdmin Docker Compose 配置文件 # DPB Docker Compose 配置文件
# ============================================ # ============================================
# 使用方式: # 使用方式:
# 开发环境: docker compose --env-file .env up -d # 开发环境: docker compose --env-file .env up -d
+1 -1
View File
@@ -1,5 +1,5 @@
# ============================================ # ============================================
# FastapiAdmin Nginx 配置文件 # DPB Nginx 配置文件
# ============================================ # ============================================
# 自动检测 CPU 核心数,多核服务器自动利用所有核心 # 自动检测 CPU 核心数,多核服务器自动利用所有核心
+56
View File
@@ -0,0 +1,56 @@
# ============================================
# DPB 测试依赖 compose —— 供 CI / 本地验收起 PostgreSQL16 + Redis
# ============================================
# 用途:架构治理 A2「测试真 PG 化」。冒烟层(backend/tests)与统计引擎 tc 套件
# 都基于真实 PG + Redis;本栈在隔离端口起容器,避免与宿主机常驻服务(本地 PG
# 5432 / WSL redis 6379)冲突。
#
# 使用方式(在 docker/ 目录):
# docker compose -f test-compose.yaml up -d
# # 后端测试连接默认走 TC_CI_* 覆盖(端口与本栈一致):
# # backend/testsDATABASE_NAME=dpb_testconftest 自动建库)
# # TC_CI_DB_PORT=5433 TC_CI_REDIS_PORT=6380
# # TC_CI_DB_NAME=dpb_test TC_CI_DB_USER=dpb TC_CI_DB_PASSWORD=dpb
# docker compose -f test-compose.yaml down
#
# 无持久化卷:每次 up 都是全新实例,测试库从 init 脚本重建,天然隔离。
# ============================================
services:
postgres:
container_name: dpb-test-postgres
image: postgres:16
restart: "no"
environment:
TZ: "Asia/Shanghai"
POSTGRES_DB: "dpb"
POSTGRES_USER: "${TEST_DB_USER:-dpb}"
POSTGRES_PASSWORD: "${TEST_DB_PASSWORD:-dpb}"
# 仅绑定本机回环;宿主常驻 PG 已占 5432,故测试栈用 5433
ports:
- "127.0.0.1:${TEST_DB_PORT:-5433}:5432"
# 首次初始化时创建测试库 dpb_testdocker-entrypoint 会执行 initdb.d 下脚本)
volumes:
- ./test-initdb/01-create-test-db.sql:/docker-entrypoint-initdb.d/01-create-test-db.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${TEST_DB_USER:-dpb} -d dpb"]
interval: 5s
timeout: 5s
retries: 12
start_period: 15s
redis:
container_name: dpb-test-redis
image: redis:7-alpine
restart: "no"
environment:
TZ: "Asia/Shanghai"
# 宿主/WSL 常驻 redis 已占 6379,故测试栈用 6380;无密码(与 dev .env 一致)
ports:
- "127.0.0.1:${TEST_REDIS_PORT:-6380}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
+3
View File
@@ -0,0 +1,3 @@
-- 测试库:冒烟层(backend/tests)专用,与开发库 dpb 隔离
-- postgres:16 镜像首次初始化时执行本脚本(/docker-entrypoint-initdb.d/
CREATE DATABASE dpb_test;
+1 -1
View File
@@ -4,7 +4,7 @@
VITE_APP_ENV = dev VITE_APP_ENV = dev
# 项目名称 # 项目名称
VITE_APP_TITLE = FastapiAdmin VITE_APP_TITLE = 数字化桃育种系统
# 浏览器侧同源前缀(与 Vite 代理配合时用 /,请求走 localhost:端口 + 代理) # 浏览器侧同源前缀(与 Vite 代理配合时用 /,请求走 localhost:端口 + 代理)
VITE_API_URL = / VITE_API_URL = /
+3 -3
View File
@@ -1,6 +1,6 @@
# FastAPI Admin · 前端工程(web # DPB 桃育种系统 · 前端工程(web
基于 **Vue 3 + Vite + TypeScript + Element Plus**后台管理前端,与 FastAPI Admin 后端配套使用。状态管理为 **Pinia**,样式以 **Tailwind CSS 4****SCSS** 为主,接口请求使用 **Axios** 基于 **Vue 3 + Vite + TypeScript + Element Plus**桃育种管理前端,与 DPB 桃育种系统后端配套使用。状态管理为 **Pinia**,样式以 **Tailwind CSS 4****SCSS** 为主,接口请求使用 **Axios**
> **与仓库根文档的关系**:项目总览、一键前后端启动、演示账号、Docker 部署等请以 [根目录 README.md](../../README.md) 为准;**本文档**侧重 `frontend/web/` 目录结构、环境变量与前端开发约定。 > **与仓库根文档的关系**:项目总览、一键前后端启动、演示账号、Docker 部署等请以 [根目录 README.md](../../README.md) 为准;**本文档**侧重 `frontend/web/` 目录结构、环境变量与前端开发约定。
@@ -27,7 +27,7 @@ pnpm dev
### 与后端联调 ### 与后端联调
1. 先启动 **FastAPI Admin 后端**,监听地址与 **`.env.dev`** 里 **`VITE_API_BASE_URL`** 一致(模板默认为 **`http://127.0.0.1:8001`**)。 1. 先启动 **DPB 桃育种系统后端**,监听地址与 **`.env.dev`** 里 **`VITE_API_BASE_URL`** 一致(模板默认为 **`http://127.0.0.1:8001`**)。
2. 前端开发时,浏览器请求发往当前页面同源路径,由 **Vite `server.proxy`****`VITE_APP_BASE_API`**(如 `/api/v1`)转发到上述后端。 2. 前端开发时,浏览器请求发往当前页面同源路径,由 **Vite `server.proxy`****`VITE_APP_BASE_API`**(如 `/api/v1`)转发到上述后端。
3. 若页面提示「连接被拒绝」,检查后端是否启动、端口是否一致,或把 **`VITE_API_BASE_URL`** 改成你的实际后端地址。 3. 若页面提示「连接被拒绝」,检查后端是否启动、端口是否一致,或把 **`VITE_API_BASE_URL`** 改成你的实际后端地址。
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "fastapiadmin", "name": "dpb-web",
"description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板", "description": "DPB 桃育种系统前端(Vue3 + Vite + TypeScript + Element-Plus",
"version": "3.0.0", "version": "3.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -190,14 +190,14 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://gitee.com/fastapiadmin/FastapiAdmin.git" "url": "http://123.207.9.209:3000/gonsun/dpb.git"
}, },
"bugs": { "bugs": {
"url": "https://gitee.com/fastapiadmin/FastapiAdmin/issues" "url": "http://123.207.9.209:3000/gonsun/dpb/issues"
}, },
"author": "fastapiadmin <948080782@qq.com>", "author": "DPB 桃育种系统团队",
"license": "MIT", "license": "MIT",
"homepage": "https://gitee.com/fastapiadmin/FastapiAdmin", "homepage": "http://123.207.9.209:3000/gonsun/dpb",
"browserslist": [ "browserslist": [
"Chrome >= 84", "Chrome >= 84",
"Firefox >= 83", "Firefox >= 83",
+1 -1
View File
@@ -11,7 +11,7 @@
* - menuStyles: 菜单风格预览图(设计/暗色/亮色) * - menuStyles: 菜单风格预览图(设计/暗色/亮色)
* *
* @module config/assets/images * @module config/assets/images
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import lightTheme from "@imgs/settings/theme_styles/light.png"; import lightTheme from "@imgs/settings/theme_styles/light.png";
+1 -1
View File
@@ -26,7 +26,7 @@
* - headerBar: 顶部栏功能配置 * - headerBar: 顶部栏功能配置
* *
* @module config * @module config
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { configImages } from "./assets/images"; import { configImages } from "./assets/images";
+1 -1
View File
@@ -12,7 +12,7 @@
* - 配置查询 - 提供工具函数快速查询组件配置 * - 配置查询 - 提供工具函数快速查询组件配置
* *
* @module config/component * @module config/component
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { defineAsyncComponent } from "vue"; import { defineAsyncComponent } from "vue";
+2 -2
View File
@@ -43,7 +43,7 @@
* - 每个用户每个自然日「自动礼花」流程只会完整播放一次(由 setting store 按自然日记录)。 * - 每个用户每个自然日「自动礼花」流程只会完整播放一次(由 setting store 按自然日记录)。
* *
* @module config/modules/festival * @module config/modules/festival
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import type { FestivalConfig } from "@/types/config"; import type { FestivalConfig } from "@/types/config";
@@ -62,7 +62,7 @@ export const festivalConfigList: FestivalConfig[] = [
// image: yd, // image: yd,
// count: 3, // count: 3,
// scrollText: // scrollText:
// "🎉 五月快乐!FastAPI Admin 祝您工作顺利、迭代顺利。本月请关注备份与安全策略,遇到问题可先查看文档或联系运维。", // "🎉 五月快乐!DPB 桃育种系统祝您工作顺利、迭代顺利。本月请关注备份与安全策略,遇到问题可先查看文档或联系运维。",
// }, // },
/** 单日示例(圣诞节):需取消注释并确保已 import 雪花图 */ /** 单日示例(圣诞节):需取消注释并确保已 import 雪花图 */
+1 -1
View File
@@ -5,7 +5,7 @@
* 通过修改此配置文件可以快速启用或禁用顶部栏的功能按钮。 * 通过修改此配置文件可以快速启用或禁用顶部栏的功能按钮。
* *
* @module config/headerBar * @module config/headerBar
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import type { HeaderBarFeatureConfig } from "@/types/config"; import type { HeaderBarFeatureConfig } from "@/types/config";
@@ -38,7 +38,7 @@
* - 智能监听:只在有新代码块时才触发处理 * - 智能监听:只在有新代码块时才触发处理
* *
* @module directives/highlight * @module directives/highlight
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { App, Directive } from "vue"; import { App, Directive } from "vue";
@@ -34,7 +34,7 @@
* - 自定义:通过 color 参数指定任意颜色 * - 自定义:通过 color 参数指定任意颜色
* *
* @module directives/ripple * @module directives/ripple
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import type { App, Directive, DirectiveBinding } from "vue"; import type { App, Directive, DirectiveBinding } from "vue";
+1 -1
View File
@@ -11,7 +11,7 @@
* - 菜单宽度枚举(收起宽度) * - 菜单宽度枚举(收起宽度)
* *
* @module enums/appEnum * @module enums/appEnum
* @author FastapiAdmin Team * @author DPB 团队
*/ */
/** /**
+1 -1
View File
@@ -7,7 +7,7 @@
* - 表格尺寸枚举(默认、紧凑、宽松) * - 表格尺寸枚举(默认、紧凑、宽松)
* *
* @module enums/formEnum * @module enums/formEnum
* @author FastapiAdmin Team * @author DPB 团队
*/ */
// 页面类型 // 页面类型
+1 -1
View File
@@ -13,7 +13,7 @@
* 4. 响应式状态 - 提供响应式的模式判断,方便在组件中使用 * 4. 响应式状态 - 提供响应式的模式判断,方便在组件中使用
* *
* @module useAppMode * @module useAppMode
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { computed } from "vue"; import { computed } from "vue";
+1 -1
View File
@@ -42,7 +42,7 @@
* ``` * ```
* *
* @module useCeremony * @module useCeremony
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { useTimeoutFn, useIntervalFn, useDateFormat } from "@vueuse/core"; import { useTimeoutFn, useIntervalFn, useDateFormat } from "@vueuse/core";
+1 -1
View File
@@ -46,7 +46,7 @@
* ``` * ```
* *
* @module useChart * @module useChart
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { echarts, type EChartsOption } from "@/plugins/echarts"; import { echarts, type EChartsOption } from "@/plugins/echarts";
+1 -1
View File
@@ -12,7 +12,7 @@
* 4. 宽度控制 - 提供最小显示宽度配置,支持响应式布局 * 4. 宽度控制 - 提供最小显示宽度配置,支持响应式布局
* *
* @module useFastEnter * @module useFastEnter
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { computed } from "vue"; import { computed } from "vue";
+1 -1
View File
@@ -12,7 +12,7 @@
* 4. 响应式状态 - 所有状态自动响应配置和 store 变化 * 4. 响应式状态 - 所有状态自动响应配置和 store 变化
* *
* @module useHeaderBar * @module useHeaderBar
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { computed } from "vue"; import { computed } from "vue";
@@ -13,7 +13,7 @@
* 5. 自动查找模式 - 提供通过 ID 自动查找元素的便捷方式 * 5. 自动查找模式 - 提供通过 ID 自动查找元素的便捷方式
* *
* @module useLayoutHeight * @module useLayoutHeight
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { ref, computed, watch, onMounted } from "vue"; import { ref, computed, watch, onMounted } from "vue";
@@ -12,7 +12,7 @@
* 4. 智能适配 - 无额外元素时自动使用 100% 高度 * 4. 智能适配 - 无额外元素时自动使用 100% 高度
* *
* @module useTableHeight * @module useTableHeight
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { computed, type Ref } from "vue"; import { computed, type Ref } from "vue";
+1 -1
View File
@@ -28,7 +28,7 @@
* ``` * ```
* *
* @module useTheme * @module useTheme
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { SystemThemeEnum } from "@/enums/appEnum"; import { SystemThemeEnum } from "@/enums/appEnum";
+1 -1
View File
@@ -18,7 +18,7 @@
* - en: English * - en: English
* *
* @module locales * @module locales
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { LanguageEnum } from "@/enums/appEnum"; import { LanguageEnum } from "@/enums/appEnum";
+1 -1
View File
@@ -5,7 +5,7 @@
* 只注册项目中实际使用的图表类型和组件。 * 只注册项目中实际使用的图表类型和组件。
* *
* @module plugins/echarts * @module plugins/echarts
* @author FastapiAdmin Team * @author DPB 团队
*/ */
// ECharts 按需导入配置 // ECharts 按需导入配置
+1 -1
View File
@@ -21,7 +21,7 @@
* - 新手引导展示 * - 新手引导展示
* *
* @module store/modules/app.store * @module store/modules/app.store
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
@@ -25,7 +25,7 @@
* - 支持强制刷新配置 * - 支持强制刷新配置
* *
* @module store/modules/config.store * @module store/modules/config.store
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { store } from "@stores"; import { store } from "@stores";
import ParamsAPI, { ConfigTable } from "@/api/module_system/params"; import ParamsAPI, { ConfigTable } from "@/api/module_system/params";
+1 -1
View File
@@ -31,7 +31,7 @@
* - 减少重复请求 * - 减少重复请求
* *
* @module store/modules/dict.store * @module store/modules/dict.store
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { store } from "@stores"; import { store } from "@stores";
import DictAPI, { DictDataTable } from "@/api/module_system/dict"; import DictAPI, { DictDataTable } from "@/api/module_system/dict";
+1 -1
View File
@@ -26,7 +26,7 @@
* 4. 登出时调用移除函数清理路由 * 4. 登出时调用移除函数清理路由
* *
* @module store/modules/menu.store * @module store/modules/menu.store
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
@@ -25,7 +25,7 @@
* - 刷新页面后保留已读状态 * - 刷新页面后保留已读状态
* *
* @module store/modules/notice.store * @module store/modules/notice.store
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { store } from "@stores"; import { store } from "@stores";
import NoticeAPI, { NoticeTable } from "@/api/module_system/notice"; import NoticeAPI, { NoticeTable } from "@/api/module_system/notice";
@@ -35,7 +35,7 @@
* - 刷新页面保持标签状态 * - 刷新页面保持标签状态
* *
* @module store/modules/worktab.store * @module store/modules/worktab.store
* @author FastapiAdmin Team * @author DPB 团队
*/ */
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
+1 -1
View File
@@ -3,7 +3,7 @@
* 提供全局类型定义的统一导出入口 * 提供全局类型定义的统一导出入口
* *
* @module types/index * @module types/index
* @author FastapiAdmin Team * @author DPB 团队
*/ */
/** AI 相关类型定义 */ /** AI 相关类型定义 */
@@ -3,15 +3,15 @@
/** 移动端断点(小于此值视为 H5/移动端) */ /** 移动端断点(小于此值视为 H5/移动端) */
export const MOBILE_BREAKPOINT = 768; export const MOBILE_BREAKPOINT = 768;
const DPB_REPO = "http://123.207.9.209:3000/gonsun/dpb";
export const WEB_LINKS = { export const WEB_LINKS = {
GITHUB_HOME: "https://github.com/fastapiadmin", GITHUB_HOME: DPB_REPO,
GITHUB: "https://github.com/fastapiadmin/FastapiAdmin", GITHUB: DPB_REPO,
GITEE: "https://gitee.com/fastapiadmin/FastapiAdmin", GITEE: DPB_REPO,
BLOG: "https://blog.csdn.net/weixin_46768253?type=blog", BLOG: "https://blog.csdn.net/weixin_46768253?type=blog",
DOCS: "https://service.fastapiadmin.com/guide/overview.html", DOCS: DPB_REPO,
LiteVersion: "https://gitee.com/fastapiadmin/FastCloud", COMMUNITY: DPB_REPO,
OldVersion: "https://github.com/fastapiadmin/FastapiAdmin/tree/v2.0.0",
COMMUNITY: "https://service.fastapiadmin.com",
BILIBILI: "https://space.bilibili.com/425500936?spm_id_from=333.1007.0.0", BILIBILI: "https://space.bilibili.com/425500936?spm_id_from=333.1007.0.0",
INTRODUCE: "https://service.fastapiadmin.com", INTRODUCE: DPB_REPO,
}; };
+1 -1
View File
@@ -7,7 +7,7 @@
* - 在线用户计数 WebSocket * - 在线用户计数 WebSocket
* - 其他业务 WebSocket * - 其他业务 WebSocket
* *
* @author fastapiadmin * @author DPB 团队
*/ */
import { Auth } from "@utils/auth"; import { Auth } from "@utils/auth";
+1 -1
View File
@@ -10,7 +10,7 @@ import { Auth, StorageConfig } from "@utils";
import { BANNER } from "../../../build/banner"; import { BANNER } from "../../../build/banner";
// ----------------------------- // -----------------------------
// Console bannerANSI 转义码生成网站 https://patorjk.com/software/taag/#p=testall&f=Fire+Font-k&t=fastapiadmin%0A&x=none&v=4&h=4&w=80&we=false // Console bannerANSI 转义码生成网站 https://patorjk.com/software/taag/#p=testall&f=Fire+Font-k&t=dpb&x=none&v=4&h=4&w=80&we=false
// ----------------------------- // -----------------------------
export function printConsoleBanner(): void { export function printConsoleBanner(): void {
@@ -159,7 +159,7 @@
<FaCardBanner <FaCardBanner
:image="bannerIcon4" :image="bannerIcon4"
title="版本更新提醒" title="版本更新提醒"
description="FastapiAdmin v3.0.0 已发布,包含优化和新功能。" description="DPB 桃育种系统 v3.0.0 已发布,包含优化和新功能。"
:button="{ :button="{
show: true, show: true,
text: '立即更新', text: '立即更新',
@@ -6,7 +6,7 @@
</div> </div>
<h1 class="screen-title"> <h1 class="screen-title">
<span class="deco-line" /> <span class="deco-line" />
FastapiAdmin · 智能运营数据监平台 DPB 桃育种系统 · 育种数据监平台
<span class="deco-line" /> <span class="deco-line" />
</h1> </h1>
<div class="header-right"> <div class="header-right">
@@ -92,7 +92,7 @@
<div class="manual-html" @click.capture="handleAnchorClick"> <div class="manual-html" @click.capture="handleAnchorClick">
<div class="manual-html__inner"> <div class="manual-html__inner">
<h1> <h1>
FastapiAdmin 功能点清单 DPB 桃育种系统 功能点清单
<small>用于全功能测试验收按模块逐页列出所有可操作元素</small> <small>用于全功能测试验收按模块逐页列出所有可操作元素</small>
</h1> </h1>
@@ -802,7 +802,7 @@
class="manual-footer-note mt-10 border-t border-g-200 pt-6 text-center text-xs text-g-500 dark:border-g-700 dark:text-g-400" class="manual-footer-note mt-10 border-t border-g-200 pt-6 text-center text-xs text-g-500 dark:border-g-700 dark:text-g-400"
> >
<p> <p>
FastapiAdmin 功能点清单完整性 DPB 桃育种系统 功能点清单完整性
与当前代码中已实现界面项对齐用于逐项核对是否漏测不评价体验优劣 与当前代码中已实现界面项对齐用于逐项核对是否漏测不评价体验优劣
</p> </p>
</div> </div>
@@ -36,7 +36,7 @@ interface Emits {
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const promptCards = [ const promptCards = [
{ title: "系统介绍", body: "请介绍一下FastApiAdmin系统", prompt: "请介绍一下FastApiAdmin系统" }, { title: "系统介绍", body: "请介绍一下DPB桃育种系统", prompt: "请介绍一下DPB桃育种系统" },
{ title: "开发指导", body: "如何在系统中创建新的模块?", prompt: "如何在系统中创建新的模块?" }, { title: "开发指导", body: "如何在系统中创建新的模块?", prompt: "如何在系统中创建新的模块?" },
{ {
title: "权限管理", title: "权限管理",
@@ -488,10 +488,9 @@ let notificationInstance: ReturnType<typeof msg.notify> | null = null;
const showVoteNotification = () => { const showVoteNotification = () => {
notificationInstance = msg.notify({ notificationInstance = msg.notify({
title: "⭐ FastapiAdmin 完全开源 · 期待您的 Star 支持 🙏", title: "⭐ DPB 桃育种系统 · 功能持续迭代中 🙏",
message: `项目持续迭代中,若对您有所帮助,欢迎点亮 Star 支持 message: `项目持续迭代中,若有问题或建议欢迎反馈
<br/><a href="https://github.com/fastapiadmin/FastapiAdmin" target="_blank" style="color: var(--el-color-primary); text-decoration: none; font-weight: 500;">Github仓库 →</a> <br/><a href="http://123.207.9.209:3000/gonsun/dpb" target="_blank" style="color: var(--el-color-primary); text-decoration: none; font-weight: 500;">项目仓库 →</a>`,
<br/><a href="https://gitee.com/fastapiadmin/FastapiAdmin" target="_blank" style="color: var(--el-color-warning); text-decoration: none; font-weight: 500;">Gitee仓库 →</a>`,
type: "success", type: "success",
position: position:
panelAlign.value === "right" || panelAlign.value === "center" panelAlign.value === "right" || panelAlign.value === "center"
+55 -6
View File
@@ -1,19 +1,22 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""本机 CI stage:验收门 = 真实 PostgreSQL16 + Redis 就绪 → `pytest -m pg` 全绿。 """本机 CI stage:验收门 = 真实 PostgreSQL16 + Redis 就绪 → `pytest -m pg` 全绿。
背景:工程 git 仓库(root/backend/frontend 均无 .git),GitHub Actions 是云端服务, 背景:工程已纳入 gitGitea gonsun/dpb2026-08-06 起);CI stage 落地为本机驱动脚本。
本机用不上;CI stage 落地为本机驱动脚本。v8 验收标准「CI 跑通 pytest 全绿」= 本脚本 EXIT=0。 v8 验收标准「CI 跑通 pytest 全绿」= 本脚本 EXIT=0。
step 0 探测本机真实 PG16backend/env/.env.dev 的 DATABASE_*,可用 TC_CI_DB_* 覆盖)+ Redis step 0 探测真实 PG16backend/env/.env.dev 的 DATABASE_*,可用 TC_CI_DB_* 覆盖)+ Redis
REDIS_*)连通;任一失败给出明确指引并 exit 非 0。 REDIS_*)连通。本机 PG/Redis 未就绪且 docker 可用时,自动
step 1 backend 目录下 `pytest -m pg`dpb python + -X utf8)跑 9 套统计引擎正式 tc 套件 `docker compose -f docker/test-compose.yaml up -d` 拉起测试依赖栈
postgres:16:5433 + redis:7:6380,预建 dpb_test)并注入 TC_CI_* 覆盖后重探。
仍失败给出明确指引并 exit 非 0。
step 1 backend 目录下 `pytest -m pg`dpb python + -X utf8)跑 10 套统计引擎正式 tc 套件
backend/tests/test_stat_analysis_tc.py 包装层,subprocess 调真实脚本)。 backend/tests/test_stat_analysis_tc.py 包装层,subprocess 调真实脚本)。
step 2 汇总 EXIT=0 → 「CI 全绿」;日志写 Temp/claude/ci_logs/。 step 2 汇总 EXIT=0 → 「CI 全绿」;日志写 Temp/claude/ci_logs/。
说明: 说明:
- 全程真实 PG16 + Redis(本工程无 SQLite);PG/Redis 缺失时 step 0 拦截,不会误绿。 - 全程真实 PG16 + Redis(本工程无 SQLite);PG/Redis 缺失时 step 0 拦截,不会误绿。
- 与 run_regression.py 并存:run_regression = 手动全量回归(e2e 探针 + tc 套件); - 与 run_regression.py 并存:run_regression = 手动全量回归(e2e 探针 + tc 套件);
run_ci = 验收门(只跑 pytest -m pg 的 9 套正式 tc)。 run_ci = 验收门(只跑 pytest -m pg 的 10 套正式 tc)。
- 解释器默认 sys.executable(须用 dpb env python 运行);可用 DPB_PYTHON 覆盖。 - 解释器默认 sys.executable(须用 dpb env python 运行);可用 DPB_PYTHON 覆盖。
""" """
import asyncio import asyncio
@@ -60,6 +63,28 @@ def _env_cfg() -> dict:
} }
def _maybe_start_compose() -> bool:
"""本机 PG/Redis 未就绪时,用 docker compose 拉起测试依赖栈(postgres:16 + redis)。
测试栈端口与宿主机常驻服务错开(5433/6380),预建测试库 dpb_test。
返回是否成功拉起(docker 缺失/失败返回 False,交由 step 0 给出指引)。
"""
import shutil
if shutil.which("docker") is None:
return False
compose = os.path.join(ROOT, "docker", "test-compose.yaml")
try:
subprocess.run(
["docker", "compose", "-f", compose, "up", "-d"],
cwd=ROOT, capture_output=True, timeout=120, check=True,
)
return True
except Exception as exc: # noqa: BLE001
print(f" [warn] docker compose 拉起测试依赖失败: {str(exc)[:120]}")
return False
async def _probe(cfg: dict) -> tuple[bool, bool, str, str]: async def _probe(cfg: dict) -> tuple[bool, bool, str, str]:
import asyncpg import asyncpg
import redis.asyncio as aioredis import redis.asyncio as aioredis
@@ -100,11 +125,35 @@ def main() -> int:
f"{'OK' if db_ok else 'FAIL ' + db_err}") f"{'OK' if db_ok else 'FAIL ' + db_err}")
print(f"[step 0] 探测 Redis {cfg['REDIS_HOST']}:{cfg['REDIS_PORT']}/{cfg['REDIS_DB_NAME']}" print(f"[step 0] 探测 Redis {cfg['REDIS_HOST']}:{cfg['REDIS_PORT']}/{cfg['REDIS_DB_NAME']}"
f"{'OK' if redis_ok else 'FAIL ' + redis_err}") f"{'OK' if redis_ok else 'FAIL ' + redis_err}")
if not (db_ok and redis_ok):
print("\n[step 0] 本机 PG/Redis 未就绪,尝试 docker compose 拉起测试依赖栈…")
if _maybe_start_compose():
# 测试栈端口与常驻服务错开(5433/6380),预建 dpb_test;注入 TC_CI_* 覆盖
os.environ.update({
"TC_CI_DB_HOST": "127.0.0.1", "TC_CI_DB_PORT": "5433",
"TC_CI_DB_USER": "dpb", "TC_CI_DB_PASSWORD": "dpb",
"TC_CI_DB_NAME": "dpb_test",
"TC_CI_REDIS_HOST": "127.0.0.1", "TC_CI_REDIS_PORT": "6380",
"TC_CI_REDIS_DB": "1",
})
cfg = _env_cfg()
# postgres:16 首次初始化可能需数十秒,轮询等健康
for _ in range(15):
db_ok, redis_ok, db_err, redis_err = asyncio.run(_probe(cfg))
if db_ok and redis_ok:
break
import time
time.sleep(5)
print(f"[step 0] 容器 PG {cfg['DATABASE_HOST']}:{cfg['DATABASE_PORT']}/{cfg['DATABASE_NAME']}"
f"{'OK' if db_ok else 'FAIL ' + db_err}")
print(f"[step 0] 容器 Redis {cfg['REDIS_HOST']}:{cfg['REDIS_PORT']}/{cfg['REDIS_DB_NAME']}"
f"{'OK' if redis_ok else 'FAIL ' + redis_err}")
if not (db_ok and redis_ok): if not (db_ok and redis_ok):
print("\n❌ 基础设施未就绪,CI 无法放行。请检查:") print("\n❌ 基础设施未就绪,CI 无法放行。请检查:")
print(" · 本机 PostgreSQL 16 服务是否启动(服务名 postgresql-x64-16,端口 5432") print(" · 本机 PostgreSQL 16 服务是否启动(服务名 postgresql-x64-16,端口 5432")
print(" · Redis 是否启动(redis-server,端口 6379;工程 WSL 内运行则确认 WSL 已起)") print(" · Redis 是否启动(redis-server,端口 6379;工程 WSL 内运行则确认 WSL 已起)")
print(f" · 连接配置 backend/env/.env.dev 的 DATABASE_*/REDIS_* 是否与本地一致") print(f" · 连接配置 backend/env/.env.dev 的 DATABASE_*/REDIS_* 是否与本地一致")
print(" · 或安装 docker 后由本脚本自动拉起 docker/test-compose.yaml 测试依赖栈")
print(" · 可用 TC_CI_DB_HOST / TC_CI_DB_PORT 等环境变量临时覆盖探测地址") print(" · 可用 TC_CI_DB_HOST / TC_CI_DB_PORT 等环境变量临时覆盖探测地址")
return 1 return 1