From 86126ea16f1f45419a5e7aba34f69bd64792f4b0 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Fri, 7 Aug 2026 09:06:19 +0800 Subject: [PATCH] =?UTF-8?q?checkpoint:=20=E6=9E=B6=E6=9E=84=E6=B2=BB?= =?UTF-8?q?=E7=90=86=E5=9B=9B=E4=BB=B6=E5=A5=97=E2=80=94=E2=80=94=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=20async=20/=20=E6=B5=8B=E8=AF=95=E7=9C=9F=20PG=20/=20?= =?UTF-8?q?Redis=20=E9=99=8D=E7=BA=A7=E5=8F=AF=E9=80=89=20/=20DPB=20?= =?UTF-8?q?=E6=89=B6=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .dockerignore | 2 +- backend/README.md | 8 +- .../api/v1/module_generator/gencode/crud.py | 203 ++++++++-------- .../app/api/v1/module_system/auth/service.py | 22 +- .../app/api/v1/module_system/dict/service.py | 12 +- backend/app/config/setting.py | 9 +- backend/app/core/ap_scheduler.py | 4 + backend/app/core/database.py | 31 ++- backend/app/core/dependencies.py | 24 +- backend/app/core/logger.py | 2 +- backend/app/init_app.py | 41 +++- backend/app/utils/ai_factory.py | 10 +- backend/app/utils/banner.py | 2 +- backend/app/utils/console.py | 4 +- backend/app/utils/dict_util.py | 39 +++- backend/main.py | 12 +- backend/pyproject.toml | 4 +- backend/sql/bre_germplasm.sql | 2 +- backend/sql/data/sys_param.json | 4 +- backend/tests/conftest.py | 217 +++++++----------- deploy.sh | 4 +- docker/.env.example | 2 +- docker/docker-compose.yaml | 2 +- docker/nginx/nginx.conf | 2 +- docker/test-compose.yaml | 56 +++++ docker/test-initdb/01-create-test-db.sql | 3 + frontend/web/.env.development.example | 2 +- frontend/web/README.md | 6 +- frontend/web/package.json | 12 +- frontend/web/src/config/assets/images.ts | 2 +- frontend/web/src/config/index.ts | 2 +- frontend/web/src/config/modules/component.ts | 2 +- frontend/web/src/config/modules/festival.ts | 4 +- frontend/web/src/config/modules/headerBar.ts | 2 +- .../web/src/directives/business/highlight.ts | 2 +- .../web/src/directives/business/ripple.ts | 2 +- frontend/web/src/enums/appEnum.ts | 2 +- frontend/web/src/enums/formEnum.ts | 2 +- frontend/web/src/hooks/core/useAppMode.ts | 2 +- frontend/web/src/hooks/core/useCeremony.ts | 2 +- frontend/web/src/hooks/core/useChart.ts | 2 +- frontend/web/src/hooks/core/useFastEnter.ts | 2 +- frontend/web/src/hooks/core/useHeaderBar.ts | 2 +- .../web/src/hooks/core/useLayoutHeight.ts | 2 +- frontend/web/src/hooks/core/useTableHeight.ts | 2 +- frontend/web/src/hooks/core/useTheme.ts | 2 +- frontend/web/src/locales/index.ts | 2 +- frontend/web/src/plugins/echarts.ts | 2 +- frontend/web/src/store/modules/app.store.ts | 2 +- .../web/src/store/modules/config.store.ts | 2 +- frontend/web/src/store/modules/dict.store.ts | 2 +- frontend/web/src/store/modules/menu.store.ts | 2 +- .../web/src/store/modules/notice.store.ts | 2 +- .../web/src/store/modules/worktab.store.ts | 2 +- frontend/web/src/types/index.ts | 2 +- .../web/src/utils/constants/definitions.ts | 16 +- frontend/web/src/utils/socket/index.ts | 2 +- frontend/web/src/utils/sys/index.ts | 2 +- .../web/src/views/dashboard/home/index.vue | 2 +- .../dashboard/screen/modules/ScreenHeader.vue | 2 +- .../web/src/views/fastlink/tutorial/index.vue | 4 +- .../chat/components/FaWelcomeScreen.vue | 2 +- .../views/module_system/auth/login/index.vue | 7 +- run_ci.py | 73 +++++- 64 files changed, 546 insertions(+), 359 deletions(-) create mode 100644 docker/test-compose.yaml create mode 100644 docker/test-initdb/01-create-test-db.sql diff --git a/.dockerignore b/.dockerignore index c67c5d4..1a2823b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,5 @@ # ============================================ -# FastapiAdmin .dockerignore +# DPB .dockerignore # 用于优化 Docker 构建上下文,提升构建速度 # ============================================ diff --git a/backend/README.md b/backend/README.md index b7e38e1..31fa4d7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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` 中配置一致 ### 第一次在本机跑起来 diff --git a/backend/app/api/v1/module_generator/gencode/crud.py b/backend/app/api/v1/module_generator/gencode/crud.py index df357fa..fd19cae 100644 --- a/backend/app/api/v1/module_generator/gencode/crud.py +++ b/backend/app/api/v1/module_generator/gencode/crud.py @@ -1,14 +1,13 @@ -import asyncio from collections.abc import Sequence from typing import TYPE_CHECKING -from sqlalchemy import Inspector, inspect, select, text +from sqlalchemy import inspect, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.config.setting import settings from app.core.base_crud import CRUDBase from app.core.base_schema import AuthSchema -from app.core.database import engine +from app.core.database import async_engine from app.core.logger import logger from app.utils.common_util import search_to_dict @@ -25,6 +24,12 @@ if TYPE_CHECKING: from sqlalchemy.engine.reflection import Inspector +async def _run_inspector(func, *args): + """在异步引擎连接上以 run_sync 执行同步 Inspector 操作,避免阻塞事件循环。""" + async with async_engine.connect() as conn: + return await conn.run_sync(func, *args) + + class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): """代码生成业务表模块数据库操作层""" @@ -142,19 +147,21 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): database_name = settings.DATABASE_NAME database_type = settings.DATABASE_TYPE - inspector: Inspector = inspect(engine) - table_names = inspector.get_table_names() + def _introspect(sync_conn) -> list[tuple[str, str]]: + insp = inspect(sync_conn) + out = [] + for table_name in insp.get_table_names(): + try: + table_comment = insp.get_table_comment(table_name) + comment = table_comment.get("text", "") if isinstance(table_comment, dict) else table_comment + out.append((table_name, comment or "")) + except Exception as e: + logger.warning(f"获取表 {table_name} 的注释失败: {e}") + out.append((table_name, "")) + return out dict_data = [] - for table_name in table_names: - try: - table_comment = inspector.get_table_comment(table_name) - comment = table_comment.get("text", "") if isinstance(table_comment, dict) else table_comment - table_comment = comment or "" - except Exception as e: - logger.warning(f"获取表 {table_name} 的注释失败: {e}") - table_comment = "" - + for table_name, table_comment in await _run_inspector(_introspect): # 统一处理 search 为 None 的情况,避免重复判断 if search: # 表名过滤:忽略大小写,支持模糊匹配 @@ -296,26 +303,30 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): database_name = settings.DATABASE_NAME database_type = settings.DATABASE_TYPE - inspector: Inspector = inspect(engine) - all_table_names = set(inspector.get_table_names()) + def _introspect(sync_conn, names: list[str]) -> list[tuple[str, str]]: + insp = inspect(sync_conn) + all_table_names = set(insp.get_table_names()) + out = [] + for table_name in names: + if table_name not in all_table_names: + continue + try: + table_comment = insp.get_table_comment(table_name) + comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "") + except Exception as e: + logger.warning(f"获取表 {table_name} 的注释失败: {e}") + comment = "" + out.append((table_name, comment or "")) + return out results = [] - for table_name in table_names: - if table_name not in all_table_names: - continue - try: - table_comment = inspector.get_table_comment(table_name) - comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "") - except Exception as e: - logger.warning(f"获取表 {table_name} 的注释失败: {e}") - comment = "" - + for table_name, comment in await _run_inspector(_introspect, table_names): results.append( GenDBTableSchema( database_name=database_name, table_name=table_name, table_type=database_type, - table_comment=comment or "", + table_comment=comment, ), ) @@ -330,8 +341,7 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): 返回: - bool: 如果表存在返回True,否则返回False。 """ - inspector: Inspector = inspect(engine) - return inspector.has_table(table_name) + return await _run_inspector(lambda sync_conn: inspect(sync_conn).has_table(table_name)) async def get_db_table_comment(self, table_name: str) -> str: """获取数据库中指定表的注释(用于主子表场景下从库中加载子表元信息)。 @@ -342,16 +352,19 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): 返回: - str: 表注释;表不存在或失败时为空字符串。 """ - inspector: Inspector = inspect(engine) - if not inspector.has_table(table_name): - return "" - try: - table_comment = inspector.get_table_comment(table_name) - comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "") - return comment or "" - except Exception as e: - logger.warning(f"获取表 {table_name} 的注释失败: {e}") - return "" + def _get_comment(sync_conn) -> str: + insp = inspect(sync_conn) + if not insp.has_table(table_name): + return "" + try: + table_comment = insp.get_table_comment(table_name) + comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "") + return comment or "" + except Exception as e: + logger.warning(f"获取表 {table_name} 的注释失败: {e}") + return "" + + return await _run_inspector(_get_comment) async def execute_sql(self, sql: str) -> bool: """执行SQL语句。 @@ -384,8 +397,8 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen super().__init__(model=GenTableColumnModel, auth=auth, db=db) @staticmethod - def _sync_get_table_columns(database_type: str, table_name: str) -> list[dict]: - """同步函数:获取数据库表的列信息 + async def _get_table_columns(database_type: str, table_name: str) -> list[dict]: + """在异步引擎连接上内省数据库表的列信息(不阻塞事件循环)。 参数: - database_type: 数据库类型 @@ -394,62 +407,65 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen 返回: - list: 列信息列表 """ - # 使用SQLAlchemy Inspector获取表列信息 - inspector: Inspector = inspect(engine) + def _introspect(sync_conn) -> list[dict]: + # 使用SQLAlchemy Inspector获取表列信息 + inspector = inspect(sync_conn) - # 获取列信息 - columns = inspector.get_columns(table_name) + # 获取列信息 + columns = inspector.get_columns(table_name) - # 获取主键信息 - try: - pk_constraint = inspector.get_pk_constraint(table_name) - primary_keys = set(pk_constraint.get("constrained_columns", [])) if pk_constraint else set() - except Exception: - primary_keys = set() + # 获取主键信息 + try: + pk_constraint = inspector.get_pk_constraint(table_name) + primary_keys = set(pk_constraint.get("constrained_columns", [])) if pk_constraint else set() + except Exception: + primary_keys = set() - # 获取唯一约束信息 - unique_columns = set() - unique_constraints = inspector.get_unique_constraints(table_name) - for constraint in unique_constraints: - unique_columns.update(constraint.get("column_names", [])) + # 获取唯一约束信息 + unique_columns = set() + unique_constraints = inspector.get_unique_constraints(table_name) + for constraint in unique_constraints: + unique_columns.update(constraint.get("column_names", [])) - # 处理列信息 - columns_list = [] - for idx, column in enumerate(columns): - # 获取列的基本信息 - column_name = column["name"] - column_type = str(column["type"]) - is_nullable = column.get("nullable", True) - column_default = column.get("default", None) - # 获取列注释(如果有的话) - column_comment = column.get("comment", "") - # 判断是否为主键 - is_pk = column_name in primary_keys - # 判断是否为唯一约束 - is_unique = column_name in unique_columns - # 判断是否为自增列(基于数据库类型和列类型) - is_increment = column.get("autoincrement", False) in (True, "auto") - # 获取列长度(如果适用) - col_len = getattr(column["type"], "length", None) - column_length = str(col_len) if col_len is not None else "" + # 处理列信息 + columns_list = [] + for idx, column in enumerate(columns): + # 获取列的基本信息 + column_name = column["name"] + column_type = str(column["type"]) + is_nullable = column.get("nullable", True) + column_default = column.get("default", None) + # 获取列注释(如果有的话) + column_comment = column.get("comment", "") + # 判断是否为主键 + is_pk = column_name in primary_keys + # 判断是否为唯一约束 + is_unique = column_name in unique_columns + # 判断是否为自增列(基于数据库类型和列类型) + is_increment = column.get("autoincrement", False) in (True, "auto") + # 获取列长度(如果适用) + col_len = getattr(column["type"], "length", None) + column_length = str(col_len) if col_len is not None else "" - # 构造列信息字典 - column_info = { - "column_name": column_name, - "column_comment": column_comment or "", - "column_type": column_type, - "column_length": column_length or "", - "column_default": str(column_default) if column_default is not None else "", - "sort": idx + 1, # 序号从1开始 - "is_pk": bool(is_pk), - "is_increment": bool(is_increment), - "is_nullable": bool(is_nullable), - "is_unique": bool(is_unique), - } + # 构造列信息字典 + column_info = { + "column_name": column_name, + "column_comment": column_comment or "", + "column_type": column_type, + "column_length": column_length or "", + "column_default": str(column_default) if column_default is not None else "", + "sort": idx + 1, # 序号从1开始 + "is_pk": bool(is_pk), + "is_increment": bool(is_increment), + "is_nullable": bool(is_nullable), + "is_unique": bool(is_unique), + } - columns_list.append(column_info) + columns_list.append(column_info) - return columns_list + return columns_list + + return await _run_inspector(_introspect) async def get_gen_table_column_by_id(self, id: int, preload: list | None = None) -> GenTableColumnModel | None: """根据业务表字段ID获取业务表字段信息。 @@ -507,12 +523,7 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen raise ValueError("数据表名称不能为空") try: - # 在线程池中执行同步 inspect 操作,避免阻塞事件循环 - columns_info = await asyncio.to_thread( - GenTableColumnCRUD._sync_get_table_columns, - settings.DATABASE_TYPE, - table_name, - ) + columns_info = await GenTableColumnCRUD._get_table_columns(settings.DATABASE_TYPE, table_name) # 转换为GenTableColumnOutSchema对象列表 columns_list = [GenTableColumnOutSchema(**column_info) for column_info in columns_info] diff --git a/backend/app/api/v1/module_system/auth/service.py b/backend/app/api/v1/module_system/auth/service.py index a971ae1..bf2b4bd 100644 --- a/backend/app/api/v1/module_system/auth/service.py +++ b/backend/app/api/v1/module_system/auth/service.py @@ -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}") diff --git a/backend/app/api/v1/module_system/dict/service.py b/backend/app/api/v1/module_system/dict/service.py index e50b13c..b78e204 100644 --- a/backend/app/api/v1/module_system/dict/service.py +++ b/backend/app/api/v1/module_system/dict/service.py @@ -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 返回的字典缓存数据""" diff --git a/backend/app/config/setting.py b/backend/app/config/setting.py index 35dde6a..9600c8c 100644 --- a/backend/app/config/setting.py +++ b/backend/app/config/setting.py @@ -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 diff --git a/backend/app/core/ap_scheduler.py b/backend/app/core/ap_scheduler.py index a1e77d7..1e5108b 100644 --- a/backend/app/core/ap_scheduler.py +++ b/backend/app/core/ap_scheduler.py @@ -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 diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 2e4af80..ca3e5f8 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -13,6 +13,9 @@ from app.core.logger import logger def create_engine_and_session(db_url: str = settings.DB_URI) -> tuple[Engine, sessionmaker]: """创建同步数据库引擎和会话工厂。 + 仅 APScheduler SQLAlchemyJobStore(同步库接口,apscheduler==3.11.0)消费; + 业务层一律使用异步 create_async_engine_and_session,不得再引入同步引擎。 + 参数: - db_url (str): 数据库连接URL,默认从配置中获取。 @@ -84,6 +87,7 @@ def create_async_engine_and_session(db_url: str = settings.ASYNC_DB_URI) -> tupl return async_engine, AsyncSessionLocal +# 同步引擎/会话仅 APScheduler SQLAlchemyJobStore 消费(同步库接口);业务层统一用 async_engine engine, db_session = create_engine_and_session() async_engine, async_db_session = create_async_engine_and_session() @@ -91,7 +95,7 @@ async def check_db() -> None: """检查数据库连接是否正常。""" try: - with engine.connect(): + async with async_engine.connect(): pass logger.info("✅ 数据库连接正常") except Exception as e: @@ -138,6 +142,7 @@ async def redis_connect(app: FastAPI, status: bool) -> Redis | None: - Redis | None: Redis连接实例,如果连接失败则返回None。 """ if status: + app.state.redis = None # 先置不可用;连接失败时应用以降级模式启动,不再中断 try: rd = await Redis.from_url( url=settings.REDIS_URI, @@ -148,18 +153,20 @@ async def redis_connect(app: FastAPI, status: bool) -> Redis | None: max_connections=settings.POOL_SIZE, socket_timeout=settings.POOL_TIMEOUT, ) - app.state.redis = rd if await rd.ping(): # pyright: ignore[reportGeneralTypeIssues] + app.state.redis = rd return rd - except exceptions.AuthenticationError as e: - logger.error(f"❌ 数据库 Redis 认证失败: {e}") - return None - except exceptions.TimeoutError as e: - logger.error(f"❌ 数据库 Redis 连接超时: {e}") - return None - except exceptions.RedisError as e: - logger.error(f"❌ 数据库 Redis 连接错误: {e}") - raise + logger.error("❌ 数据库 Redis ping 失败") + await rd.aclose() + except (exceptions.RedisError, OSError) as e: + logger.error(f"❌ 数据库 Redis 连接失败: {e}") + return None else: - await app.state.redis.close() + rd = app.state.redis + if rd is not None: + try: + await rd.close() + except Exception: # noqa: BLE001 + pass + app.state.redis = None logger.info("✅️ Redis连接已关闭") diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index a3ac016..1fe465f 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -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: diff --git a/backend/app/core/logger.py b/backend/app/core/logger.py index 046635c..eb93848 100644 --- a/backend/app/core/logger.py +++ b/backend/app/core/logger.py @@ -52,7 +52,7 @@ def setup_logger() -> None: LOG_FMT = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}{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, diff --git a/backend/app/init_app.py b/backend/app/init_app.py index b19c15d..aac9ee7 100644 --- a/backend/app/init_app.py +++ b/backend/app/init_app.py @@ -26,22 +26,45 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]: await InitializeData().init_db() logger.info("✅ {}数据库初始化完成", settings.DATABASE_TYPE) + + # Redis 启动不强依赖:连接失败降级启动(参数/字典读接口回源数据库, + # 登录会话/在线监控/AI配置/调度任务等存储类接口返回 503),不再中断应用。 await redis_connect(app, status=True) - logger.info("✅ Redis 连接初始化完成") - await ParamsService.init_cache(redis=app.state.redis) - logger.info("✅ Redis系统参数初始化完成") - await DictDataService.init_cache(redis=app.state.redis) - logger.info("✅ Redis数据字典初始化完成") - await SchedulerUtil.init_scheduler(redis=app.state.redis) - logger.info("✅ 定时任务调度器初始化完成") + redis_ready = app.state.redis is not None + if redis_ready: + logger.info("✅ Redis 连接初始化完成") + await ParamsService.init_cache(redis=app.state.redis) + logger.info("✅ Redis系统参数初始化完成") + await DictDataService.init_cache(redis=app.state.redis) + logger.info("✅ Redis数据字典初始化完成") + else: + logger.warning( + "⚠️ Redis 不可用,以降级模式启动:参数/字典读接口回源数据库;" + "登录会话/在线监控/AI配置/调度任务接口将返回 503" + ) + + from app.utils.dict_util import preload_value2label_cache + + await preload_value2label_cache() + logger.info("✅ 数据字典导出映射预载完成") + + scheduler_ready = False + try: + await SchedulerUtil.init_scheduler(redis=app.state.redis) + scheduler_ready = SchedulerUtil.is_running() + logger.info("✅ 定时任务调度器初始化完成") + except Exception as e: + # Redis 不可达时默认 jobstore 连不上:不阻断启动,任务接口降级为 503 + SchedulerUtil.shutdown(wait=False) + logger.error("❌ 定时任务调度器初始化失败(继续启动,任务接口将返回 503): {}", e) console_start( host=settings.SERVER_HOST, port=settings.SERVER_PORT, reload=settings.DEBUG, database_ready=True, - redis_ready=True, - scheduler_ready=SchedulerUtil.is_running(), + redis_ready=redis_ready, + scheduler_ready=scheduler_ready, ) yield diff --git a/backend/app/utils/ai_factory.py b/backend/app/utils/ai_factory.py index 6fcca94..80a1730 100644 --- a/backend/app/utils/ai_factory.py +++ b/backend/app/utils/ai_factory.py @@ -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 diff --git a/backend/app/utils/banner.py b/backend/app/utils/banner.py index e8d9156..fa7ac51 100644 --- a/backend/app/utils/banner.py +++ b/backend/app/utils/banner.py @@ -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 文本。 diff --git a/backend/app/utils/console.py b/backend/app/utils/console.py index 9f79f87..7b4c169 100644 --- a/backend/app/utils/console.py +++ b/backend/app/utils/console.py @@ -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") diff --git a/backend/app/utils/dict_util.py b/backend/app/utils/dict_util.py index 404e006..a5d4ed0 100644 --- a/backend/app/utils/dict_util.py +++ b/backend/app/utils/dict_util.py @@ -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) diff --git a/backend/main.py b/backend/main.py index 72e28b3..21db5ab 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b1e9630..66282ad 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", ] diff --git a/backend/sql/bre_germplasm.sql b/backend/sql/bre_germplasm.sql index c61ee4e..fbbe451 100644 --- a/backend/sql/bre_germplasm.sql +++ b/backend/sql/bre_germplasm.sql @@ -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 diff --git a/backend/sql/data/sys_param.json b/backend/sql/data/sys_param.json index 456122e..b0a58aa 100644 --- a/backend/sql/data/sys_param.json +++ b/backend/sql/data/sys_param.json @@ -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": "平台系统名称,用于登录页等界面展示" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cefaf7a..84ce290 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/deploy.sh b/deploy.sh index f320e3b..e87cb34 100644 --- a/deploy.sh +++ b/deploy.sh @@ -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' diff --git a/docker/.env.example b/docker/.env.example index e7f5b31..688e933 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -54,4 +54,4 @@ HTTPS_PORT=443 # ── 部署 ── DEPLOY_ENV=prod -PROJECT_NAME=FastapiAdmin +PROJECT_NAME=dpb diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 992cd2e..3b3ba02 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,5 +1,5 @@ # ============================================ -# FastapiAdmin Docker Compose 配置文件 +# DPB Docker Compose 配置文件 # ============================================ # 使用方式: # 开发环境: docker compose --env-file .env up -d diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index e7c05ef..ea01429 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -1,5 +1,5 @@ # ============================================ -# FastapiAdmin Nginx 配置文件 +# DPB Nginx 配置文件 # ============================================ # 自动检测 CPU 核心数,多核服务器自动利用所有核心 diff --git a/docker/test-compose.yaml b/docker/test-compose.yaml new file mode 100644 index 0000000..75f169a --- /dev/null +++ b/docker/test-compose.yaml @@ -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 diff --git a/docker/test-initdb/01-create-test-db.sql b/docker/test-initdb/01-create-test-db.sql new file mode 100644 index 0000000..c0a3904 --- /dev/null +++ b/docker/test-initdb/01-create-test-db.sql @@ -0,0 +1,3 @@ +-- 测试库:冒烟层(backend/tests)专用,与开发库 dpb 隔离 +-- postgres:16 镜像首次初始化时执行本脚本(/docker-entrypoint-initdb.d/) +CREATE DATABASE dpb_test; diff --git a/frontend/web/.env.development.example b/frontend/web/.env.development.example index f3a54ee..a3bf75a 100644 --- a/frontend/web/.env.development.example +++ b/frontend/web/.env.development.example @@ -4,7 +4,7 @@ VITE_APP_ENV = dev # 项目名称 -VITE_APP_TITLE = FastapiAdmin +VITE_APP_TITLE = 数字化桃育种系统 # 浏览器侧同源前缀(与 Vite 代理配合时用 /,请求走 localhost:端口 + 代理) VITE_API_URL = / diff --git a/frontend/web/README.md b/frontend/web/README.md index 5fa6155..9ece4ea 100644 --- a/frontend/web/README.md +++ b/frontend/web/README.md @@ -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`** 改成你的实际后端地址。 diff --git a/frontend/web/package.json b/frontend/web/package.json index 7143183..3037267 100644 --- a/frontend/web/package.json +++ b/frontend/web/package.json @@ -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", diff --git a/frontend/web/src/config/assets/images.ts b/frontend/web/src/config/assets/images.ts index f8558d3..d0f1372 100644 --- a/frontend/web/src/config/assets/images.ts +++ b/frontend/web/src/config/assets/images.ts @@ -11,7 +11,7 @@ * - menuStyles: 菜单风格预览图(设计/暗色/亮色) * * @module config/assets/images - * @author FastapiAdmin Team + * @author DPB 团队 */ import lightTheme from "@imgs/settings/theme_styles/light.png"; diff --git a/frontend/web/src/config/index.ts b/frontend/web/src/config/index.ts index 9e36ca9..79294f3 100644 --- a/frontend/web/src/config/index.ts +++ b/frontend/web/src/config/index.ts @@ -26,7 +26,7 @@ * - headerBar: 顶部栏功能配置 * * @module config - * @author FastapiAdmin Team + * @author DPB 团队 */ import { configImages } from "./assets/images"; diff --git a/frontend/web/src/config/modules/component.ts b/frontend/web/src/config/modules/component.ts index 84dcd03..64616ed 100644 --- a/frontend/web/src/config/modules/component.ts +++ b/frontend/web/src/config/modules/component.ts @@ -12,7 +12,7 @@ * - 配置查询 - 提供工具函数快速查询组件配置 * * @module config/component - * @author FastapiAdmin Team + * @author DPB 团队 */ import { defineAsyncComponent } from "vue"; diff --git a/frontend/web/src/config/modules/festival.ts b/frontend/web/src/config/modules/festival.ts index 1be7cf1..776c5d4 100644 --- a/frontend/web/src/config/modules/festival.ts +++ b/frontend/web/src/config/modules/festival.ts @@ -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 雪花图 */ diff --git a/frontend/web/src/config/modules/headerBar.ts b/frontend/web/src/config/modules/headerBar.ts index 4fc2904..fdc8d99 100644 --- a/frontend/web/src/config/modules/headerBar.ts +++ b/frontend/web/src/config/modules/headerBar.ts @@ -5,7 +5,7 @@ * 通过修改此配置文件可以快速启用或禁用顶部栏的功能按钮。 * * @module config/headerBar - * @author FastapiAdmin Team + * @author DPB 团队 */ import type { HeaderBarFeatureConfig } from "@/types/config"; diff --git a/frontend/web/src/directives/business/highlight.ts b/frontend/web/src/directives/business/highlight.ts index b884329..f03ea7e 100644 --- a/frontend/web/src/directives/business/highlight.ts +++ b/frontend/web/src/directives/business/highlight.ts @@ -38,7 +38,7 @@ * - 智能监听:只在有新代码块时才触发处理 * * @module directives/highlight - * @author FastapiAdmin Team + * @author DPB 团队 */ import { App, Directive } from "vue"; diff --git a/frontend/web/src/directives/business/ripple.ts b/frontend/web/src/directives/business/ripple.ts index e5fcb77..57cc811 100644 --- a/frontend/web/src/directives/business/ripple.ts +++ b/frontend/web/src/directives/business/ripple.ts @@ -34,7 +34,7 @@ * - 自定义:通过 color 参数指定任意颜色 * * @module directives/ripple - * @author FastapiAdmin Team + * @author DPB 团队 */ import type { App, Directive, DirectiveBinding } from "vue"; diff --git a/frontend/web/src/enums/appEnum.ts b/frontend/web/src/enums/appEnum.ts index e2ab618..df0572b 100644 --- a/frontend/web/src/enums/appEnum.ts +++ b/frontend/web/src/enums/appEnum.ts @@ -11,7 +11,7 @@ * - 菜单宽度枚举(收起宽度) * * @module enums/appEnum - * @author FastapiAdmin Team + * @author DPB 团队 */ /** diff --git a/frontend/web/src/enums/formEnum.ts b/frontend/web/src/enums/formEnum.ts index 6c5e432..1c32b1a 100644 --- a/frontend/web/src/enums/formEnum.ts +++ b/frontend/web/src/enums/formEnum.ts @@ -7,7 +7,7 @@ * - 表格尺寸枚举(默认、紧凑、宽松) * * @module enums/formEnum - * @author FastapiAdmin Team + * @author DPB 团队 */ // 页面类型 diff --git a/frontend/web/src/hooks/core/useAppMode.ts b/frontend/web/src/hooks/core/useAppMode.ts index e35e2e9..7ace9ce 100644 --- a/frontend/web/src/hooks/core/useAppMode.ts +++ b/frontend/web/src/hooks/core/useAppMode.ts @@ -13,7 +13,7 @@ * 4. 响应式状态 - 提供响应式的模式判断,方便在组件中使用 * * @module useAppMode - * @author FastapiAdmin Team + * @author DPB 团队 */ import { computed } from "vue"; diff --git a/frontend/web/src/hooks/core/useCeremony.ts b/frontend/web/src/hooks/core/useCeremony.ts index 760e771..ca38d08 100644 --- a/frontend/web/src/hooks/core/useCeremony.ts +++ b/frontend/web/src/hooks/core/useCeremony.ts @@ -42,7 +42,7 @@ * ``` * * @module useCeremony - * @author FastapiAdmin Team + * @author DPB 团队 */ import { useTimeoutFn, useIntervalFn, useDateFormat } from "@vueuse/core"; diff --git a/frontend/web/src/hooks/core/useChart.ts b/frontend/web/src/hooks/core/useChart.ts index 1fd581d..102aca9 100644 --- a/frontend/web/src/hooks/core/useChart.ts +++ b/frontend/web/src/hooks/core/useChart.ts @@ -46,7 +46,7 @@ * ``` * * @module useChart - * @author FastapiAdmin Team + * @author DPB 团队 */ import { echarts, type EChartsOption } from "@/plugins/echarts"; diff --git a/frontend/web/src/hooks/core/useFastEnter.ts b/frontend/web/src/hooks/core/useFastEnter.ts index 4667665..440b3ca 100644 --- a/frontend/web/src/hooks/core/useFastEnter.ts +++ b/frontend/web/src/hooks/core/useFastEnter.ts @@ -12,7 +12,7 @@ * 4. 宽度控制 - 提供最小显示宽度配置,支持响应式布局 * * @module useFastEnter - * @author FastapiAdmin Team + * @author DPB 团队 */ import { computed } from "vue"; diff --git a/frontend/web/src/hooks/core/useHeaderBar.ts b/frontend/web/src/hooks/core/useHeaderBar.ts index 120c2bb..bfa99a9 100644 --- a/frontend/web/src/hooks/core/useHeaderBar.ts +++ b/frontend/web/src/hooks/core/useHeaderBar.ts @@ -12,7 +12,7 @@ * 4. 响应式状态 - 所有状态自动响应配置和 store 变化 * * @module useHeaderBar - * @author FastapiAdmin Team + * @author DPB 团队 */ import { computed } from "vue"; diff --git a/frontend/web/src/hooks/core/useLayoutHeight.ts b/frontend/web/src/hooks/core/useLayoutHeight.ts index 960e17a..64019b3 100644 --- a/frontend/web/src/hooks/core/useLayoutHeight.ts +++ b/frontend/web/src/hooks/core/useLayoutHeight.ts @@ -13,7 +13,7 @@ * 5. 自动查找模式 - 提供通过 ID 自动查找元素的便捷方式 * * @module useLayoutHeight - * @author FastapiAdmin Team + * @author DPB 团队 */ import { ref, computed, watch, onMounted } from "vue"; diff --git a/frontend/web/src/hooks/core/useTableHeight.ts b/frontend/web/src/hooks/core/useTableHeight.ts index 47a910b..dc5dbeb 100644 --- a/frontend/web/src/hooks/core/useTableHeight.ts +++ b/frontend/web/src/hooks/core/useTableHeight.ts @@ -12,7 +12,7 @@ * 4. 智能适配 - 无额外元素时自动使用 100% 高度 * * @module useTableHeight - * @author FastapiAdmin Team + * @author DPB 团队 */ import { computed, type Ref } from "vue"; diff --git a/frontend/web/src/hooks/core/useTheme.ts b/frontend/web/src/hooks/core/useTheme.ts index f098ead..76e74dc 100644 --- a/frontend/web/src/hooks/core/useTheme.ts +++ b/frontend/web/src/hooks/core/useTheme.ts @@ -28,7 +28,7 @@ * ``` * * @module useTheme - * @author FastapiAdmin Team + * @author DPB 团队 */ import { SystemThemeEnum } from "@/enums/appEnum"; diff --git a/frontend/web/src/locales/index.ts b/frontend/web/src/locales/index.ts index 376798f..6c92035 100644 --- a/frontend/web/src/locales/index.ts +++ b/frontend/web/src/locales/index.ts @@ -18,7 +18,7 @@ * - en: English * * @module locales - * @author FastapiAdmin Team + * @author DPB 团队 */ import { LanguageEnum } from "@/enums/appEnum"; diff --git a/frontend/web/src/plugins/echarts.ts b/frontend/web/src/plugins/echarts.ts index 624cd1b..3f21d52 100644 --- a/frontend/web/src/plugins/echarts.ts +++ b/frontend/web/src/plugins/echarts.ts @@ -5,7 +5,7 @@ * 只注册项目中实际使用的图表类型和组件。 * * @module plugins/echarts - * @author FastapiAdmin Team + * @author DPB 团队 */ // ECharts 按需导入配置 diff --git a/frontend/web/src/store/modules/app.store.ts b/frontend/web/src/store/modules/app.store.ts index 37170cd..444446d 100644 --- a/frontend/web/src/store/modules/app.store.ts +++ b/frontend/web/src/store/modules/app.store.ts @@ -21,7 +21,7 @@ * - 新手引导展示 * * @module store/modules/app.store - * @author FastapiAdmin Team + * @author DPB 团队 */ import { defineStore } from "pinia"; import { ref, computed, watch } from "vue"; diff --git a/frontend/web/src/store/modules/config.store.ts b/frontend/web/src/store/modules/config.store.ts index 1efa328..33c1e22 100644 --- a/frontend/web/src/store/modules/config.store.ts +++ b/frontend/web/src/store/modules/config.store.ts @@ -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"; diff --git a/frontend/web/src/store/modules/dict.store.ts b/frontend/web/src/store/modules/dict.store.ts index 103a858..9d04e56 100644 --- a/frontend/web/src/store/modules/dict.store.ts +++ b/frontend/web/src/store/modules/dict.store.ts @@ -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"; diff --git a/frontend/web/src/store/modules/menu.store.ts b/frontend/web/src/store/modules/menu.store.ts index 4d17b2d..e3c56ec 100644 --- a/frontend/web/src/store/modules/menu.store.ts +++ b/frontend/web/src/store/modules/menu.store.ts @@ -26,7 +26,7 @@ * 4. 登出时调用移除函数清理路由 * * @module store/modules/menu.store - * @author FastapiAdmin Team + * @author DPB 团队 */ import { defineStore } from "pinia"; import { ref, computed } from "vue"; diff --git a/frontend/web/src/store/modules/notice.store.ts b/frontend/web/src/store/modules/notice.store.ts index af35c9b..c2797f7 100644 --- a/frontend/web/src/store/modules/notice.store.ts +++ b/frontend/web/src/store/modules/notice.store.ts @@ -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"; diff --git a/frontend/web/src/store/modules/worktab.store.ts b/frontend/web/src/store/modules/worktab.store.ts index 0294fdc..b1e3748 100644 --- a/frontend/web/src/store/modules/worktab.store.ts +++ b/frontend/web/src/store/modules/worktab.store.ts @@ -35,7 +35,7 @@ * - 刷新页面保持标签状态 * * @module store/modules/worktab.store - * @author FastapiAdmin Team + * @author DPB 团队 */ import { defineStore } from "pinia"; import { ref, computed } from "vue"; diff --git a/frontend/web/src/types/index.ts b/frontend/web/src/types/index.ts index 5b936a6..f64a065 100644 --- a/frontend/web/src/types/index.ts +++ b/frontend/web/src/types/index.ts @@ -3,7 +3,7 @@ * 提供全局类型定义的统一导出入口 * * @module types/index - * @author FastapiAdmin Team + * @author DPB 团队 */ /** AI 相关类型定义 */ diff --git a/frontend/web/src/utils/constants/definitions.ts b/frontend/web/src/utils/constants/definitions.ts index 077b16d..36b8e02 100644 --- a/frontend/web/src/utils/constants/definitions.ts +++ b/frontend/web/src/utils/constants/definitions.ts @@ -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, }; diff --git a/frontend/web/src/utils/socket/index.ts b/frontend/web/src/utils/socket/index.ts index b99b610..e466d37 100644 --- a/frontend/web/src/utils/socket/index.ts +++ b/frontend/web/src/utils/socket/index.ts @@ -7,7 +7,7 @@ * - 在线用户计数 WebSocket * - 其他业务 WebSocket * - * @author fastapiadmin + * @author DPB 团队 */ import { Auth } from "@utils/auth"; diff --git a/frontend/web/src/utils/sys/index.ts b/frontend/web/src/utils/sys/index.ts index f82c98c..7ecabca 100644 --- a/frontend/web/src/utils/sys/index.ts +++ b/frontend/web/src/utils/sys/index.ts @@ -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 { diff --git a/frontend/web/src/views/dashboard/home/index.vue b/frontend/web/src/views/dashboard/home/index.vue index 7257db0..05eb233 100644 --- a/frontend/web/src/views/dashboard/home/index.vue +++ b/frontend/web/src/views/dashboard/home/index.vue @@ -159,7 +159,7 @@ - FastapiAdmin · 智能运营数据监控平台 + DPB 桃育种系统 · 育种数据监测平台
diff --git a/frontend/web/src/views/fastlink/tutorial/index.vue b/frontend/web/src/views/fastlink/tutorial/index.vue index 068d0f4..0a767a8 100644 --- a/frontend/web/src/views/fastlink/tutorial/index.vue +++ b/frontend/web/src/views/fastlink/tutorial/index.vue @@ -92,7 +92,7 @@

