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
-1
@@ -1,5 +1,5 @@
|
||||
# ============================================
|
||||
# FastapiAdmin .dockerignore
|
||||
# DPB .dockerignore
|
||||
# 用于优化 Docker 构建上下文,提升构建速度
|
||||
# ============================================
|
||||
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# FastApiAdmin - Backend
|
||||
# DPB 桃育种系统后端
|
||||
|
||||
基于 FastAPI 框架构建的企业级后端架构,为前端 Vue3 管理系统提供完整的 API 服务支持。
|
||||
基于 FastAPI 框架自研的桃树育种管理系统后端,为前端 Vue3 管理界面提供完整的 API 服务支持。
|
||||
|
||||
> **与仓库根文档的关系**:项目总览、一键前后端启动、演示账号、Docker 部署、架构图与默认端口等请以 [根目录 README.md](../README.md) 为准;**本文档**侧重 `backend/` 目录结构、迁移命令与后端开发约定。
|
||||
|
||||
@@ -60,14 +60,14 @@ module_*/
|
||||
└── 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+
|
||||
- **数据库**: PostgreSQL 16(连接串在 `env/.env.dev`;测试套件使用 SQLite)
|
||||
- **数据库**: PostgreSQL 16(连接串在 `env/.env.dev`;测试套件连独立测试库 `dpb_test`,同为真实 PostgreSQL16,见 `run_ci.py`)
|
||||
- **Redis**: 与 `.env.dev` 中配置一致
|
||||
|
||||
### 第一次在本机跑起来
|
||||
|
||||
@@ -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()
|
||||
|
||||
dict_data = []
|
||||
for table_name in 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 = 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
|
||||
table_comment = comment or ""
|
||||
out.append((table_name, comment or ""))
|
||||
except Exception as 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 的情况,避免重复判断
|
||||
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())
|
||||
|
||||
results = []
|
||||
for table_name in 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 = 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 "")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
|
||||
comment = ""
|
||||
out.append((table_name, comment or ""))
|
||||
return out
|
||||
|
||||
results = []
|
||||
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,17 +352,20 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
|
||||
返回:
|
||||
- str: 表注释;表不存在或失败时为空字符串。
|
||||
"""
|
||||
inspector: Inspector = inspect(engine)
|
||||
if not inspector.has_table(table_name):
|
||||
def _get_comment(sync_conn) -> str:
|
||||
insp = inspect(sync_conn)
|
||||
if not insp.has_table(table_name):
|
||||
return ""
|
||||
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 "")
|
||||
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,8 +407,9 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen
|
||||
返回:
|
||||
- list: 列信息列表
|
||||
"""
|
||||
def _introspect(sync_conn) -> list[dict]:
|
||||
# 使用SQLAlchemy Inspector获取表列信息
|
||||
inspector: Inspector = inspect(engine)
|
||||
inspector = inspect(sync_conn)
|
||||
|
||||
# 获取列信息
|
||||
columns = inspector.get_columns(table_name)
|
||||
@@ -451,6 +465,8 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen
|
||||
|
||||
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}")
|
||||
logger.error("❌ 数据库 Redis ping 失败")
|
||||
await rd.aclose()
|
||||
except (exceptions.RedisError, OSError) 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
|
||||
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,
|
||||
|
||||
+25
-2
@@ -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)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
+6
-6
@@ -18,7 +18,7 @@ from app.common.enums import EnvironmentEnum
|
||||
from app.config.setting import settings
|
||||
from app.utils.banner import worship
|
||||
|
||||
fastapiadmin_cli = typer.Typer()
|
||||
dpb_cli = typer.Typer()
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ def create_app() -> FastAPI:
|
||||
|
||||
|
||||
# typer.Option是非必填;typer.Argument是必填
|
||||
@fastapiadmin_cli.command(
|
||||
@dpb_cli.command(
|
||||
name="run",
|
||||
help="启动 FastapiAdmin 服务, 运行 uv run main.py run --env=dev 不加参数默认 dev 环境",
|
||||
help="启动 DPB 桃育种系统服务, 运行 uv run main.py run --env=dev 不加参数默认 dev 环境",
|
||||
)
|
||||
def run(
|
||||
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",
|
||||
help="生成新的 Alembic 迁移脚本, 运行 python main.py revision --env=dev",
|
||||
)
|
||||
@@ -106,7 +106,7 @@ def revision(
|
||||
typer.echo("迁移脚本已生成")
|
||||
|
||||
|
||||
@fastapiadmin_cli.command(
|
||||
@dpb_cli.command(
|
||||
name="upgrade",
|
||||
help="应用最新的 Alembic 迁移, 运行 python main.py upgrade --env=dev",
|
||||
)
|
||||
@@ -130,4 +130,4 @@ def upgrade(
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fastapiadmin_cli()
|
||||
dpb_cli()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "backend"
|
||||
version = "2.0.0"
|
||||
description = "fastapiadmin后端工程"
|
||||
description = "DPB 桃育种系统后端"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
@@ -33,7 +33,7 @@ dependencies = [
|
||||
"uvicorn==0.49.0", # uvicorn web 框架
|
||||
"websockets>=16.0,<17.0", # websocket 通信
|
||||
"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",
|
||||
"pydantic[email]>=2.12.5",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- 桃育种系统 - 亲本资源表 (bre_germplasm)
|
||||
-- ------------------------------------------------------------
|
||||
-- 依据: 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 /
|
||||
-- deleted_time / created_id / updated_id / deleted_id
|
||||
-- 用途: 建表后由「代码生成器」导入,一键生成前后端 CRUD
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
{
|
||||
"config_name": "版权信息",
|
||||
"config_key": "copyright",
|
||||
"config_value": "Copyright © 2025-2027 service.fastapiadmin.com 版权所有",
|
||||
"config_value": "Copyright © 2026 DPB 桃育种系统 版权所有",
|
||||
"config_type": true,
|
||||
"status": 0,
|
||||
"description": "页面底部版权信息"
|
||||
@@ -106,7 +106,7 @@
|
||||
{
|
||||
"config_name": "系统名称",
|
||||
"config_key": "sys_name",
|
||||
"config_value": "FastapiAdmin系统",
|
||||
"config_value": "DPB桃育种系统",
|
||||
"config_type": true,
|
||||
"status": 0,
|
||||
"description": "平台系统名称,用于登录页等界面展示"
|
||||
|
||||
+85
-132
@@ -1,187 +1,138 @@
|
||||
"""conftest — 模块化 API 接口测试共享 fixture。
|
||||
"""conftest — 模块化 API 接口测试共享 fixture(真实 PostgreSQL + Redis)。
|
||||
|
||||
A2 架构治理:冒烟层不再用 SQLite + mock Redis,改为连真实 PostgreSQL16(专用
|
||||
测试库 dpb_test,number_gen 的 pg_advisory_xact_lock 路径真被执行,不再静默降级)
|
||||
+ 真实 Redis,与统计引擎 tc 套件同一套基础设施(可被 TC_CI_* 环境变量覆盖,供 CI
|
||||
容器指定宿主)。
|
||||
|
||||
提供:
|
||||
- test_client: FastAPI TestClient 实例 (session 级复用)
|
||||
- assert_route: 验证接口路由存在 (status_code != 404)
|
||||
|
||||
注意: 本 conftest 强制 DATABASE_TYPE=sqlite + mock Redis,仅作用于 backend/tests/ 目录。
|
||||
backend/scripts/ 下的 test_bre_analysis_*_tc.py 是独立进程的真实 PG16+Redis 套件
|
||||
(由 tests/test_stat_analysis_tc.py 以 subprocess 调用),此处用 collect_ignore_glob
|
||||
排除,避免 pytest 收集时 import 它们污染 sqlite 环境。
|
||||
排除,避免 pytest 收集时 import 它们。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
collect_ignore_glob = ["../scripts/test_*.py"]
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
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))
|
||||
|
||||
import pytest
|
||||
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
|
||||
|
||||
settings.DATABASE_TYPE = "sqlite"
|
||||
settings.DATABASE_NAME = _TEST_DB_PATH
|
||||
settings.POOL_SIZE = 1
|
||||
settings.MAX_OVERFLOW = 1
|
||||
settings.CAPTCHA_ENABLE = False # 测试环境关闭验证码
|
||||
|
||||
# ============================================================
|
||||
# Mock Redis — dict 存储,支持 get/set/delete/exists/keys/ttl/expire
|
||||
# 登录成功后写入的 session 数据可在后续请求中正确读取
|
||||
# 跨事件循环安全 —— TestClient 的 lifespan 跑在 portal 线程 loop,而 pytest-asyncio
|
||||
# 的 async 测试跑在函数级新 loop;asyncpg 连接 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:
|
||||
return _mock_redis_store.get(name)
|
||||
_test_engine = create_async_engine(settings.ASYNC_DB_URI, poolclass=NullPool)
|
||||
_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.shutdown", new=AsyncMock()).start()
|
||||
|
||||
# slowapi 通过中间件限流,测试无需 mock
|
||||
|
||||
# ============================================================
|
||||
# 精简 lifespan — 仅做数据库初始化
|
||||
# 精简 lifespan — 真实 PG + Redis,仅跳过调度器
|
||||
# ============================================================
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
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.core.database import async_db_session, redis_connect
|
||||
|
||||
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"
|
||||
from sqlalchemy import update
|
||||
|
||||
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
|
||||
|
||||
async with async_db_session() as db:
|
||||
@@ -190,6 +141,8 @@ async def _test_lifespan(app) -> AsyncGenerator[Any, None]:
|
||||
|
||||
yield
|
||||
|
||||
await redis_connect(app, status=False)
|
||||
|
||||
|
||||
from main import create_app
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
set -euo pipefail
|
||||
|
||||
# ==================== 配置 ====================
|
||||
PROJECT_NAME="FastapiAdmin"
|
||||
PROJECT_NAME="dpb"
|
||||
WORK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DOCKER_DIR="${WORK_DIR}/docker"
|
||||
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'
|
||||
|
||||
|
||||
+1
-1
@@ -54,4 +54,4 @@ HTTPS_PORT=443
|
||||
|
||||
# ── 部署 ──
|
||||
DEPLOY_ENV=prod
|
||||
PROJECT_NAME=FastapiAdmin
|
||||
PROJECT_NAME=dpb
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ============================================
|
||||
# FastapiAdmin Docker Compose 配置文件
|
||||
# DPB Docker Compose 配置文件
|
||||
# ============================================
|
||||
# 使用方式:
|
||||
# 开发环境: docker compose --env-file .env up -d
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ============================================
|
||||
# FastapiAdmin Nginx 配置文件
|
||||
# DPB Nginx 配置文件
|
||||
# ============================================
|
||||
|
||||
# 自动检测 CPU 核心数,多核服务器自动利用所有核心
|
||||
|
||||
@@ -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/tests:DATABASE_NAME=dpb_test(conftest 自动建库)
|
||||
# # 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_test(docker-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
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 测试库:冒烟层(backend/tests)专用,与开发库 dpb 隔离
|
||||
-- postgres:16 镜像首次初始化时执行本脚本(/docker-entrypoint-initdb.d/)
|
||||
CREATE DATABASE dpb_test;
|
||||
@@ -4,7 +4,7 @@
|
||||
VITE_APP_ENV = dev
|
||||
|
||||
# 项目名称
|
||||
VITE_APP_TITLE = FastapiAdmin
|
||||
VITE_APP_TITLE = 数字化桃育种系统
|
||||
|
||||
# 浏览器侧同源前缀(与 Vite 代理配合时用 /,请求走 localhost:端口 + 代理)
|
||||
VITE_API_URL = /
|
||||
|
||||
@@ -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/` 目录结构、环境变量与前端开发约定。
|
||||
|
||||
@@ -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`)转发到上述后端。
|
||||
3. 若页面提示「连接被拒绝」,检查后端是否启动、端口是否一致,或把 **`VITE_API_BASE_URL`** 改成你的实际后端地址。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fastapiadmin",
|
||||
"description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板",
|
||||
"name": "dpb-web",
|
||||
"description": "DPB 桃育种系统前端(Vue3 + Vite + TypeScript + Element-Plus)",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -190,14 +190,14 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitee.com/fastapiadmin/FastapiAdmin.git"
|
||||
"url": "http://123.207.9.209:3000/gonsun/dpb.git"
|
||||
},
|
||||
"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",
|
||||
"homepage": "https://gitee.com/fastapiadmin/FastapiAdmin",
|
||||
"homepage": "http://123.207.9.209:3000/gonsun/dpb",
|
||||
"browserslist": [
|
||||
"Chrome >= 84",
|
||||
"Firefox >= 83",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* - menuStyles: 菜单风格预览图(设计/暗色/亮色)
|
||||
*
|
||||
* @module config/assets/images
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import lightTheme from "@imgs/settings/theme_styles/light.png";
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* - headerBar: 顶部栏功能配置
|
||||
*
|
||||
* @module config
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { configImages } from "./assets/images";
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* - 配置查询 - 提供工具函数快速查询组件配置
|
||||
*
|
||||
* @module config/component
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { defineAsyncComponent } from "vue";
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
* - 每个用户每个自然日「自动礼花」流程只会完整播放一次(由 setting store 按自然日记录)。
|
||||
*
|
||||
* @module config/modules/festival
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import type { FestivalConfig } from "@/types/config";
|
||||
@@ -62,7 +62,7 @@ export const festivalConfigList: FestivalConfig[] = [
|
||||
// image: yd,
|
||||
// count: 3,
|
||||
// scrollText:
|
||||
// "🎉 五月快乐!FastAPI Admin 祝您工作顺利、迭代顺利。本月请关注备份与安全策略,遇到问题可先查看文档或联系运维。",
|
||||
// "🎉 五月快乐!DPB 桃育种系统祝您工作顺利、迭代顺利。本月请关注备份与安全策略,遇到问题可先查看文档或联系运维。",
|
||||
// },
|
||||
|
||||
/** 单日示例(圣诞节):需取消注释并确保已 import 雪花图 */
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 通过修改此配置文件可以快速启用或禁用顶部栏的功能按钮。
|
||||
*
|
||||
* @module config/headerBar
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import type { HeaderBarFeatureConfig } from "@/types/config";
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
* - 智能监听:只在有新代码块时才触发处理
|
||||
*
|
||||
* @module directives/highlight
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { App, Directive } from "vue";
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
* - 自定义:通过 color 参数指定任意颜色
|
||||
*
|
||||
* @module directives/ripple
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import type { App, Directive, DirectiveBinding } from "vue";
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* - 菜单宽度枚举(收起宽度)
|
||||
*
|
||||
* @module enums/appEnum
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - 表格尺寸枚举(默认、紧凑、宽松)
|
||||
*
|
||||
* @module enums/formEnum
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
// 页面类型
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* 4. 响应式状态 - 提供响应式的模式判断,方便在组件中使用
|
||||
*
|
||||
* @module useAppMode
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { computed } from "vue";
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
* ```
|
||||
*
|
||||
* @module useCeremony
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { useTimeoutFn, useIntervalFn, useDateFormat } from "@vueuse/core";
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
* ```
|
||||
*
|
||||
* @module useChart
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { echarts, type EChartsOption } from "@/plugins/echarts";
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* 4. 宽度控制 - 提供最小显示宽度配置,支持响应式布局
|
||||
*
|
||||
* @module useFastEnter
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { computed } from "vue";
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* 4. 响应式状态 - 所有状态自动响应配置和 store 变化
|
||||
*
|
||||
* @module useHeaderBar
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { computed } from "vue";
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* 5. 自动查找模式 - 提供通过 ID 自动查找元素的便捷方式
|
||||
*
|
||||
* @module useLayoutHeight
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* 4. 智能适配 - 无额外元素时自动使用 100% 高度
|
||||
*
|
||||
* @module useTableHeight
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { computed, type Ref } from "vue";
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
* ```
|
||||
*
|
||||
* @module useTheme
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { SystemThemeEnum } from "@/enums/appEnum";
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* - en: English
|
||||
*
|
||||
* @module locales
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { LanguageEnum } from "@/enums/appEnum";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 只注册项目中实际使用的图表类型和组件。
|
||||
*
|
||||
* @module plugins/echarts
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
// ECharts 按需导入配置
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* - 新手引导展示
|
||||
*
|
||||
* @module store/modules/app.store
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* - 支持强制刷新配置
|
||||
*
|
||||
* @module store/modules/config.store
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
import { store } from "@stores";
|
||||
import ParamsAPI, { ConfigTable } from "@/api/module_system/params";
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
* - 减少重复请求
|
||||
*
|
||||
* @module store/modules/dict.store
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
import { store } from "@stores";
|
||||
import DictAPI, { DictDataTable } from "@/api/module_system/dict";
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* 4. 登出时调用移除函数清理路由
|
||||
*
|
||||
* @module store/modules/menu.store
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* - 刷新页面后保留已读状态
|
||||
*
|
||||
* @module store/modules/notice.store
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
import { store } from "@stores";
|
||||
import NoticeAPI, { NoticeTable } from "@/api/module_system/notice";
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
* - 刷新页面保持标签状态
|
||||
*
|
||||
* @module store/modules/worktab.store
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 提供全局类型定义的统一导出入口
|
||||
*
|
||||
* @module types/index
|
||||
* @author FastapiAdmin Team
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
/** AI 相关类型定义 */
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
/** 移动端断点(小于此值视为 H5/移动端) */
|
||||
export const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
const DPB_REPO = "http://123.207.9.209:3000/gonsun/dpb";
|
||||
|
||||
export const WEB_LINKS = {
|
||||
GITHUB_HOME: "https://github.com/fastapiadmin",
|
||||
GITHUB: "https://github.com/fastapiadmin/FastapiAdmin",
|
||||
GITEE: "https://gitee.com/fastapiadmin/FastapiAdmin",
|
||||
GITHUB_HOME: DPB_REPO,
|
||||
GITHUB: DPB_REPO,
|
||||
GITEE: DPB_REPO,
|
||||
BLOG: "https://blog.csdn.net/weixin_46768253?type=blog",
|
||||
DOCS: "https://service.fastapiadmin.com/guide/overview.html",
|
||||
LiteVersion: "https://gitee.com/fastapiadmin/FastCloud",
|
||||
OldVersion: "https://github.com/fastapiadmin/FastapiAdmin/tree/v2.0.0",
|
||||
COMMUNITY: "https://service.fastapiadmin.com",
|
||||
DOCS: DPB_REPO,
|
||||
COMMUNITY: DPB_REPO,
|
||||
BILIBILI: "https://space.bilibili.com/425500936?spm_id_from=333.1007.0.0",
|
||||
INTRODUCE: "https://service.fastapiadmin.com",
|
||||
INTRODUCE: DPB_REPO,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - 在线用户计数 WebSocket
|
||||
* - 其他业务 WebSocket
|
||||
*
|
||||
* @author fastapiadmin
|
||||
* @author DPB 团队
|
||||
*/
|
||||
|
||||
import { Auth } from "@utils/auth";
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Auth, StorageConfig } from "@utils";
|
||||
import { BANNER } from "../../../build/banner";
|
||||
|
||||
// -----------------------------
|
||||
// Console banner:ANSI 转义码生成网站 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 banner:ANSI 转义码生成网站 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 {
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
<FaCardBanner
|
||||
:image="bannerIcon4"
|
||||
title="版本更新提醒"
|
||||
description="FastapiAdmin v3.0.0 已发布,包含优化和新功能。"
|
||||
description="DPB 桃育种系统 v3.0.0 已发布,包含优化和新功能。"
|
||||
:button="{
|
||||
show: true,
|
||||
text: '立即更新',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
<h1 class="screen-title">
|
||||
<span class="deco-line" />
|
||||
FastapiAdmin · 智能运营数据监控平台
|
||||
DPB 桃育种系统 · 育种数据监测平台
|
||||
<span class="deco-line" />
|
||||
</h1>
|
||||
<div class="header-right">
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<div class="manual-html" @click.capture="handleAnchorClick">
|
||||
<div class="manual-html__inner">
|
||||
<h1>
|
||||
FastapiAdmin 功能点清单
|
||||
DPB 桃育种系统 功能点清单
|
||||
<small>用于全功能测试验收,按模块逐页列出所有可操作元素</small>
|
||||
</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"
|
||||
>
|
||||
<p>
|
||||
FastapiAdmin 功能点清单(完整性)—
|
||||
DPB 桃育种系统 功能点清单(完整性)—
|
||||
与当前代码中已实现界面项对齐,用于逐项核对是否漏测,不评价体验优劣。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ interface Emits {
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const promptCards = [
|
||||
{ title: "系统介绍", body: "请介绍一下FastApiAdmin系统", prompt: "请介绍一下FastApiAdmin系统" },
|
||||
{ title: "系统介绍", body: "请介绍一下DPB桃育种系统", prompt: "请介绍一下DPB桃育种系统" },
|
||||
{ title: "开发指导", body: "如何在系统中创建新的模块?", prompt: "如何在系统中创建新的模块?" },
|
||||
{
|
||||
title: "权限管理",
|
||||
|
||||
@@ -488,10 +488,9 @@ let notificationInstance: ReturnType<typeof msg.notify> | null = null;
|
||||
|
||||
const showVoteNotification = () => {
|
||||
notificationInstance = msg.notify({
|
||||
title: "⭐ FastapiAdmin 完全开源 · 期待您的 Star 支持 🙏",
|
||||
message: `项目持续迭代中,若对您有所帮助,欢迎点亮 Star 支持!
|
||||
<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="https://gitee.com/fastapiadmin/FastapiAdmin" target="_blank" style="color: var(--el-color-warning); text-decoration: none; font-weight: 500;">Gitee仓库 →</a>`,
|
||||
title: "⭐ DPB 桃育种系统 · 功能持续迭代中 🙏",
|
||||
message: `项目持续迭代中,若有问题或建议欢迎反馈!
|
||||
<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>`,
|
||||
type: "success",
|
||||
position:
|
||||
panelAlign.value === "right" || panelAlign.value === "center"
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""本机 CI stage:验收门 = 真实 PostgreSQL16 + Redis 就绪 → `pytest -m pg` 全绿。
|
||||
|
||||
背景:全工程无 git 仓库(root/backend/frontend 均无 .git),GitHub Actions 是云端服务,
|
||||
本机用不上;CI stage 落地为本机驱动脚本。v8 验收标准「CI 跑通 pytest 全绿」= 本脚本 EXIT=0。
|
||||
背景:工程已纳入 git(Gitea gonsun/dpb,2026-08-06 起);CI stage 落地为本机驱动脚本。
|
||||
v8 验收标准「CI 跑通 pytest 全绿」= 本脚本 EXIT=0。
|
||||
|
||||
step 0 探测本机真实 PG16(backend/env/.env.dev 的 DATABASE_*,可用 TC_CI_DB_* 覆盖)+ Redis
|
||||
(REDIS_*)连通;任一失败给出明确指引并 exit 非 0。
|
||||
step 1 backend 目录下 `pytest -m pg`(dpb python + -X utf8)跑 9 套统计引擎正式 tc 套件
|
||||
step 0 探测真实 PG16(backend/env/.env.dev 的 DATABASE_*,可用 TC_CI_DB_* 覆盖)+ Redis
|
||||
(REDIS_*)连通。本机 PG/Redis 未就绪且 docker 可用时,自动
|
||||
`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 调真实脚本)。
|
||||
step 2 汇总 EXIT=0 → 「CI 全绿」;日志写 Temp/claude/ci_logs/。
|
||||
|
||||
说明:
|
||||
- 全程真实 PG16 + Redis(本工程无 SQLite);PG/Redis 缺失时 step 0 拦截,不会误绿。
|
||||
- 与 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 覆盖。
|
||||
"""
|
||||
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]:
|
||||
import asyncpg
|
||||
import redis.asyncio as aioredis
|
||||
@@ -100,11 +125,35 @@ def main() -> int:
|
||||
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):
|
||||
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):
|
||||
print("\n❌ 基础设施未就绪,CI 无法放行。请检查:")
|
||||
print(" · 本机 PostgreSQL 16 服务是否启动(服务名 postgresql-x64-16,端口 5432)")
|
||||
print(" · Redis 是否启动(redis-server,端口 6379;工程 WSL 内运行则确认 WSL 已起)")
|
||||
print(f" · 连接配置 backend/env/.env.dev 的 DATABASE_*/REDIS_* 是否与本地一致")
|
||||
print(" · 或安装 docker 后由本脚本自动拉起 docker/test-compose.yaml 测试依赖栈")
|
||||
print(" · 可用 TC_CI_DB_HOST / TC_CI_DB_PORT 等环境变量临时覆盖探测地址")
|
||||
return 1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user