feat(phase5): 解耦与收尾清理
- core/redis_client.py 移除 scheduler 耦合,lifespan 由 core/app.py 组合(Redis→调度器→Redis) - main.py 改从 core.app 导入 create_app - 删除 apps/、extensions/ 目录;utils 删除已迁移垫片与死代码 formatter.py - 新增 deploy/(nginx、supervisor)、scripts/(init_db、seed)、logs/(结构化日志落盘) - core/app.py 接入 core.logger setup_logging - 更新 CLAUDE.md 反映 core/shared/modules 新结构 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -53,30 +53,30 @@ async def add(request: Request, data: LevelForm):
|
|||||||
return await level.LevelAdd(request, data)
|
return await level.LevelAdd(request, data)
|
||||||
```
|
```
|
||||||
|
|
||||||
Validation errors are caught by a global `RequestValidationError` handler registered in `apps/__init__.py` and converted to the standard response shape `{"code": -1, "data": null, "msg": "field: message/..."}` with HTTP 200. Services receive the validated Pydantic instance and use `data.model_dump()` (full dict), `data.model_dump(exclude={'id'})` (for updates), or direct attribute access (`data.id`, `data.status`).
|
Validation errors are caught by a global `RequestValidationError` handler registered in `core/app.py` and converted to the standard response shape `{"code": -1, "data": null, "msg": "field: message/..."}` with HTTP 200. Services receive the validated Pydantic instance and use `data.model_dump()` (full dict), `data.model_dump(exclude={'id'})` (for updates), or direct attribute access (`data.id`, `data.status`).
|
||||||
|
|
||||||
### Key Directories
|
### Key Directories
|
||||||
|
|
||||||
- **`config/`** - Configuration split into sub-modules: `app.py` (app settings, CORS, upload limits), `database.py` (DB connection, pool config, `SQLALCHEMY_MYSQL_URL`), `redis.py`, `auth.py` (JWT), `captcha.py`, `email.py`, `sms.py`. All loaded from `.env` via `python-dotenv`. `config/__init__.py` loads `.env` first, then re-exports everything.
|
- **`config/`** - Configuration split into sub-modules: `app.py` (app settings, CORS, upload limits), `database.py` (DB connection, pool config, `SQLALCHEMY_MYSQL_URL`), `redis.py`, `auth.py` (JWT), `captcha.py`, `email.py`, `sms.py`. All loaded from `.env` via `python-dotenv`. `config/__init__.py` loads `.env` first, then re-exports everything.
|
||||||
- **`extensions/`** - Framework extensions: SQLAlchemy engine/session/contextvars (`extensions/sqlalchemy.py`), custom exception class (`extensions/exception.py`), `get_db_session()` accessor.
|
- **`core/`** - Core infrastructure: `database.py` (engine/SessionLocal/`_db_ctx`/`get_db_session()`/`commit_independently`/`Base`), `redis_client.py` (`get_redis()`/`redis_lifespan`/`redis_middleware`), `jwt.py` (JWT create/parse/refresh/blacklist with HS256), `password.py` (bcrypt hashing with salt), `response.py` (`R.ok()`/`R.failed()`), `exceptions.py` (AuthorizationException/BusinessException), `security.py` (`get_user_id`/`get_username`), `middleware.py` (`login_required`/`permission_required`/`check_demo`/`db_session_middleware`/`operation_log_middleware`/`register_cros`), `deps.py` (DI), `logger.py` (structured logging), `app.py` (create_app factory + lifespan).
|
||||||
- **`routers/v1.py`** - Central route registration. All module routers are included here with prefix paths.
|
- **`shared/`** - Cross-module shared layer: `enums.py` (enum dicts), `constants.py` (PAGE_SIZE/DEFAULT_PASSWORD/SERIALIZE_EXCLUDE_FIELDS), `pagination.py`, `base_model.py`, `base_db.py`, `base_repository.py` (Generic repository base), `base_service.py`.
|
||||||
- **`apps/middleware/`** - `login_required` (JWT auth dependency, ignores `/login` and `/captcha`), `permission_required` (decorator checking user's permission nodes; admin userId=1 bypasses all checks), `check_demo` (decorator that blocks write operations when `FASTAPI_DEMO=True`), `db_session` (Starlette middleware setting request-scoped DB session via contextvars).
|
- **`modules/`** - Business modules, each a directory with `router.py` (may export multiple routers, e.g. `dict_router` + `dict_item_router`), `schemas.py`, `service.py`, `repository.py`, `models.py`. Module groups: `auth/`, `system/` (user, role, menu, dept, position, level), `dictionary/`, `configuration/`, `cms/` (item, item_cate, ad, ad_sort), `member/`, `region/`, `link/`, `notice/`, `job/` (job + job_log + scheduler + job_executor), `log/`, `upload/`, `index/`.
|
||||||
- **`utils/`** - `response` (JSON response helpers: `R.ok()`, `R.failed()`), `jwt` (JWT create/parse/refresh/blacklist with HS256), `password` (bcrypt-based password hashing with salt), `security` (`get_user_id(request)`, `get_username(request)` — reads from `request.state` set by `login_required`), `request` (`parse_batch_ids`, `like_escape`, `like_contains` for safe LIKE queries), `captcha` (verification code generation), `file` (file operations), `redis_ctx` (Redis connection pool lifecycle and request-scoped context via `get_redis()`), `ip`, `formatter`, `rich_text`.
|
- **`routers/`** - `v1.py` central route registration (imports routers from `modules/`), `__init__.py` mounts login (免认证) on app and v1 behind `login_required`.
|
||||||
- **`apps/constants/`** - `constants.py` (enum dicts for gender, dept types, menu types, etc.), `message.py` (default page size, messages, `SERIALIZE_EXCLUDE_FIELDS`).
|
- **`utils/`** - Remaining utilities that stay here: `request` (`parse_batch_ids`, `like_escape`, `like_contains`), `captcha`, `file`, `ip`/`ip2region`, `ua`, `rich_text`, `perm_cache`. `R`/`password` are re-exported from `core` via `utils/__init__.py` for back-compat.
|
||||||
|
|
||||||
### Database Access
|
### Database Access
|
||||||
|
|
||||||
Database sessions are request-scoped via `contextvars` + Starlette middleware (`apps/middleware/db_session.py`). Services call `db = get_db_session()` to get the current request's session. Models use `self.save()` / `self.delete()` from `base_db`. The middleware auto-closes the session after each request (with rollback). Soft deletes via `is_delete` flag (0=active, 1=deleted).
|
Database sessions are request-scoped via `contextvars` + Starlette middleware (`core/middleware.py: db_session_middleware`). Services call `db = get_db_session()` (from `core.database`) to get the current request's session. Models use `self.save()` / `self.delete()` from `shared/base_db.py`. The middleware auto-closes the session after each request (with rollback). Soft deletes via `is_delete` flag (0=active, 1=deleted).
|
||||||
|
|
||||||
### Redis Access
|
### Redis Access
|
||||||
|
|
||||||
Redis uses async client via `utils/redis_ctx.py`. Connection pool created in `redis_lifespan` (app lifespan), injected per-request via `redis_middleware` into contextvars. Business code calls `get_redis()` to get the current request's async Redis client.
|
Redis uses async client via `core/redis_client.py`. Connection pool created in `redis_lifespan` (composed with scheduler startup in `core/app.py` lifespan), injected per-request via `redis_middleware` into contextvars. Business code calls `get_redis()` to get the current request's async Redis client.
|
||||||
|
|
||||||
### Authentication & Authorization
|
### Authentication & Authorization
|
||||||
|
|
||||||
- JWT tokens via `utils/jwt.py` (HS256, configurable expiry via JWT_EXPIRE_MINUTES, secret via JWT_SALT env var). Token sent as `Authorization: Bearer <token>` header. Supports token refresh and blacklist (logout) via Redis.
|
- JWT tokens via `core/jwt.py` (HS256, configurable expiry via JWT_EXPIRE_MINUTES, secret via JWT_SALT env var). Token sent as `Authorization: Bearer <token>` header. Supports token refresh and blacklist (logout) via Redis.
|
||||||
- `login_required` is a FastAPI `Depends` applied globally to the v1 router. On success, it sets `request.state.user_id`, `request.state.username`, `request.state.realname`.
|
- `login_required` is a FastAPI `Depends` applied globally to the v1 router. On success, it sets `request.state.user_id`, `request.state.username`, `request.state.realname`.
|
||||||
- `permission_required` is a per-endpoint decorator checking permission strings like `"sys:level:list"`. User ID 1 (admin) bypasses all permission checks.
|
- `permission_required` is a per-endpoint decorator checking permission strings like `"sys:level:list"`. User ID 1 (admin) bypasses all permission checks. It lazily queries `modules.system.menu.service.get_permissions_list_sync`.
|
||||||
- `check_demo` is a per-endpoint decorator that returns `R.failed("演示环境,暂无操作权限")` when `FASTAPI_DEMO=True`. Apply it after `@permission_required` on all write endpoints (add, update, delete, status, import, export).
|
- `check_demo` is a per-endpoint decorator that returns `R.failed("演示环境,暂无操作权限")` when `FASTAPI_DEMO=True`. Apply it after `@permission_required` on all write endpoints (add, update, delete, status, import, export).
|
||||||
|
|
||||||
### API Response Format
|
### API Response Format
|
||||||
@@ -98,12 +98,12 @@ Single delete: `DELETE /delete/{id}` where id can be comma-separated (e.g., `"1,
|
|||||||
|
|
||||||
## Adding a New Module
|
## Adding a New Module
|
||||||
|
|
||||||
1. Create `apps/models/{name}.py` - model class inheriting `base_model` and `base_db`, set `__tablename__ = DB_PREFIX + "{name}"`
|
1. Create `modules/{group}/{name}/models.py` - model class inheriting `base_model` and `base_db` (from `shared/`), set `__tablename__ = DB_PREFIX + "{name}"`
|
||||||
2. Create `apps/forms/{name}.py` - Pydantic `BaseModel` validation classes with `Field(...)` constraints and `@field_validator` for custom checks
|
2. Create `modules/{group}/{name}/schemas.py` - Pydantic `BaseModel` validation classes with `Field(...)` constraints and `@field_validator` for custom checks
|
||||||
3. Create `apps/services/{name}.py` - CRUD service functions following the `get_level_page`/`get_level_detail`/`add_level`/`update_level`/`delete_level`/`update_level_status` pattern
|
3. Create `modules/{group}/{name}/repository.py` - subclass `shared.base_repository.BaseRepository` + module-level instance
|
||||||
4. Create `apps/views/{name}.py` - APIRouter with endpoints, apply `@permission_required` and `@check_demo` on write endpoints
|
4. Create `modules/{group}/{name}/service.py` - CRUD service functions following the `get_level_page`/`get_level_detail`/`add_level`/`update_level`/`delete_level`/`update_level_status` pattern
|
||||||
5. Register the router in `routers/v1.py` - `v1.include_router({name}.router, prefix="/{prefix}", tags=["标签"])`
|
5. Create `modules/{group}/{name}/router.py` - APIRouter with endpoints, apply `@permission_required` and `@check_demo` on write endpoints
|
||||||
6. Import the view in `routers/v1.py`
|
6. Register the router in `routers/v1.py` - `v1.include_router({name}_router, prefix="/{prefix}", tags=["标签"])`
|
||||||
|
|
||||||
## Key Dependencies
|
## Key Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:应用工厂已迁移到 core/app.py,保留旧导入路径
|
|
||||||
from core.app import create_app
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 shared/enums.py 迁移,保留旧导入路径
|
|
||||||
from shared.enums import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 shared/constants.py 迁移,保留旧导入路径
|
|
||||||
from shared.constants import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.ad.schemas 迁移
|
|
||||||
from modules.cms.ad.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.ad_sort.schemas 迁移
|
|
||||||
from modules.cms.ad_sort.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.region.schemas 迁移
|
|
||||||
from modules.region.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.configuration.schemas 迁移
|
|
||||||
from modules.configuration.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.configuration.schemas 迁移
|
|
||||||
from modules.configuration.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.dept.schemas 迁移
|
|
||||||
from modules.system.dept.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.dictionary.schemas 迁移
|
|
||||||
from modules.dictionary.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.dictionary.schemas 迁移
|
|
||||||
from modules.dictionary.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.item.schemas 迁移
|
|
||||||
from modules.cms.item.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.item_cate.schemas 迁移
|
|
||||||
from modules.cms.item_cate.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.job.schemas 迁移
|
|
||||||
from modules.job.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.level.schemas 迁移
|
|
||||||
from modules.system.level.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.link.schemas 迁移
|
|
||||||
from modules.link.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.auth.schemas 迁移
|
|
||||||
from modules.auth.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.member.schemas 迁移
|
|
||||||
from modules.member.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.member.schemas 迁移
|
|
||||||
from modules.member.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.menu.schemas 迁移
|
|
||||||
from modules.system.menu.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.notice.schemas 迁移
|
|
||||||
from modules.notice.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.position.schemas 迁移
|
|
||||||
from modules.system.position.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.role.schemas 迁移
|
|
||||||
from modules.system.role.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.role.schemas 迁移
|
|
||||||
from modules.system.role.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.user.schemas 迁移
|
|
||||||
from modules.system.user.schemas import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.ad.service 迁移
|
|
||||||
from modules.cms.ad.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.ad_sort.service 迁移
|
|
||||||
from modules.cms.ad_sort.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.region.service 迁移
|
|
||||||
from modules.region.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.configuration.service 迁移
|
|
||||||
from modules.configuration.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.configuration.service 迁移
|
|
||||||
from modules.configuration.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.dept.service 迁移
|
|
||||||
from modules.system.dept.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.dictionary.service 迁移
|
|
||||||
from modules.dictionary.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.dictionary.service 迁移
|
|
||||||
from modules.dictionary.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.item.service 迁移
|
|
||||||
from modules.cms.item.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.item_cate.service 迁移
|
|
||||||
from modules.cms.item_cate.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.job.service 迁移
|
|
||||||
from modules.job.service import * # noqa: F401,F403
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
"""
|
|
||||||
任务执行器模块
|
|
||||||
|
|
||||||
根据触发器标识执行对应任务的功能。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class JobExecutor:
|
|
||||||
"""任务执行器:根据触发器标识调用对应的内部方法执行任务"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._handlers = {}
|
|
||||||
self._register_default_handlers()
|
|
||||||
|
|
||||||
def _register_default_handlers(self):
|
|
||||||
"""注册默认的任务处理器"""
|
|
||||||
self.register('send_sms', self._send_sms)
|
|
||||||
self.register('send_email', self._send_email)
|
|
||||||
self.register('sync_data', self._sync_data)
|
|
||||||
|
|
||||||
def register(self, trigger: str, handler):
|
|
||||||
"""注册任务处理器"""
|
|
||||||
self._handlers[trigger] = handler
|
|
||||||
logger.info(f"注册任务处理器: {trigger}")
|
|
||||||
|
|
||||||
def execute(self, trigger: str, **kwargs) -> Any:
|
|
||||||
"""执行任务"""
|
|
||||||
if trigger not in self._handlers:
|
|
||||||
raise KeyError(f"未注册的任务触发器: {trigger}(可用 job_executor.register() 注册处理器)")
|
|
||||||
|
|
||||||
handler = self._handlers[trigger]
|
|
||||||
logger.info(f"执行任务: {trigger}, 参数: {kwargs}")
|
|
||||||
return handler(**kwargs)
|
|
||||||
|
|
||||||
def _send_sms(self, **kwargs) -> Dict:
|
|
||||||
"""发送短信任务"""
|
|
||||||
logger.info(f"发送短信 [任务ID: {kwargs.get('job_id')}]")
|
|
||||||
return {'success': True, 'message': '短信发送成功'}
|
|
||||||
|
|
||||||
def _send_email(self, **kwargs) -> Dict:
|
|
||||||
"""发送邮件任务"""
|
|
||||||
logger.info(f"发送邮件 [任务ID: {kwargs.get('job_id')}]")
|
|
||||||
return {'success': True, 'message': '邮件发送成功'}
|
|
||||||
|
|
||||||
def _sync_data(self, **kwargs) -> Dict:
|
|
||||||
"""数据同步任务"""
|
|
||||||
logger.info(f"数据同步 [任务ID: {kwargs.get('job_id')}]")
|
|
||||||
return {'success': True, 'message': '数据同步成功'}
|
|
||||||
|
|
||||||
|
|
||||||
# 全局执行器实例
|
|
||||||
job_executor = JobExecutor()
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.job.service 迁移
|
|
||||||
from modules.job.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.level.service 迁移
|
|
||||||
from modules.system.level.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.link.service 迁移
|
|
||||||
from modules.link.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.auth.service 迁移
|
|
||||||
from modules.auth.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.log.service 迁移
|
|
||||||
from modules.log.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.member.service 迁移
|
|
||||||
from modules.member.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.member.service 迁移
|
|
||||||
from modules.member.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.menu.service 迁移
|
|
||||||
from modules.system.menu.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.notice.service 迁移
|
|
||||||
from modules.notice.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.log.service 迁移
|
|
||||||
from modules.log.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.position.service 迁移
|
|
||||||
from modules.system.position.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.role.service 迁移
|
|
||||||
from modules.system.role.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.role.service 迁移
|
|
||||||
from modules.system.role.service import * # noqa: F401,F403
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
"""
|
|
||||||
定时任务调度核心模块
|
|
||||||
|
|
||||||
使用 APScheduler 管理和执行定时任务。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
|
||||||
from apscheduler.jobstores.base import JobLookupError
|
|
||||||
|
|
||||||
from config import FASTAPI_HOST, FASTAPI_PORT
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# 调度器实例
|
|
||||||
_scheduler: Optional[BackgroundScheduler] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_scheduler() -> BackgroundScheduler:
|
|
||||||
"""获取调度器单例"""
|
|
||||||
global _scheduler
|
|
||||||
if _scheduler is None:
|
|
||||||
_scheduler = BackgroundScheduler(timezone='Asia/Shanghai')
|
|
||||||
return _scheduler
|
|
||||||
|
|
||||||
|
|
||||||
def build_full_url(url: str) -> str:
|
|
||||||
"""构建完整URL,将相对路径拼接为带域名的URL"""
|
|
||||||
if not url:
|
|
||||||
return url
|
|
||||||
if url.startswith(('http://', 'https://')):
|
|
||||||
return url
|
|
||||||
if url.startswith('/'):
|
|
||||||
url = f"http://{FASTAPI_HOST}:{FASTAPI_PORT}{url}"
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 任务执行核心函数
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
def execute_job(
|
|
||||||
job_id: int,
|
|
||||||
url: Optional[str] = None,
|
|
||||||
is_sync: int = 0,
|
|
||||||
job_trigger: Optional[str] = None,
|
|
||||||
params: Optional[Dict] = None
|
|
||||||
) -> Dict:
|
|
||||||
"""执行任务(调度器调用入口),执行完成后写入任务日志"""
|
|
||||||
start_time = datetime.now()
|
|
||||||
result = {'success': False, 'error': '未知错误'}
|
|
||||||
try:
|
|
||||||
logger.info(f"执行定时任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}]")
|
|
||||||
|
|
||||||
if url:
|
|
||||||
result = _execute_by_url(job_id, url, is_sync, params)
|
|
||||||
elif job_trigger:
|
|
||||||
result = _execute_by_trigger(job_trigger, job_id, params)
|
|
||||||
else:
|
|
||||||
result = {'success': False, 'error': '任务既没有URL也没有触发器'}
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"执行任务异常 [ID: {job_id}]: {str(e)}")
|
|
||||||
result = {'success': False, 'error': str(e)}
|
|
||||||
return result
|
|
||||||
|
|
||||||
finally:
|
|
||||||
_record_execution_log(job_id, result, start_time)
|
|
||||||
|
|
||||||
|
|
||||||
def _record_execution_log(job_id: int, result: Dict, start_time: datetime):
|
|
||||||
"""记录一次任务执行日志"""
|
|
||||||
try:
|
|
||||||
from apps.services.job_log import record_job_log
|
|
||||||
|
|
||||||
end_time = datetime.now()
|
|
||||||
success = bool(result.get('success'))
|
|
||||||
status = 0 if success else 1
|
|
||||||
if not success:
|
|
||||||
job_message = '任务执行失败'
|
|
||||||
exception_info = str(result.get('error'))
|
|
||||||
elif result.get('status') == 'triggered':
|
|
||||||
job_message = '异步任务已触发'
|
|
||||||
exception_info = None
|
|
||||||
else:
|
|
||||||
job_message = '任务执行成功'
|
|
||||||
exception_info = None
|
|
||||||
|
|
||||||
record_job_log(job_id, start_time, end_time, status,
|
|
||||||
exception_info=exception_info, job_message=job_message)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"记录任务日志失败 [ID: {job_id}]: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def _execute_by_url(job_id: int, url: str, is_sync: int, params: Optional[Dict] = None) -> Dict:
|
|
||||||
"""通过URL方式执行任务"""
|
|
||||||
full_url = build_full_url(url)
|
|
||||||
request_data = {
|
|
||||||
'job_id': job_id,
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
'params': params or {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_sync == 1:
|
|
||||||
return _send_sync_request(full_url, request_data, job_id)
|
|
||||||
else:
|
|
||||||
return _send_async_request(full_url, request_data, job_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _send_sync_request(url: str, data: Dict, job_id: int) -> Dict:
|
|
||||||
"""发送同步HTTP请求"""
|
|
||||||
try:
|
|
||||||
logger.info(f"发送同步请求 [ID: {job_id}, URL: {url}]")
|
|
||||||
headers = {'Content-Type': 'application/json', 'User-Agent': 'Scheduler/1.0'}
|
|
||||||
response = requests.post(url, json=data, timeout=30, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
try:
|
|
||||||
result_data = response.json()
|
|
||||||
except Exception:
|
|
||||||
result_data = {'text': response.text[:200]}
|
|
||||||
|
|
||||||
return {'success': True, 'status_code': response.status_code, 'data': result_data}
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
return {'success': False, 'error': str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
def _send_async_request(url: str, data: Dict, job_id: int) -> Dict:
|
|
||||||
"""发送异步HTTP请求(独立线程)"""
|
|
||||||
|
|
||||||
def async_request():
|
|
||||||
try:
|
|
||||||
headers = {'Content-Type': 'application/json'}
|
|
||||||
resp = requests.post(url, json=data, timeout=30, headers=headers)
|
|
||||||
logger.info(f"异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"异步任务异常 [ID: {job_id}]: {str(e)}")
|
|
||||||
|
|
||||||
thread = threading.Thread(target=async_request)
|
|
||||||
thread.start()
|
|
||||||
return {'success': True, 'status': 'triggered'}
|
|
||||||
|
|
||||||
|
|
||||||
def _execute_by_trigger(trigger: str, job_id: int, params: Optional[Dict] = None) -> Dict:
|
|
||||||
"""通过触发器方式执行任务"""
|
|
||||||
try:
|
|
||||||
from apps.services.job_executor import job_executor
|
|
||||||
# 剔除 job_id,避免与 execute(job_id=...) 关键字参数冲突
|
|
||||||
trigger_params = dict(params or {})
|
|
||||||
trigger_params.pop('job_id', None)
|
|
||||||
result = job_executor.execute(trigger, job_id=job_id, **trigger_params)
|
|
||||||
# 处理器返回 success=False 时透传失败,而非一律视为成功
|
|
||||||
if isinstance(result, dict) and result.get('success') is False:
|
|
||||||
error = result.get('message') or result.get('error') or '任务执行失败'
|
|
||||||
return {'success': False, 'trigger': trigger, 'error': error}
|
|
||||||
return {'success': True, 'trigger': trigger, 'data': result}
|
|
||||||
except Exception as e:
|
|
||||||
return {'success': False, 'error': str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 调度器任务管理函数
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
def _parse_job_params(params: Optional[str]) -> Dict:
|
|
||||||
"""解析任务参数(JSON字符串 -> dict),容错非法输入"""
|
|
||||||
if not params:
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
parsed = json.loads(params)
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
return parsed
|
|
||||||
except Exception:
|
|
||||||
logger.warning(f"任务参数不是合法JSON,已忽略: {str(params)[:200]}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_misfire_config(execute_policy) -> Dict:
|
|
||||||
"""根据执行策略映射 APScheduler 的 misfire/coalesce 参数"""
|
|
||||||
if execute_policy == 3:
|
|
||||||
# 放弃执行:错过的运行不补
|
|
||||||
return {'misfire_grace_time': 0, 'coalesce': True}
|
|
||||||
if execute_policy == 2:
|
|
||||||
# 执行一次:短时间内错过的补跑一次
|
|
||||||
return {'misfire_grace_time': 600, 'coalesce': True}
|
|
||||||
# 立即执行(默认):错过立即补跑一次
|
|
||||||
return {'misfire_grace_time': 3600, 'coalesce': True}
|
|
||||||
|
|
||||||
|
|
||||||
def add_job_to_scheduler(job_dict: dict) -> bool:
|
|
||||||
"""添加任务到调度器"""
|
|
||||||
try:
|
|
||||||
job_id = job_dict.get('id')
|
|
||||||
job_status = job_dict.get('status')
|
|
||||||
job_cron = job_dict.get('cron_expression')
|
|
||||||
job_url = job_dict.get('url')
|
|
||||||
job_sync = job_dict.get('is_sync', 0)
|
|
||||||
job_trigger = job_dict.get('job_trigger')
|
|
||||||
job_name = job_dict.get('job_name', '')
|
|
||||||
job_params = _parse_job_params(job_dict.get('params'))
|
|
||||||
|
|
||||||
if job_status != 1:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not job_cron:
|
|
||||||
logger.warning(f"跳过添加任务: Cron表达式为空 (id={job_id})")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 验证Cron表达式
|
|
||||||
try:
|
|
||||||
trigger = CronTrigger.from_crontab(job_cron)
|
|
||||||
next_run = trigger.get_next_fire_time(None, datetime.now())
|
|
||||||
if next_run is None:
|
|
||||||
logger.warning(f"Cron表达式不会触发 (id={job_id})")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Cron表达式无效 (id={job_id}): {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
scheduler = get_scheduler()
|
|
||||||
scheduler_job_id = f"job_{job_id}"
|
|
||||||
|
|
||||||
# 移除已存在的同名任务
|
|
||||||
try:
|
|
||||||
scheduler.remove_job(scheduler_job_id)
|
|
||||||
except JobLookupError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
misfire_config = _build_misfire_config(job_dict.get('execute_policy', 1))
|
|
||||||
|
|
||||||
scheduler.add_job(
|
|
||||||
func=execute_job,
|
|
||||||
trigger=trigger,
|
|
||||||
args=[job_id, job_url, job_sync, job_trigger, job_params],
|
|
||||||
id=scheduler_job_id,
|
|
||||||
name=job_name,
|
|
||||||
replace_existing=True,
|
|
||||||
**misfire_config
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"任务已添加到调度器 [ID: {job_id}, cron: {job_cron}]")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"添加任务到调度器异常: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def remove_job_from_scheduler(job_id: int) -> bool:
|
|
||||||
"""从调度器移除任务"""
|
|
||||||
try:
|
|
||||||
get_scheduler().remove_job(f"job_{job_id}")
|
|
||||||
return True
|
|
||||||
except JobLookupError:
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"移除任务异常: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def pause_job_in_scheduler(job_id: int) -> bool:
|
|
||||||
"""暂停调度器中的任务"""
|
|
||||||
try:
|
|
||||||
get_scheduler().pause_job(f"job_{job_id}")
|
|
||||||
return True
|
|
||||||
except JobLookupError:
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"暂停任务异常: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def resume_job_in_scheduler(job_id: int) -> bool:
|
|
||||||
"""恢复调度器中的任务"""
|
|
||||||
try:
|
|
||||||
get_scheduler().resume_job(f"job_{job_id}")
|
|
||||||
return True
|
|
||||||
except JobLookupError:
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"恢复任务异常: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def run_job_once(job_id: int, params: Optional[Dict] = None) -> Dict:
|
|
||||||
"""立即执行一次任务(不依赖调度器)"""
|
|
||||||
try:
|
|
||||||
from apps.models.job import Job
|
|
||||||
from extensions import get_db_session
|
|
||||||
|
|
||||||
db = get_db_session()
|
|
||||||
job = db.query(Job).filter(Job.id == job_id, Job.is_delete == 0).first()
|
|
||||||
if not job:
|
|
||||||
return {'success': False, 'error': '任务不存在'}
|
|
||||||
|
|
||||||
return execute_job(job.id, job.url, job.is_sync, job.job_trigger, params or {})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {'success': False, 'error': str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 调度器初始化
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
def init_scheduler() -> int:
|
|
||||||
"""初始化调度器,启动并从数据库加载所有启用的任务"""
|
|
||||||
try:
|
|
||||||
from apps.models.job import Job
|
|
||||||
from extensions.sqlalchemy import SessionLocal
|
|
||||||
|
|
||||||
scheduler = get_scheduler()
|
|
||||||
if not scheduler.running:
|
|
||||||
scheduler.start()
|
|
||||||
logger.info("调度器已启动")
|
|
||||||
|
|
||||||
# lifespan 启动阶段无请求上下文,需用独立会话而非请求级 get_db_session()
|
|
||||||
session = SessionLocal()
|
|
||||||
try:
|
|
||||||
jobs = session.query(Job).filter(Job.status == 1, Job.is_delete == 0).all()
|
|
||||||
# 转为字典避免 Session 分离问题
|
|
||||||
job_dicts = [job.to_dict() for job in jobs]
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
success_count = 0
|
|
||||||
for job_dict in job_dicts:
|
|
||||||
if add_job_to_scheduler(job_dict):
|
|
||||||
success_count += 1
|
|
||||||
|
|
||||||
logger.info(f"调度器初始化完成,加载 {success_count}/{len(job_dicts)} 个任务")
|
|
||||||
return success_count
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"初始化调度器异常: {str(e)}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def shutdown_scheduler():
|
|
||||||
"""关闭调度器"""
|
|
||||||
try:
|
|
||||||
scheduler = get_scheduler()
|
|
||||||
if scheduler.running:
|
|
||||||
scheduler.shutdown(wait=False)
|
|
||||||
logger.info("调度器已关闭")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"关闭调度器异常: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def reload_scheduler() -> int:
|
|
||||||
"""重新加载调度器:清空所有任务,重新从数据库加载"""
|
|
||||||
try:
|
|
||||||
scheduler = get_scheduler()
|
|
||||||
scheduler.remove_all_jobs()
|
|
||||||
return init_scheduler()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"重新加载调度器异常: {str(e)}")
|
|
||||||
return 0
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.upload.service 迁移
|
|
||||||
from modules.upload.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.user.service 迁移
|
|
||||||
from modules.system.user.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.user.service 迁移
|
|
||||||
from modules.system.user.service import * # noqa: F401,F403
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.ad.router 迁移
|
|
||||||
from modules.cms.ad.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.ad_sort.router 迁移
|
|
||||||
from modules.cms.ad_sort.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.region.router 迁移
|
|
||||||
from modules.region.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.configuration.router 迁移
|
|
||||||
from modules.configuration.router import config_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.configuration.router 迁移
|
|
||||||
from modules.configuration.router import config_item_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.dept.router 迁移
|
|
||||||
from modules.system.dept.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.dictionary.router 迁移
|
|
||||||
from modules.dictionary.router import dict_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.dictionary.router 迁移
|
|
||||||
from modules.dictionary.router import dict_item_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.index.router 迁移
|
|
||||||
from modules.index.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.item.router 迁移
|
|
||||||
from modules.cms.item.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.cms.item_cate.router 迁移
|
|
||||||
from modules.cms.item_cate.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.job.router 迁移
|
|
||||||
from modules.job.router import job_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.job.router 迁移
|
|
||||||
from modules.job.router import job_log_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.level.router 迁移
|
|
||||||
from modules.system.level.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.link.router 迁移
|
|
||||||
from modules.link.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.auth.router 迁移
|
|
||||||
from modules.auth.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.log.router 迁移
|
|
||||||
from modules.log.router import login_log_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.member.router 迁移
|
|
||||||
from modules.member.router import member_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.member.router 迁移
|
|
||||||
from modules.member.router import member_level_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.menu.router 迁移
|
|
||||||
from modules.system.menu.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.notice.router 迁移
|
|
||||||
from modules.notice.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.log.router 迁移
|
|
||||||
from modules.log.router import operation_log_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.position.router 迁移
|
|
||||||
from modules.system.position.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.role.router 迁移
|
|
||||||
from modules.system.role.router import role_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.role.router 迁移
|
|
||||||
from modules.system.role.router import role_menu_router as router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.upload.router 迁移
|
|
||||||
from modules.upload.router import router
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 modules.system.user.router 迁移
|
|
||||||
from modules.system.user.router import router
|
|
||||||
+23
-1
@@ -8,7 +8,9 @@
|
|||||||
|
|
||||||
"""应用工厂:create_app 组装中间件、路由、异常处理与 lifespan。"""
|
"""应用工厂:create_app 组装中间件、路由、异常处理与 lifespan。"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
@@ -18,6 +20,7 @@ from starlette.responses import JSONResponse
|
|||||||
|
|
||||||
from config.app import FASTAPI_DEBUG
|
from config.app import FASTAPI_DEBUG
|
||||||
from core.exceptions import AuthorizationException, BusinessException
|
from core.exceptions import AuthorizationException, BusinessException
|
||||||
|
from core.logger import setup_logging
|
||||||
from core.middleware import (
|
from core.middleware import (
|
||||||
register_middleware,
|
register_middleware,
|
||||||
register_cros,
|
register_cros,
|
||||||
@@ -28,12 +31,31 @@ from core.redis_client import redis_lifespan, redis_middleware
|
|||||||
from routers import register_router
|
from routers import register_router
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _combined_lifespan(app: FastAPI):
|
||||||
|
"""组合 lifespan:先起 Redis,再启动定时任务调度器;退出时先停调度器再关 Redis。"""
|
||||||
|
async with redis_lifespan(app):
|
||||||
|
# 懒加载调度器,避免 core → 业务模块顶层依赖
|
||||||
|
from modules.job.scheduler import init_scheduler, shutdown_scheduler
|
||||||
|
|
||||||
|
job_count = init_scheduler()
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
f"定时任务调度器已启动,加载 {job_count} 个任务")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
shutdown_scheduler()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
# 初始化结构化日志(控制台 + logs/app.log)
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FastAPI",
|
title="FastAPI",
|
||||||
description="FastAPI",
|
description="FastAPI",
|
||||||
version="v1",
|
version="v1",
|
||||||
lifespan=redis_lifespan,
|
lifespan=_combined_lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 注册跨域中间件
|
# 注册跨域中间件
|
||||||
|
|||||||
@@ -56,14 +56,7 @@ async def redis_lifespan(app: FastAPI):
|
|||||||
print(f"Redis 连接成功 --> {REDIS_HOST}:{REDIS_PORT}/{REDIS_INDEX}")
|
print(f"Redis 连接成功 --> {REDIS_HOST}:{REDIS_PORT}/{REDIS_INDEX}")
|
||||||
logger.info(f"Redis 连接成功: {REDIS_HOST}:{REDIS_PORT}/{REDIS_INDEX}")
|
logger.info(f"Redis 连接成功: {REDIS_HOST}:{REDIS_PORT}/{REDIS_INDEX}")
|
||||||
|
|
||||||
# startup: 初始化定时任务调度器
|
|
||||||
from modules.job.scheduler import init_scheduler, shutdown_scheduler
|
|
||||||
job_count = init_scheduler()
|
|
||||||
logger.info(f"定时任务调度器已启动,加载 {job_count} 个任务")
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
# shutdown: 关闭调度器
|
|
||||||
shutdown_scheduler()
|
|
||||||
|
|
||||||
# shutdown: 关闭连接
|
# shutdown: 关闭连接
|
||||||
await redis.aclose()
|
await redis.aclose()
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Nginx 反向代理示例配置
|
||||||
|
# 将 8031 端口的 FastAPI 服务暴露到 80 端口
|
||||||
|
# 部署时将 server_name 与 proxy_pass 替换为实际值
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# 上传文件大小上限(与 .env 中 UPLOAD_* 保持一致)
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
# 静态资源(如前端 build 产物)可选配置
|
||||||
|
# location /assets/ {
|
||||||
|
# root /var/www/frontend/dist;
|
||||||
|
# }
|
||||||
|
|
||||||
|
# API 反向代理
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8031;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
; Supervisor 进程管理示例配置
|
||||||
|
; 将 command / directory 替换为实际路径后复制到 /etc/supervisor/conf.d/
|
||||||
|
|
||||||
|
[program:djangoadmin-fastapi]
|
||||||
|
; 启动命令:使用项目虚拟环境中的 uvicorn
|
||||||
|
command=/path/to/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8031
|
||||||
|
; 项目根目录
|
||||||
|
directory=/path/to/DjangoAdmin_FastAPI_EleVue
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
redirect_stderr=true
|
||||||
|
stdout_logfile=/path/to/logs/supervisor.out.log
|
||||||
|
stdout_logfile_maxbytes=50MB
|
||||||
|
stdout_logfile_backups=10
|
||||||
|
environment=PYTHONUNBUFFERED=1
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# 兼容垫片:由 core/ 迁移,保留旧导入路径
|
|
||||||
from core.database import get_db_session, commit_independently
|
|
||||||
|
|
||||||
|
|
||||||
def register_extends(app):
|
|
||||||
pass
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 兼容垫片:由 core/exceptions.py 迁移,保留旧导入路径
|
|
||||||
from core.exceptions import AuthorizationException, BusinessException
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# 兼容垫片:由 core/database.py 迁移,保留旧导入路径
|
|
||||||
from core.database import (
|
|
||||||
engine,
|
|
||||||
SessionLocal,
|
|
||||||
_db_ctx,
|
|
||||||
get_db_session,
|
|
||||||
commit_independently,
|
|
||||||
Base,
|
|
||||||
)
|
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
# | 许可协议》附件为准。
|
# | 许可协议》附件为准。
|
||||||
# +======================================================================
|
# +======================================================================
|
||||||
|
|
||||||
from apps import create_app
|
from core.app import create_app
|
||||||
from config import FASTAPI_HOST, FASTAPI_PORT
|
from config import FASTAPI_HOST, FASTAPI_PORT
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
数据库初始化脚本
|
||||||
|
=============================================================
|
||||||
|
|
||||||
|
功能:
|
||||||
|
1. 创建数据库(默认 utf8mb4)
|
||||||
|
2. 导入 document/djangoadmin.fastapi.elevue.sql(若存在)
|
||||||
|
3. 用模型 metadata 补建 SQL 中缺失的表(如 fastapi_login_log / fastapi_operation_log)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/init_db.py # 使用 .env 中的数据库配置
|
||||||
|
python scripts/init_db.py --drop-first # 先删除已存在的同名库再重建
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
def split_statements(content: str):
|
||||||
|
"""按 ';\\n' 切分语句,并剥离 -- 注释行,避免注释+语句整块被跳过。"""
|
||||||
|
for block in content.split(";\n"):
|
||||||
|
body = []
|
||||||
|
for line in block.splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if s.startswith("--") or s.startswith("#"):
|
||||||
|
continue
|
||||||
|
body.append(line)
|
||||||
|
stmt = "\n".join(body).strip()
|
||||||
|
if stmt:
|
||||||
|
yield stmt
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="初始化数据库")
|
||||||
|
parser.add_argument("--drop-first", action="store_true", help="先删除已存在库再重建")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 加载 .env 数据库配置
|
||||||
|
from config import DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_PREFIX
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from core.database import Base
|
||||||
|
|
||||||
|
# 加载全部模型(导入 create_app 会连带导入所有模块)
|
||||||
|
from core.app import create_app
|
||||||
|
|
||||||
|
create_app()
|
||||||
|
|
||||||
|
root_conn = pymysql.connect(host=DB_HOST, port=int(DB_PORT), user=DB_USERNAME, password=DB_PASSWORD)
|
||||||
|
cur = root_conn.cursor()
|
||||||
|
if args.drop_first:
|
||||||
|
cur.execute(f"DROP DATABASE IF EXISTS `{DB_DATABASE}`")
|
||||||
|
cur.execute(
|
||||||
|
f"CREATE DATABASE IF NOT EXISTS `{DB_DATABASE}` "
|
||||||
|
f"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
|
||||||
|
)
|
||||||
|
cur.execute(f"USE `{DB_DATABASE}`")
|
||||||
|
print(f"database {DB_DATABASE} ready (prefix={DB_PREFIX})")
|
||||||
|
|
||||||
|
sql_path = os.path.join("document", "djangoadmin.fastapi.elevue.sql")
|
||||||
|
if os.path.exists(sql_path):
|
||||||
|
with open(sql_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
done = errors = 0
|
||||||
|
for stmt in split_statements(content):
|
||||||
|
try:
|
||||||
|
cur.execute(stmt)
|
||||||
|
done += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
print(f"[SKIP] {e}\n {stmt[:100]}")
|
||||||
|
root_conn.commit()
|
||||||
|
print(f"imported {done} statements, errors={errors}")
|
||||||
|
else:
|
||||||
|
print(f"!! 未找到 {sql_path},跳过 SQL 导入")
|
||||||
|
|
||||||
|
# 用模型补建缺失表
|
||||||
|
engine = create_engine(
|
||||||
|
f"mysql+pymysql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_DATABASE}?charset=utf8mb4"
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
actual = set(inspect(engine).get_table_names())
|
||||||
|
missing = set(Base.metadata.tables) - actual
|
||||||
|
print(f"tables in DB: {len(actual)}; still missing: {sorted(missing)}")
|
||||||
|
engine.dispose()
|
||||||
|
cur.close()
|
||||||
|
root_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
数据种子脚本:重置指定用户密码为 bcrypt 哈希。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/seed.py admin 123456 # 重置 admin 用户密码为 123456
|
||||||
|
python scripts/seed.py admin 123456 3 # 指定用户 ID 3
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="重置用户密码")
|
||||||
|
parser.add_argument("username", help="用户名")
|
||||||
|
parser.add_argument("password", nargs="?", default="123456", help="新密码(默认 123456)")
|
||||||
|
parser.add_argument("user_id", nargs="?", type=int, default=None, help="用户 ID(可选,避免同名冲突)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
from config import DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD
|
||||||
|
from core.password import generate_salt, encrypt_password
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
salt = generate_salt()
|
||||||
|
hashed = encrypt_password(args.password, salt)
|
||||||
|
conn = pymysql.connect(host=DB_HOST, port=int(DB_PORT), user=DB_USERNAME,
|
||||||
|
password=DB_PASSWORD, database=DB_DATABASE)
|
||||||
|
cur = conn.cursor()
|
||||||
|
if args.user_id:
|
||||||
|
cur.execute("UPDATE fastapi_user SET salt=%s, password=%s WHERE id=%s",
|
||||||
|
(salt, hashed, args.user_id))
|
||||||
|
else:
|
||||||
|
cur.execute("UPDATE fastapi_user SET salt=%s, password=%s WHERE username=%s",
|
||||||
|
(salt, hashed, args.username))
|
||||||
|
conn.commit()
|
||||||
|
print(f"updated {cur.rowcount} row(s) for username={args.username}")
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -21,7 +21,7 @@ UPDATE_GOLDEN = os.getenv("UPDATE_GOLDEN") == "1"
|
|||||||
|
|
||||||
def collect_routes():
|
def collect_routes():
|
||||||
"""通过 openapi schema 收集全部 (METHOD /path),去重排序。"""
|
"""通过 openapi schema 收集全部 (METHOD /path),去重排序。"""
|
||||||
from apps import create_app
|
from core.app import create_app
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
spec = app.openapi()
|
spec = app.openapi()
|
||||||
@@ -44,7 +44,7 @@ def collect_permissions():
|
|||||||
|
|
||||||
def collect_tables():
|
def collect_tables():
|
||||||
"""从 SQLAlchemy metadata 收集全部表名。"""
|
"""从 SQLAlchemy metadata 收集全部表名。"""
|
||||||
from extensions.sqlalchemy import Base
|
from core.database import Base
|
||||||
|
|
||||||
return sorted(Base.metadata.tables.keys())
|
return sorted(Base.metadata.tables.keys())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user