- FastapiAdmin 功能点清单 + DPB 桃育种系统 功能点清单 用于全功能测试验收,按模块逐页列出所有可操作元素

@@ -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" >

- FastapiAdmin 功能点清单(完整性)— + DPB 桃育种系统 功能点清单(完整性)— 与当前代码中已实现界面项对齐,用于逐项核对是否漏测,不评价体验优劣。

diff --git a/frontend/web/src/views/module_ai/chat/components/FaWelcomeScreen.vue b/frontend/web/src/views/module_ai/chat/components/FaWelcomeScreen.vue index 2d6bea0..e683004 100644 --- a/frontend/web/src/views/module_ai/chat/components/FaWelcomeScreen.vue +++ b/frontend/web/src/views/module_ai/chat/components/FaWelcomeScreen.vue @@ -36,7 +36,7 @@ interface Emits { const emit = defineEmits(); const promptCards = [ - { title: "系统介绍", body: "请介绍一下FastApiAdmin系统", prompt: "请介绍一下FastApiAdmin系统" }, + { title: "系统介绍", body: "请介绍一下DPB桃育种系统", prompt: "请介绍一下DPB桃育种系统" }, { title: "开发指导", body: "如何在系统中创建新的模块?", prompt: "如何在系统中创建新的模块?" }, { title: "权限管理", diff --git a/frontend/web/src/views/module_system/auth/login/index.vue b/frontend/web/src/views/module_system/auth/login/index.vue index e67a8ec..797a8fb 100644 --- a/frontend/web/src/views/module_system/auth/login/index.vue +++ b/frontend/web/src/views/module_system/auth/login/index.vue @@ -488,10 +488,9 @@ let notificationInstance: ReturnType | null = null; const showVoteNotification = () => { notificationInstance = msg.notify({ - title: "⭐ FastapiAdmin 完全开源 · 期待您的 Star 支持 🙏", - message: `项目持续迭代中,若对您有所帮助,欢迎点亮 Star 支持! -
Github仓库 → -
Gitee仓库 →`, + title: "⭐ DPB 桃育种系统 · 功能持续迭代中 🙏", + message: `项目持续迭代中,若有问题或建议欢迎反馈! +
项目仓库 →`, type: "success", position: panelAlign.value === "right" || panelAlign.value === "center" diff --git a/run_ci.py b/run_ci.py index 30b1357..321ea7d 100644 --- a/run_ci.py +++ b/run_ci.py @@ -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 @@ -101,12 +126,36 @@ def main() -> int: 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(" · 可用 TC_CI_DB_HOST / TC_CI_DB_PORT 等环境变量临时覆盖探测地址") - return 1 + 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 # ---- step 1: pytest -m pg ---- os.makedirs(LOG_DIR, exist_ok=True)