From 2dfa836acb23da299a6b056603f40bbfca1989a3 Mon Sep 17 00:00:00 2001
From: chakcy <947105045@qq.com>
Date: Wed, 29 Apr 2026 11:24:19 +0800
Subject: [PATCH] =?UTF-8?q?modify:=20=E5=8A=9F=E8=83=BDtongbu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
{{ cookiecutter.project_name }}/.gitignore | 10 +-
{{ cookiecutter.project_name }}/Dockerfile | 57 ++
{{ cookiecutter.project_name }}/README.md | 36 +-
{{ cookiecutter.project_name }}/config.toml | 70 ++-
{{ cookiecutter.project_name }}/deploy.sh | 95 +++
.../public/config.toml | 71 ++-
.../public/main.py | 65 +-
.../public/server.py | 10 +-
.../public/utils/config.py | 114 +++-
.../public/utils/launch.py | 32 +-
.../pyproject.toml | 13 +-
.../pyrightconfig.json | 2 +-
.../requirements.txt | 27 +-
.../src/plugins/jwt/__init__.py | 0
.../app/core => plugins/jwt}/jwt_cache.py | 347 ++++++-----
.../src/plugins/scheduler/__init__.py | 0
.../src/plugins/scheduler/scheduler.py | 560 ++++++++++++++++++
.../.gitignore | 39 ++
.../.prettierrc.json | 6 +
.../env.d.ts | 1 +
.../index.html | 13 +
.../package.json | 38 ++
.../public/favicon.ico | Bin 0 -> 16958 bytes
.../src/App.vue | 16 +
.../src/assets/chongming64x64.png | Bin 0 -> 9813 bytes
.../src/assets/logo.png | Bin 0 -> 233758 bytes
.../src/assets/logo.svg | 175 ++++++
.../src/main.ts | 31 +
.../src/router/index.ts | 44 ++
.../src/stores/auth.ts | 77 +++
.../src/stores/todo.ts | 69 +++
.../src/views/LoginView.vue | 58 ++
.../src/views/RegisterView.vue | 61 ++
.../src/views/TodoView.vue | 245 ++++++++
.../tsconfig.app.json | 24 +
.../tsconfig.json | 11 +
.../tsconfig.node.json | 26 +
.../vite.config.ts | 22 +
.../__init__.py | 51 +-
.../app/__init__.py | 168 +++++-
.../app/api/__init__.py | 3 +-
.../app/api/deps.py | 18 +-
.../app/api/routers/auth.py | 209 +++++--
.../app/api/routers/todo.py | 78 ++-
.../app/api/routers/upload.py | 67 +++
.../app/core/cache.py | 397 +++++++++++++
.../app/core/config.py | 1 -
.../app/core/constant.py | 46 ++
.../app/core/logger.py | 166 +++++-
.../app/core/security.py | 44 +-
.../app/core/static_file.py | 100 ++++
.../app/model/__init__.py | 15 +
.../app/model/http_error.py | 31 +
.../app/model/permission.py | 36 ++
.../app/model/relationship_table.py | 35 ++
.../app/model/role.py | 52 ++
.../app/model/todo.py | 47 ++
.../app/model/user.py | 107 +++-
.../app/service/auth.py | 111 +++-
.../app/service/permission.py | 193 ++++++
.../app/service/role.py | 187 ++++++
.../app/service/todo.py | 56 ++
.../app/service/uploads.py | 81 +++
.../app/service/user.py | 43 +-
.../app/task/__init__.py | 31 +
.../app/task/dev_init_db.py | 92 +++
.../scripts/build.py | 390 +++++++++---
.../scripts/clean_pycache.py | 1 +
.../scripts/code_content.py | 14 +-
69 files changed, 4817 insertions(+), 518 deletions(-)
create mode 100644 {{ cookiecutter.project_name }}/Dockerfile
create mode 100644 {{ cookiecutter.project_name }}/deploy.sh
create mode 100644 {{ cookiecutter.project_name }}/src/plugins/jwt/__init__.py
rename {{ cookiecutter.project_name }}/src/{{{ cookiecutter.project_name }}/app/core => plugins/jwt}/jwt_cache.py (54%)
create mode 100644 {{ cookiecutter.project_name }}/src/plugins/scheduler/__init__.py
create mode 100644 {{ cookiecutter.project_name }}/src/plugins/scheduler/scheduler.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.gitignore
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.prettierrc.json
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/env.d.ts
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/index.html
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/package.json
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/public/favicon.ico
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/App.vue
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/chongming64x64.png
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/logo.png
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/logo.svg
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/main.ts
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/router/index.ts
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/stores/auth.ts
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/stores/todo.ts
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/LoginView.vue
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/RegisterView.vue
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/TodoView.vue
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.app.json
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.json
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.node.json
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/vite.config.ts
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/upload.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/cache.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/constant.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/static_file.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/http_error.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/permission.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/relationship_table.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/role.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/permission.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/role.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/uploads.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/task/__init__.py
create mode 100644 {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/task/dev_init_db.py
diff --git a/{{ cookiecutter.project_name }}/.gitignore b/{{ cookiecutter.project_name }}/.gitignore
index f19c072..8d21926 100644
--- a/{{ cookiecutter.project_name }}/.gitignore
+++ b/{{ cookiecutter.project_name }}/.gitignore
@@ -10,4 +10,12 @@ wheels/
.venv
logs/
resources/
-output.md
\ No newline at end of file
+output.md
+*.db
+*.svfs
+
+# resources
+uploads/
+
+# docker
+*.tar
diff --git a/{{ cookiecutter.project_name }}/Dockerfile b/{{ cookiecutter.project_name }}/Dockerfile
new file mode 100644
index 0000000..19d7b69
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/Dockerfile
@@ -0,0 +1,57 @@
+FROM debian:bookworm-slim AS builder
+
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends ca-certificates && \
+ rm -rf /var/lib/apt/lists/*
+
+COPY build/{{ cookiecutter.project_name }} /tmp/{{ cookiecutter.project_name }}
+
+RUN mkdir -p /tmp/rootfs/app && \
+ # === 临时目录 ===
+ mkdir -p /tmp/rootfs/tmp && \
+ chmod 1777 /tmp/rootfs/tmp && \
+ # === 数据目录 ===
+ mkdir -p /tmp/rootfs/app/data && \
+ chmod 777 /tmp/rootfs/app/data && \
+ # === 解释器目录 ===
+ mkdir -p /tmp/rootfs/lib64 && \
+ mkdir -p /tmp/rootfs/lib/x86_64-linux-gnu && \
+ cp /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
+ ln -s /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /tmp/rootfs/lib64/ld-linux-x86-64.so.2 && \
+ # === 依赖库 ===
+ cp /lib/x86_64-linux-gnu/libdl.so.2 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
+ cp /lib/x86_64-linux-gnu/libz.so.1 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
+ cp /lib/x86_64-linux-gnu/libpthread.so.0 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
+ cp /lib/x86_64-linux-gnu/libc.so.6 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
+ cp /lib/x86_64-linux-gnu/librt.so.1 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
+ cp /lib/x86_64-linux-gnu/libm.so.6 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
+ cp /lib/x86_64-linux-gnu/libutil.so.1 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
+ cp /lib/x86_64-linux-gnu/libnss_dns.so.2 /tmp/rootfs/lib/x86_64-linux-gnu/ 2>/dev/null || true && \
+ cp /lib/x86_64-linux-gnu/libnss_files.so.2 /tmp/rootfs/lib/x86_64-linux-gnu/ 2>/dev/null || true && \
+ cp /lib/x86_64-linux-gnu/libresolv.so.2 /tmp/rootfs/lib/x86_64-linux-gnu/ 2>/dev/null || true && \
+ # === 时区数据 ===
+ mkdir -p /tmp/rootfs/usr/share/zoneinfo && \
+ cp -r /usr/share/zoneinfo/Asia /tmp/rootfs/usr/share/zoneinfo/ && \
+ cp /usr/share/zoneinfo/UTC /tmp/rootfs/usr/share/zoneinfo/ && \
+ # === 证书和 DNS ===
+ mkdir -p /tmp/rootfs/etc/ssl/certs && \
+ cp /etc/ssl/certs/ca-certificates.crt /tmp/rootfs/etc/ssl/certs/ && \
+ echo "hosts: files dns" > /tmp/rootfs/etc/nsswitch.conf && \
+ # === /proc /sys /dev ===
+ mkdir -p /tmp/rootfs/proc /tmp/rootfs/sys /tmp/rootfs/dev
+
+FROM scratch
+
+COPY --from=builder /tmp/rootfs/ /
+
+COPY build/{{ cookiecutter.project_name }} /app/{{ cookiecutter.project_name }}
+COPY build/config.toml /app/config.toml
+COPY build/resources/ /app/resources/
+COPY build/static.svfs /app/static.svfs
+COPY build/data /app/data
+
+WORKDIR /app
+
+EXPOSE 8000
+
+CMD ["./{{ cookiecutter.project_name }}"]
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/README.md b/{{ cookiecutter.project_name }}/README.md
index c67ae07..12e4165 100644
--- a/{{ cookiecutter.project_name }}/README.md
+++ b/{{ cookiecutter.project_name }}/README.md
@@ -42,13 +42,12 @@ build
```text
build
-├── resources
-│ ├── {{ cookiecutter.project_name }}.ico
-│ ├── plugins.mbank
-│ └── app.mbank
-├── config.toml
-├── db.sqlite3
-└── {{ cookiecutter.project_name }} # windows {{ cookiecutter.project_name }}.exe
+build/
+├── {{ cookiecutter.project_name }} # 单文件可执行程序 (Linux) 或 {{ cookiecutter.project_name }}.exe (Windows)
+├── config.toml # 生产配置(密钥已注入)
+├── resources/ # 加密的模块银行 (.mbank) 和图标
+├── static.svfs # Vue 前端虚拟文件系统
+└── data/ # 初始 SQLite 数据库
```
运行打包后的项目
@@ -56,3 +55,26 @@ build
cd build
{{ cookiecutter.project_name }} # windows {{ cookiecutter.project_name }}.exe
```
+
+## Docker 部署
+
+Docker 镜像采用多角度构建 + `scratch` 基础镜像,真正做到最小化。
+
+### 构建镜像
+
+```bash
+docker build -t {{ cookiecutter.project_name }}:latest .
+```
+
+### 执行部署脚本
+```bash
+sudo chmod +x deploy.sh
+./deploy.sh
+```
+部署脚本会:
+
+- 在 `$HOME/{{ cookiecutter.project_name }}-data` 下创建 `data/`, `uploads/`, `logs/` 目录
+- 若首次运行,从镜像提取初始数据库
+- 启动容器并挂载上述目录,端口 `8000` 映射
+- 自动设置 `--restart unless-stopped`
+
diff --git a/{{ cookiecutter.project_name }}/config.toml b/{{ cookiecutter.project_name }}/config.toml
index 97471bd..dfb8b9f 100644
--- a/{{ cookiecutter.project_name }}/config.toml
+++ b/{{ cookiecutter.project_name }}/config.toml
@@ -1,80 +1,90 @@
[default]
+app.debug = false
+app.description = "基于 FastAPI 的 {{ cookiecutter.project_name }} 打包脚手架"
app.name = "{{ cookiecutter.project_name }}"
app.version = "{{ cookiecutter.project_name }}"
-app.description = "基于 FastAPI 和 Tortoise-ORM 的 chongming 打包脚手架"
-app.debug = false
env = "development"
+prefix = "/api/v1"
+upload_path = "uploads"
[database]
+database_path = "./data/database.db"
type = "sqlite"
-url = "sqlite+aiosqlite:///./database.db"
-generate_schemas = true # 生产环境使用迁移工具
+url = "sqlite+aiosqlite:///./data/database.db"
[database.sqlite]
-echo = true
-pool_size = 20
-max_overflow = 10
-pool_timeout = 30
-pool_recycle = 300
-pool_pre_ping = true
-connect_args.timeout = 15
connect_args.check_same_thread = false
+connect_args.timeout = 15
+echo = true
+echo_pool = true
+max_overflow = 10
+pool_pre_ping = true
+pool_recycle = 300
+pool_size = 20
+pool_timeout = 30
+
+[scheduler]
+job_store_path = "scheduler_jobs.db"
+
+[cache]
+cache_store_path = "./"
[security]
-secret_key = "your-secret-key-change-in-production"
-algorithm = "HS256"
access_token_expire_minutes = 30
-refresh_token_expire_days = 7
-jwt_cache_db_path = "jwt_cache"
+algorithm = "HS256"
max_sessions_per_user = 5
+refresh_token_expire_days = 7
+secret_key = "your-secret-key-change-in-production"
[development]
-env = "development"
debug = true
+env = "development"
[development.server]
+access_log = false
host = "127.0.0.1"
port = 8000
reload = true
[development.logging]
-level = "DEBUG"
-file = "logs/dev.log"
+file = "logs"
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+level = "DEBUG"
[development.cors]
-allow_origins = ["*"]
allow_credentials = true
-allow_methods = ["*"]
allow_headers = ["*"]
+allow_methods = ["*"]
+allow_origins = ["*"]
[production]
-env = "production"
debug = false
+env = "production"
[production.server]
+access_log = false
host = "0.0.0.0"
port = 8000
reload = false
workers = 4
[production.logging]
-level = "INFO"
-file = "logs/prod.log"
-format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
-max_size = 10485760 # 10MB
-backup_count = 5 # 最多保存 10 个日志文件
+backup_count = 5 # 最多保存 10 个日志文件
+file = "logs"
+format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+level = "INFO"
+max_size = 10485760 # 10MB
[production.cors]
-allow_origins = ["https://yourdomain.com", "http://localhost:8000", "http://127.0.0.1:8000"]
allow_credentials = true
-allow_methods = ["GET", "POST", "PUT", "DELETE"]
allow_headers = ["Authorization", "Content-Type"]
+allow_methods = ["GET", "POST", "PUT", "DELETE"]
+allow_origins = ["https://yourdomain.com", "http://localhost:8000", "http://127.0.0.1:8000"]
[production.module_system]
-type = "module-bank"
path = ["resources/app.mbank", "resources/plugins.mbank"]
+type = "module-bank"
[production.file_system]
+path = "static.svfs"
type = "svfs"
-path = "resources/application.vfs"
diff --git a/{{ cookiecutter.project_name }}/deploy.sh b/{{ cookiecutter.project_name }}/deploy.sh
new file mode 100644
index 0000000..fb34dde
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/deploy.sh
@@ -0,0 +1,95 @@
+#!/bin/bash
+# deploy.sh - {{ cookiecutter.project_name }} 快速部署脚本
+set -e
+
+# 颜色输出
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
+log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
+
+# 配置
+IMAGE="{{ cookiecutter.project_name }}:latest"
+CONTAINER="{{ cookiecutter.project_name }}"
+PORT=8000
+DATA_DIR="${HOME}/{{ cookiecutter.project_name }}-data"
+
+# 检查 docker
+if ! command -v docker &> /dev/null; then
+ log_error "Docker 未安装,请先安装 Docker"
+ exit 1
+fi
+
+# 检查镜像
+if ! docker image inspect "$IMAGE" &> /dev/null; then
+ log_error "镜像 $IMAGE 不存在,请先构建: docker build -t $IMAGE ."
+ exit 1
+fi
+
+# 1. 停止并删除旧容器
+if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
+ log_warn "发现旧容器,正在停止并删除..."
+ docker stop "$CONTAINER" 2>/dev/null || true
+ docker rm "$CONTAINER" 2>/dev/null || true
+fi
+
+# 2. 创建目录
+log_info "创建数据目录..."
+mkdir -p "$DATA_DIR"/{data,uploads,logs}
+chmod 755 "$DATA_DIR"/{data,uploads,logs}
+
+# 3. 复制初始数据(仅在目录为空时)
+if [ -z "$(ls -A $DATA_DIR/data 2>/dev/null)" ]; then
+ log_info "首次部署,从镜像中提取初始数据库..."
+ TMP_CONTAINER="{{ cookiecutter.project_name }}-tmp-$$"
+ docker create --name "$TMP_CONTAINER" "$IMAGE" &> /dev/null
+ docker cp "$TMP_CONTAINER:/app/data/." "$DATA_DIR/data/" &> /dev/null
+ docker rm "$TMP_CONTAINER" &> /dev/null
+ log_info "初始数据库已就绪"
+else
+ log_info "数据库已存在,跳过初始化"
+fi
+
+# 检查端口是否被占用
+if ss -tlnp | grep -q ":${PORT}"; then
+ log_error "端口 ${PORT} 已被占用,请修改 PORT 变量或释放端口"
+ exit 1
+fi
+
+# 4. 启动容器
+log_info "启动容器..."
+docker run -d \
+ -p "$PORT:8000" \
+ -v "$DATA_DIR/data:/app/data" \
+ -v "$DATA_DIR/uploads:/app/uploads" \
+ -v "$DATA_DIR/logs:/app/logs" \
+ --restart unless-stopped \
+ --name "$CONTAINER" \
+ "$IMAGE"
+
+# 5. 等待启动
+log_info "等待应用启动..."
+sleep 3
+
+# 6. 检查状态
+if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
+ log_info "=========================================="
+ log_info "✅ 部署成功!"
+ log_info "=========================================="
+ log_info "应用地址: http://localhost:$PORT"
+ log_info "API 文档: http://localhost:$PORT/docs"
+ log_info "数据目录: $DATA_DIR"
+ log_info ""
+ log_info "查看日志: docker logs -f $CONTAINER"
+ log_info "停止服务: docker stop $CONTAINER"
+ log_info "重启服务: docker restart $CONTAINER"
+ log_info "=========================================="
+else
+ log_error "容器启动失败,查看日志:"
+ docker logs "$CONTAINER" 2>/dev/null || true
+ exit 1
+fi
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/public/config.toml b/{{ cookiecutter.project_name }}/public/config.toml
index c65335a..f28c07b 100644
--- a/{{ cookiecutter.project_name }}/public/config.toml
+++ b/{{ cookiecutter.project_name }}/public/config.toml
@@ -1,78 +1,91 @@
[default]
+app.debug = false
+app.description = "基于 FastAPI 的 {{ cookiecutter.project_name }} 打包脚手架"
app.name = "{{ cookiecutter.project_name }}"
app.version = "0.1.0"
-app.description = "基于 FastAPI 的 chongming 打包脚手架"
-app.debug = false
env = "production"
+prefix = "/api/v1"
+upload_path = "uploads"
[database]
-type = "sqlite"
-url = "sqlite+aiosqlite:///./database.db"
-generate_schemas = true # 生产环境使用迁移工具
+generate_schemas = true # 生产环境使用迁移工具
+type = "sqlite"
+url = "sqlite+aiosqlite:///./data/database.db"
[database.sqlite]
-echo = true
-pool_size = 20
-max_overflow = 10
-pool_timeout = 30
-pool_recycle = 300
-pool_pre_ping = true
-connect_args.timeout = 15
connect_args.check_same_thread = false
+connect_args.timeout = 15
+echo = false
+echo_pool = false
+max_overflow = 10
+pool_pre_ping = true
+pool_recycle = 300
+pool_size = 20
+pool_timeout = 30
+
+[scheduler]
+job_store_path = "scheduler_jobs.db"
+
+[cache]
+cache_store_path = "./"
[security]
-secret_key = "your-secret-key-change-in-production"
-algorithm = "HS256"
access_token_expire_minutes = 30
-refresh_token_expire_days = 7
-jwt_cache_db_path = "jwt_cache"
+algorithm = "HS256"
max_sessions_per_user = 5
+refresh_token_expire_days = 7
+secret_key = "your-secret-key-change-in-production"
[development]
-env = "development"
debug = true
+env = "development"
[development.server]
+access_log = false
host = "127.0.0.1"
port = 8001
reload = true
[development.logging]
-level = "DEBUG"
-file = "logs/dev.log"
+file = "logs"
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+level = "DEBUG"
[development.cors]
-allow_origins = ["*"]
allow_credentials = true
-allow_methods = ["*"]
allow_headers = ["*"]
+allow_methods = ["*"]
+allow_origins = ["*"]
[production]
-env = "production"
debug = false
+env = "production"
[production.server]
+access_log = false
host = "0.0.0.0"
port = 8000
reload = false
+timeout_keep_alive = 65
workers = 4
[production.logging]
-level = "INFO"
-file = "logs/prod.log"
-format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+backup_count = 5 # 最多保存 10 个日志文件
+file = "logs"
+format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+level = "INFO"
+max_size = 10485760 # 10MB
[production.cors]
-allow_origins = ["https://yourdomain.com", "http://localhost:8000", "http://127.0.0.1:8000"]
allow_credentials = true
-allow_methods = ["GET", "POST", "PUT", "DELETE"]
allow_headers = ["Authorization", "Content-Type"]
+allow_methods = ["GET", "POST", "PUT", "DELETE"]
+allow_origins = ["https://yourdomain.com", "http://localhost:8000", "http://127.0.0.1:8000"]
[production.module_system]
-type = "module-bank"
path = ["resources/app.mbank", "resources/plugins.mbank"]
+type = "module-bank"
[production.file_system]
+path = "static.svfs"
type = "svfs"
-path = "resources/application.vfs"
diff --git a/{{ cookiecutter.project_name }}/public/main.py b/{{ cookiecutter.project_name }}/public/main.py
index 6250102..3603467 100644
--- a/{{ cookiecutter.project_name }}/public/main.py
+++ b/{{ cookiecutter.project_name }}/public/main.py
@@ -1,33 +1,39 @@
# main.py - 修复 reload 问题
#!/usr/bin/env python3
"""
-FastAPI + Tortoise-ORM + {{ cookiecutter.project_name }} 示例
-主入口文件,适配 {{ cookiecutter.project_name }} 打包系统
+FastAPI chongming 示例
+主入口文件,适配 chongming 打包系统
"""
+
import sys
+import platform
from utils.launch import launch
from multiprocessing import freeze_support
import server
+use_shell = platform.system() == "Windows"
+
def app_run(app_config: dict, default_config: dict):
"""
应用运行函数 - 适配模块银行环境
Args:
- app_config: 应用配置
- default_config: 默认配置
- """
- import uvicorn
- from app.core.logger import setup_logging, get_logger # type: ignore
+ app_config: 应用配置字典,包含服务器配置、环境设置等
+ default_config: 默认配置字典,包含应用名称、版本等基础信息
+
+ Returns:
+ None: 此函数不返回值,直接启动服务器并阻塞运行
+ """
+ from app.core.logger import get_logger # type: ignore
- setup_logging(app_config["logging"])
logger = get_logger("app")
# 获取服务器配置
server_config = app_config.get("server", {})
host = server_config.get("host", "0.0.0.0")
port = server_config.get("port", 8000)
+ timeout_keep_alive = server_config.get("timeout_keep_alive", 5)
# 打包环境强制禁用重载
is_frozen = getattr(sys, "frozen", False)
@@ -47,18 +53,43 @@ def app_run(app_config: dict, default_config: dict):
logger.info(f"👥 Workers: {workers}")
logger.info("=" * 50)
- uvicorn.run(
- "server:app",
- host=host,
- port=port,
- reload=reload,
- workers=workers,
- log_level="debug" if app_config.get("debug", False) else "info",
- access_log=True,
- )
+ # 根据操作系统选择不同的服务器启动方式
+ if use_shell:
+ import uvicorn
+
+ uvicorn.run(
+ "server:app",
+ host=host,
+ port=port,
+ reload=reload,
+ workers=workers,
+ log_level="debug" if app_config.get("debug", False) else "info",
+ access_log=False,
+ timeout_keep_alive=timeout_keep_alive,
+ )
+ else:
+ from gunicorn.app.wsgiapp import run
+
+ sys.argv = [
+ "gunicorn",
+ "server:app",
+ "-k",
+ "uvicorn.workers.UvicornWorker",
+ "-w",
+ str(workers),
+ "-b",
+ f"{host}:{port}",
+ ]
+ run()
def main():
+ """
+ 主函数入口 - 初始化多进程支持并启动应用
+
+ Returns:
+ None: 此函数不返回值,调用launch函数启动应用
+ """
freeze_support()
launch(app_run, "production")
diff --git a/{{ cookiecutter.project_name }}/public/server.py b/{{ cookiecutter.project_name }}/public/server.py
index 022f923..6e586ec 100644
--- a/{{ cookiecutter.project_name }}/public/server.py
+++ b/{{ cookiecutter.project_name }}/public/server.py
@@ -3,17 +3,19 @@ import sys
from utils.launch import init_module_bank
from utils.config import ConfigLoader
+# 加载配置文件并初始化模块银行
config = ConfigLoader("config.toml")
init_module_bank(config)
-# 从数据库导入真正的 app 模块
+# 从数据库动态导入 app 模块
+# 该模块存储在 SQLite 数据库中,通过自定义导入机制加载
try:
- import app # type: ignore # 此时会从 SQLite 数据库加载
+ import app # type: ignore
- app_instance = app.app # 获取 FastAPI 实例
+ app_instance = app.app
except ImportError as e:
print(f"无法从数据库导入 app 模块: {e}")
sys.exit(1)
-# 暴露 ASGI 应用变量,供 uvicorn 使用
+# 暴露 ASGI 应用实例,供 uvicorn 服务器使用
app = app_instance
diff --git a/{{ cookiecutter.project_name }}/public/utils/config.py b/{{ cookiecutter.project_name }}/public/utils/config.py
index 0e3d837..bef1306 100644
--- a/{{ cookiecutter.project_name }}/public/utils/config.py
+++ b/{{ cookiecutter.project_name }}/public/utils/config.py
@@ -6,6 +6,7 @@ import sys
from pathlib import Path
from typing import Any, Dict, Optional
+# 根据Python版本选择合适的TOML解析库
if sys.version_info >= (3, 11):
import tomllib
else:
@@ -13,7 +14,15 @@ else:
class ConfigLoader:
- """TOML配置文件加载器"""
+ """TOML配置文件加载器
+
+ 提供配置文件的加载、访问和管理功能。支持自动查找配置文件、
+ 点号分隔的嵌套键访问、配置节获取以及配置重载等功能。
+
+ Attributes:
+ config_path: 配置文件的路径
+ _config: 存储解析后的配置数据
+ """
def __init__(self, config_path: Optional[str] = None):
"""
@@ -21,6 +30,9 @@ class ConfigLoader:
Args:
config_path: 配置文件路径,如果为None则自动查找
+
+ Raises:
+ FileNotFoundError: 当无法找到配置文件时抛出异常
"""
if config_path is None:
# 自动查找配置文件
@@ -31,7 +43,21 @@ class ConfigLoader:
self.load()
def _find_config_file(self) -> str:
- """自动查找配置文件"""
+ """
+ 自动查找配置文件
+
+ 按照预定义的优先级顺序搜索配置文件:
+ 1. 当前目录下的config.toml
+ 2. config子目录下的config.toml
+ 3. 用户配置目录~/.config/myapp/config.toml
+ 4. 系统配置目录/etc/myapp/config.toml
+
+ Returns:
+ str: 找到的第一个配置文件路径
+
+ Raises:
+ FileNotFoundError: 当所有可能的位置都未找到配置文件时抛出异常
+ """
possible_paths = [
"config.toml",
"config/config.toml",
@@ -47,7 +73,15 @@ class ConfigLoader:
raise FileNotFoundError("未找到配置文件")
def load(self) -> None:
- """加载配置文件"""
+ """
+ 加载配置文件
+
+ 读取并解析TOML格式的配置文件,将配置数据存储到内部字典中。
+
+ Raises:
+ FileNotFoundError: 当配置文件不存在时抛出异常
+ tomllib.TOMLDecodeError: 当配置文件格式不正确时抛出异常
+ """
if not self.config_path.exists():
raise FileNotFoundError(f"配置文件不存在: {self.config_path}")
@@ -55,7 +89,20 @@ class ConfigLoader:
self._config = tomllib.loads(f.read())
def get(self, key: str, default: Any = None) -> Any:
- """获取配置值,支持点号分隔的路径"""
+ """
+ 获取配置值,支持点号分隔的路径
+
+ 通过点号分隔的键路径访问嵌套的配置值。例如:
+ - "database.host" 访问 {"database": {"host": "localhost"}}
+ - "server.port" 访问 {"server": {"port": 8080}}
+
+ Args:
+ key: 配置键,支持点号分隔的嵌套路径
+ default: 当键不存在时返回的默认值,默认为None
+
+ Returns:
+ Any: 配置值,如果键不存在则返回默认值
+ """
keys = key.split(".")
value = self._config
@@ -67,22 +114,71 @@ class ConfigLoader:
return default
def get_section(self, section: str) -> Dict[str, Any]:
- """获取整个配置节"""
+ """
+ 获取整个配置节
+
+ 返回指定配置节的完整字典内容,适用于需要访问某个配置节下
+ 所有配置项的场景。
+
+ Args:
+ section: 配置节名称
+
+ Returns:
+ Dict[str, Any]: 配置节的内容字典,如果节不存在则返回空字典
+ """
return self._config.get(section, {})
def reload(self) -> None:
- """重新加载配置文件"""
+ """
+ 重新加载配置文件
+
+ 从磁盘重新读取配置文件并更新内部配置数据。
+ 适用于配置文件在运行时被修改的场景。
+
+ Raises:
+ FileNotFoundError: 当配置文件不存在时抛出异常
+ """
self.load()
def __getitem__(self, key: str) -> Any:
- """支持字典式访问"""
+ """
+ 支持字典式访问
+
+ 允许使用方括号语法访问配置值,如:config["database.host"]
+
+ Args:
+ key: 配置键
+
+ Returns:
+ Any: 配置值
+
+ Raises:
+ KeyError: 当键不存在且无默认值时抛出异常(通过get方法处理)
+ """
return self.get(key)
def __contains__(self, key: str) -> bool:
- """检查配置是否存在"""
+ """
+ 检查配置是否存在
+
+ 支持使用in操作符检查配置键是否存在,如:"database.host" in config
+
+ Args:
+ key: 要检查的配置键
+
+ Returns:
+ bool: 如果配置键存在且值不为None则返回True,否则返回False
+ """
return self.get(key) is not None
@property
def all(self) -> Dict[str, Any]:
- """获取所有配置"""
+ """
+ 获取所有配置
+
+ 返回完整的配置字典,适用于需要遍历或导出所有配置的场景。
+
+ Returns:
+ Dict[str, Any]: 包含所有配置的字典
+ """
return self._config
diff --git a/{{ cookiecutter.project_name }}/public/utils/launch.py b/{{ cookiecutter.project_name }}/public/utils/launch.py
index d18e7c9..27be687 100644
--- a/{{ cookiecutter.project_name }}/public/utils/launch.py
+++ b/{{ cookiecutter.project_name }}/public/utils/launch.py
@@ -7,14 +7,29 @@ key = None
def init_module_bank(config):
+ """
+ 初始化模块银行系统,将指定路径下的Python模块打包并安装导入器。
+
+ 该函数会验证所有模块路径的有效性,创建PythonToSQLite打包器实例,
+ 列出所有可用模块,并为每个打包器安装导入器以支持模块的动态加载。
+
+ Args:
+ config (dict): 配置字典,必须包含 'production' -> 'module_system' -> 'path' 键,
+ 其值为模块路径列表
+
+ Raises:
+ ValueError: 当配置的模块路径中有任何一个不存在时抛出异常
+ """
MODULE_BANK_PATH_LIST = config["production"]["module_system"]["path"]
for module_path in MODULE_BANK_PATH_LIST:
- if os.path.exists(module_path) == False:
+ if not os.path.exists(module_path):
raise ValueError(f"模块路径不存在: {module_path}")
+ # 为每个模块路径创建PythonToSQLite打包器实例
packer_list = [
PythonToSQLite(module_path, key=key) for module_path in MODULE_BANK_PATH_LIST
]
+ # 遍历所有打包器,列出模块信息并安装导入器
for packer in packer_list:
modules = packer.list_modules()
for module in modules:
@@ -23,6 +38,21 @@ def init_module_bank(config):
def launch(run_fun: Callable[[dict, dict], None], run_mode: str):
+ """
+ 启动应用程序,加载配置并执行指定的运行函数。
+
+ 该函数从config.toml文件中加载配置,根据运行模式获取对应的应用配置和默认配置,
+ 然后调用传入的运行函数执行应用程序逻辑。
+
+ Args:
+ run_fun (Callable[[dict, dict], None]): 运行函数,接收两个参数:
+ - app_config (dict): 当前运行模式的配置
+ - default_config (dict): 默认配置
+ run_mode (str): 运行模式,用于从配置文件中获取对应模式的配置项
+
+ Returns:
+ None
+ """
config = ConfigLoader("config.toml")
# 获取对应运行模式的配置
diff --git a/{{ cookiecutter.project_name }}/pyproject.toml b/{{ cookiecutter.project_name }}/pyproject.toml
index 66dfcb6..3c978c7 100644
--- a/{{ cookiecutter.project_name }}/pyproject.toml
+++ b/{{ cookiecutter.project_name }}/pyproject.toml
@@ -9,14 +9,17 @@ description = "基于 modules-bank 的 fastapi 打包脚手架"
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
+ "aiofiles",
"aiosqlite",
- "bcrypt==4.0.1",
+ "apscheduler",
+ "bcrypt",
+ "diskcache",
"fastapi[uvicorn]",
+ "gunicorn",
"module-bank",
- "passlib[bcrypt]",
"pyjwt",
+ "python-multipart",
"sqlite-vfs",
- "sqlitedict",
"sqlmodel[aiosqlite]",
"tomli",
"typing-extensions",
@@ -35,6 +38,7 @@ url = "https://mirrors.ustc.edu.cn/pypi/web/simple/"
dev = [
"pyarmor",
"pyinstaller",
+ "ruff>=0.15.10",
]
[tool.hatch.build.targets.wheel]
@@ -42,4 +46,5 @@ packages = ["src/{{ cookiecutter.project_name }}"]
[project.scripts]
build = "{{ cookiecutter.project_name }}.scripts.build:build"
-serve = "{{ cookiecutter.project_name }}:serve"
\ No newline at end of file
+serve = "{{ cookiecutter.project_name }}:serve"
+gunicorn = "{{ cookiecutter.project_name }}:gunicorn_serve"
diff --git a/{{ cookiecutter.project_name }}/pyrightconfig.json b/{{ cookiecutter.project_name }}/pyrightconfig.json
index a499e3b..2f5d117 100644
--- a/{{ cookiecutter.project_name }}/pyrightconfig.json
+++ b/{{ cookiecutter.project_name }}/pyrightconfig.json
@@ -4,6 +4,6 @@
"pythonVersion": "{{ cookiecutter.python_version }}",
"extraPaths": [
"./",
- "./.venv/lib/python3.12/site-packages"
+ "./.venv/lib/python{{ cookiecutter.python_version }}/site-packages"
]
}
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/requirements.txt b/{{ cookiecutter.project_name }}/requirements.txt
index 528d3f4..3516563 100644
--- a/{{ cookiecutter.project_name }}/requirements.txt
+++ b/{{ cookiecutter.project_name }}/requirements.txt
@@ -1,16 +1,15 @@
-aerich
+aiofiles
+aiosqlite
+apscheduler
+bcrypt
+diskcache
fastapi[uvicorn]
-module-bank==0.1.1
-passlib
-pydantic-settings
-pydantic[email]
-sqlite-vfs
-toml
-tortoise-orm
-uvicorn[standard]
-sqlitedict
+gunicorn
+module-bank
pyjwt
-bcrypt==4.0.1
-pyinstaller
-aerich
-requests
\ No newline at end of file
+python-multipart
+sqlite-vfs
+sqlmodel[aiosqlite]
+tomli
+typing-extensions
+uvicorn
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/plugins/jwt/__init__.py b/{{ cookiecutter.project_name }}/src/plugins/jwt/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/jwt_cache.py b/{{ cookiecutter.project_name }}/src/plugins/jwt/jwt_cache.py
similarity index 54%
rename from {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/jwt_cache.py
rename to {{ cookiecutter.project_name }}/src/plugins/jwt/jwt_cache.py
index 756ff4e..318cd1e 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/jwt_cache.py
+++ b/{{ cookiecutter.project_name }}/src/plugins/jwt/jwt_cache.py
@@ -1,17 +1,12 @@
-import jwt
import json
from datetime import datetime, timedelta
-from typing import Optional, Dict, Any, List, TypedDict
-from sqlitedict import SqliteDict
+from typing import Optional, Dict, Any, List
from pydantic import BaseModel
from dataclasses import dataclass, asdict
-
-# from pydantic import BaseModel
import hashlib
import threading
-from pathlib import Path
-from .config import get_config
+import jwt
@dataclass
@@ -31,6 +26,7 @@ class TokenCacheData:
token_type: str = "access" # access 或 refresh
def __post_init__(self):
+ """初始化后处理,设置默认时间戳"""
if self.created_at is None:
self.created_at = datetime.utcnow().isoformat()
if self.last_used is None:
@@ -38,54 +34,37 @@ class TokenCacheData:
class JWTCache:
- """JWT Token 缓存管理器"""
+ """JWT Token 缓存管理器
- def __init__(self, db_path: Optional[str] = None):
- self.config = get_config()
+ 负责 JWT Token 的创建、验证、失效和会话管理。
+ 使用 Redis 缓存存储 Token 哈希和用户会话索引,支持多设备登录和会话限制。
+ """
- if db_path is None:
- db_path = self.config["security"]["jwt_cache_db_path"]
+ def __init__(self, config, cache):
+ """初始化 JWT 缓存管理器
- db_file_path = Path(db_path)
- db_file_path.mkdir(parents=True, exist_ok=True)
+ Args:
+ config: 配置字典,包含 security 相关配置项
+ cache: 缓存实例,提供 get/set/delete/exists/keys 等方法
+ """
+ self.config = config
self.lock = threading.RLock()
- # 主 Token 缓存
- self.token_db = SqliteDict(
- rf"{db_path}/tokens.cache",
- tablename="tokens",
- autocommit=True,
- encode=self._json_encode,
- decode=self._json_decode,
- journal_mode="WAL",
- )
-
- # 黑名单缓存
- self.blacklist_db = SqliteDict(
- rf"{db_path}/blacklist.cache",
- tablename="blacklist",
- autocommit=True,
- encode=json.dumps,
- decode=json.loads,
- journal_mode="WAL",
- )
-
- # 用户会话索引
- self.user_sessions_db = SqliteDict(
- rf"{db_path}/user_sessions.cache",
- tablename="user_sessions",
- autocommit=True,
- encode=json.dumps,
- decode=json.loads,
- journal_mode="WAL",
- )
+ self.cache = cache
self.secret_key = self.config["security"]["secret_key"]
self.algorithm = self.config["security"]["algorithm"]
def _json_encode(self, obj):
- """自定义 JSON 编码器,处理 datetime 对象"""
+ """自定义 JSON 编码器,处理 datetime 对象
+
+ Args:
+ obj: 需要序列化的对象
+
+ Returns:
+ JSON 字符串
+ """
def convert_datetime(o):
if isinstance(o, datetime):
@@ -97,11 +76,25 @@ class JWTCache:
return json.dumps(obj, default=convert_datetime)
def _json_decode(self, s):
- """自定义 JSON 解码器"""
+ """自定义 JSON 解码器
+
+ Args:
+ s: JSON 字符串
+
+ Returns:
+ 解码后的 Python 对象
+ """
return json.loads(s)
def _hash_token(self, token: str) -> str:
- """Token 哈希化(避免存储明文 Token)"""
+ """Token 哈希化(避免存储明文 Token)
+
+ Args:
+ token: 原始 JWT Token 字符串
+
+ Returns:
+ SHA256 哈希值(十六进制字符串)
+ """
return hashlib.sha256(token.encode()).hexdigest()
def create_token(
@@ -113,16 +106,18 @@ class JWTCache:
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> str:
- """
- 创建并缓存 JWT Token
+ """创建并缓存 JWT Token
+
+ 生成新的 JWT Token,将其哈希值和元数据存储到缓存中,
+ 并更新用户会话索引。如果超出会话限制,会自动清理最早的会话。
Args:
user_id: 用户ID
- user_data: 用户数据
+ user_data: 用户数据,将嵌入到 Token payload 中
token_type: Token 类型(access 或 refresh)
- device_id: 设备ID
- user_agent: 用户代理
- ip_address: IP地址
+ device_id: 设备ID,用于标识登录设备
+ user_agent: 用户代理字符串,记录客户端信息
+ ip_address: IP地址,记录登录来源
Returns:
JWT Token 字符串
@@ -168,7 +163,7 @@ class JWTCache:
)
# 存储到缓存
- self.token_db[token_hash] = asdict(cache_data)
+ self.cache.set(f"token:{token_hash}", asdict(cache_data))
# 更新用户会话索引
self._update_user_session_index(user_id, token_hash, token_type)
@@ -179,7 +174,11 @@ class JWTCache:
return token
def _generate_jti(self) -> str:
- """生成唯一的 JWT ID"""
+ """生成唯一的 JWT ID
+
+ Returns:
+ UUID v4 字符串,用作 Token 的唯一标识符
+ """
import uuid
return str(uuid.uuid4())
@@ -187,10 +186,19 @@ class JWTCache:
def _update_user_session_index(
self, user_id: str, token_hash: str, token_type: str
):
- """更新用户会话索引"""
+ """更新用户会话索引
+
+ 维护每个用户每种 Token 类型的会话列表,限制最大数量为 100。
+ 当超过限制时,保留最新的 100 个会话。
+
+ Args:
+ user_id: 用户ID
+ token_hash: Token 的 SHA256 哈希值
+ token_type: Token 类型(access 或 refresh)
+ """
with self.lock:
- key = f"{user_id}:{token_type}"
- user_tokens = self.user_sessions_db.get(key, [])
+ key = f"user:{user_id}:{token_type}"
+ user_tokens = self.cache.get(key, [])
# 添加新 token
user_tokens.append(token_hash)
@@ -200,49 +208,63 @@ class JWTCache:
if len(user_tokens) > max_tokens:
user_tokens = user_tokens[-max_tokens:]
- self.user_sessions_db[key] = user_tokens
+ self.cache.set(key, user_tokens)
def _enforce_session_limit(self, user_id: str):
- """强制用户会话限制"""
+ """强制用户会话限制
+
+ 检查用户的 access 和 refresh token 数量,如果超过配置的最大会话数,
+ 则删除最早的会话以维持限制。
+
+ Args:
+ user_id: 用户ID
+ """
max_sessions = self.config["security"]["max_sessions_per_user"]
- access_key = f"{user_id}:access"
- refresh_key = f"{user_id}:refresh"
+ access_key = f"user:{user_id}:access"
+ refresh_key = f"user:{user_id}:refresh"
# 检查 access token 数量
- access_tokens = self.user_sessions_db.get(access_key, [])
+ access_tokens = self.cache.get(access_key, [])
if len(access_tokens) > max_sessions:
# 删除最早的 token
tokens_to_remove = access_tokens[:-max_sessions]
for token_hash in tokens_to_remove:
- if token_hash in self.token_db:
- del self.token_db[token_hash]
+ if self.cache.exists(f"token:{token_hash}"):
+ self.cache.delete(f"token:{token_hash}")
# 更新索引
- self.user_sessions_db[access_key] = access_tokens[-max_sessions:]
+ self.cache.set(access_key, access_tokens[-max_sessions:])
# 检查 refresh token 数量
- refresh_tokens = self.user_sessions_db.get(refresh_key, [])
+ refresh_tokens = self.cache.get(refresh_key, [])
if len(refresh_tokens) > max_sessions:
tokens_to_remove = refresh_tokens[:-max_sessions]
for token_hash in tokens_to_remove:
- if token_hash in self.token_db:
- del self.token_db[token_hash]
+ if self.cache.exists(f"token:{token_hash}"):
+ self.cache.delete(f"token:{token_hash}")
- self.user_sessions_db[refresh_key] = refresh_tokens[-max_sessions:]
+ self.cache.set(refresh_key, refresh_tokens[-max_sessions:])
def validate_token(
self, token: str, token_type: str = "access"
) -> Optional[Dict[str, Any]]:
- """
- 验证 JWT Token
+ """验证 JWT Token
+
+ 执行多层验证:JWT 签名验证、类型检查、缓存存在性检查、
+ 活跃状态检查和过期时间检查。验证成功后更新最后使用时间。
Args:
- token: JWT Token
- token_type: 期望的 Token 类型
+ token: JWT Token 字符串
+ token_type: 期望的 Token 类型(access 或 refresh)
Returns:
- 验证成功返回用户数据,失败返回 None
+ 验证成功返回包含用户信息的字典,包含以下字段:
+ - user_id: 用户ID
+ - device_id: 设备ID
+ - payload: JWT payload 数据
+ - cache_data: 缓存中的完整会话数据
+ 验证失败返回 None
"""
try:
# 解码验证 Token
@@ -259,14 +281,11 @@ class JWTCache:
if not user_id or not jti:
return None
- # 检查黑名单
token_hash = self._hash_token(token)
- if self._is_blacklisted(token_hash):
- return None
# 检查缓存
with self.lock:
- cache_data = self.token_db.get(token_hash)
+ cache_data = self.cache.get(f"token:{token_hash}")
if not cache_data:
return None
@@ -279,12 +298,12 @@ class JWTCache:
if datetime.utcnow() > expires_at:
# 标记为不活跃
cache_data["is_active"] = False
- self.token_db[token_hash] = cache_data
+ self.cache.set(f"token:{token_hash}", cache_data)
return None
# 更新最后使用时间
cache_data["last_used"] = datetime.utcnow().isoformat()
- self.token_db[token_hash] = cache_data
+ self.cache.set(f"token:{token_hash}", cache_data)
return {
"user_id": user_id,
@@ -300,81 +319,84 @@ class JWTCache:
except Exception:
return None
- def add_to_blacklist(self, token: str, reason: str = "logout"):
- """添加 Token 到黑名单"""
- token_hash = self._hash_token(token)
- expire_time = datetime.utcnow() + timedelta(days=7) # 黑名单保留7天
-
- self.blacklist_db[token_hash] = {
- "reason": reason,
- "blacklisted_at": datetime.utcnow().isoformat(),
- "expires_at": expire_time.isoformat(),
- }
-
- def _is_blacklisted(self, token_hash: str) -> bool:
- """检查 Token 是否在黑名单中"""
- with self.lock:
- if token_hash not in self.blacklist_db:
- return False
-
- blacklist_data = self.blacklist_db[token_hash]
- expires_at = datetime.fromisoformat(blacklist_data["expires_at"])
-
- # 如果黑名单条目已过期,删除它
- if datetime.utcnow() > expires_at:
- del self.blacklist_db[token_hash]
- return False
-
- return True
-
def invalidate_token(self, token: str):
- """使单个 Token 失效"""
+ """使单个 Token 失效
+
+ 通过标记缓存中的 Token 为非活跃状态来实现失效,
+ 而不是直接删除,以便保留审计信息。
+
+ Args:
+ token: 需要失效的 JWT Token 字符串
+ """
with self.lock:
token_hash = self._hash_token(token)
- # 添加到黑名单
- self.add_to_blacklist(token)
-
# 标记为不活跃
- if token_hash in self.token_db:
- cache_data = self.token_db[token_hash]
+ if self.cache.exists(f"token:{token_hash}"):
+ cache_data = self.cache.get(f"token:{token_hash}")
cache_data["is_active"] = False
- self.token_db[token_hash] = cache_data
+ self.cache.set(f"token:{token_hash}", cache_data)
def invalidate_user_tokens(self, user_id: str, token_type: Optional[str] = None):
- """使用户的所有 Token 失效"""
+ """使用户的所有 Token 失效
+
+ 批量失效指定用户的所有 Token,可用于用户登出、密码修改等场景。
+ 可以指定失效特定类型的 Token,或者同时失效 access 和 refresh token。
+
+ Args:
+ user_id: 用户ID
+ token_type: 可选,指定要失效的 Token 类型(access 或 refresh)。
+ 如果不指定,则失效该用户的所有类型 Token
+ """
with self.lock:
if token_type:
- keys = [f"{user_id}:{token_type}"]
+ keys = [f"user:{user_id}:{token_type}"]
else:
- keys = [f"{user_id}:access", f"{user_id}:refresh"]
+ keys = [f"user:{user_id}:access", f"user:{user_id}:refresh"]
for key in keys:
- token_hashes = self.user_sessions_db.get(key, [])
+ token_hashes = self.cache.get(key, [])
for token_hash in token_hashes:
# 添加到黑名单
# 注意:这里我们只有 token_hash,没有原始 token
# 所以只能标记为不活跃
- if token_hash in self.token_db:
- cache_data = self.token_db[token_hash]
+ if self.cache.exists(f"token:{token_hash}"):
+ cache_data = self.cache.get(f"token:{token_hash}")
cache_data["is_active"] = False
- self.token_db[token_hash] = cache_data
+ self.cache.set(f"token:{token_hash}", cache_data)
# 清空用户会话索引
- self.user_sessions_db[key] = []
+ self.cache.set(key, [])
def get_user_sessions(self, user_id: str) -> List[Dict[str, Any]]:
- """获取用户的所有活跃会话"""
+ """获取用户的所有活跃会话
+
+ 遍历用户的所有 Token 会话,过滤出当前活跃且未过期的会话,
+ 返回包含设备信息、时间戳等详细信息的会话列表。
+
+ Args:
+ user_id: 用户ID
+
+ Returns:
+ 活跃会话列表,每个会话包含以下字段:
+ - token_type: Token 类型
+ - device_id: 设备ID
+ - user_agent: 用户代理
+ - ip_address: IP地址
+ - created_at: 创建时间
+ - last_used: 最后使用时间
+ - expires_at: 过期时间
+ """
with self.lock:
sessions = []
for token_type in ["access", "refresh"]:
- key = f"{user_id}:{token_type}"
- token_hashes = self.user_sessions_db.get(key, [])
+ key = f"user:{user_id}:{token_type}"
+ token_hashes = self.cache.get(key, [])
for token_hash in token_hashes:
- if token_hash in self.token_db:
- cache_data = self.token_db[token_hash]
+ if self.cache.exists(f"token:{token_hash}"):
+ cache_data = self.cache.get(f"token:{token_hash}")
# 检查是否活跃且未过期
if cache_data.get("is_active", True):
@@ -397,50 +419,62 @@ class JWTCache:
return sessions
def cleanup_expired(self):
- """清理过期数据"""
+ """清理过期数据
+
+ 扫描所有 Token 缓存,删除已过期的 Token 记录。
+ 建议定期调用此方法以释放缓存空间。
+
+ Returns:
+ 清理的过期 Token 数量
+ """
with self.lock:
cleaned_count = 0
current_time = datetime.utcnow()
# 清理过期 Token
- for token_hash, data in list(self.token_db.items()):
- expires_at = datetime.fromisoformat(data["expires_at"])
+ for token_hash in self.cache.keys("token:*"):
+ expires_at = datetime.fromisoformat(self.cache.get(token_hash))
if current_time > expires_at:
- del self.token_db[token_hash]
- cleaned_count += 1
-
- # 清理过期黑名单
- for token_hash, data in list(self.blacklist_db.items()):
- expires_at = datetime.fromisoformat(data["expires_at"])
- if current_time > expires_at:
- del self.blacklist_db[token_hash]
+ self.cache.delete(token_hash)
cleaned_count += 1
return cleaned_count
- def close(self):
- with self.lock:
- """关闭所有数据库连接"""
- self.token_db.close()
- self.blacklist_db.close()
- self.user_sessions_db.close()
-
# 全局缓存实例
_jwt_cache = None
-def get_jwt_cache() -> JWTCache:
- """获取 JWT 缓存实例(单例模式)"""
+def get_jwt_cache(config=None, cache=None) -> JWTCache:
+ """获取 JWT 缓存实例(单例模式)
+
+ 确保整个应用中只有一个 JWTCache 实例,避免重复初始化和资源浪费。
+ 首次调用时必须提供 config 和 cache 参数。
+
+ Args:
+ config: 配置字典,仅在首次初始化时需要
+ cache: 缓存实例,仅在首次初始化时需要
+
+ Returns:
+ JWTCache 单例实例
+
+ Raises:
+ ValueError: 首次调用时如果 config 或 cache 为空则抛出异常
+ """
global _jwt_cache
if _jwt_cache is None:
- _jwt_cache = JWTCache()
+ if config is None or cache is None:
+ raise ValueError("参数 config 和 cache 不能为空")
+ _jwt_cache = JWTCache(config, cache)
return _jwt_cache
# Token 响应模型
class TokenResponse(BaseModel):
- """Token 响应模型"""
+ """Token 响应模型
+
+ 用于登录接口返回 access token 和 refresh token
+ """
access_token: str
refresh_token: str
@@ -449,9 +483,22 @@ class TokenResponse(BaseModel):
refresh_expires_in: int
+class RefreshTokenResponse(BaseModel):
+ """刷新 Token 响应模型
+
+ 用于刷新 token 接口,仅返回新的 access token
+ """
+
+ access_token: str
+ token_type: str = "bearer"
+
+
# Token 数据模型
class TokenData(BaseModel):
- """Token 数据模型"""
+ """Token 数据模型
+
+ 用于从 Token 中提取的用户身份信息
+ """
user_id: str
username: Optional[str] = None
diff --git a/{{ cookiecutter.project_name }}/src/plugins/scheduler/__init__.py b/{{ cookiecutter.project_name }}/src/plugins/scheduler/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/{{ cookiecutter.project_name }}/src/plugins/scheduler/scheduler.py b/{{ cookiecutter.project_name }}/src/plugins/scheduler/scheduler.py
new file mode 100644
index 0000000..1cd3d3c
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/plugins/scheduler/scheduler.py
@@ -0,0 +1,560 @@
+"""
+APScheduler 集成模块
+提供持久化任务调度服务
+"""
+
+import os
+import sys
+import tempfile
+from contextlib import suppress
+from typing import Dict, List, Optional, Callable, AsyncGenerator
+from datetime import datetime
+
+from fastapi import Request
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
+from apscheduler.executors.asyncio import AsyncIOExecutor
+from apscheduler.job import Job
+from apscheduler.triggers.interval import IntervalTrigger
+from apscheduler.triggers.cron import CronTrigger
+from apscheduler.triggers.date import DateTrigger
+
+
+def acquire_scheduler_lock():
+ """
+ 获取调度器分布式锁,确保只有一个进程实例运行调度器
+
+ 通过在临时目录创建锁文件并使用文件系统锁机制,防止多个进程同时启动调度器。
+ 支持 Windows 和 Unix/Linux 系统的不同锁实现。
+
+ Returns:
+ tuple: 包含两个元素的元组
+ - lock_file (file object or None): 锁文件对象,如果获取锁失败则为 None
+ - bool: 是否成功获取锁,成功为 True,失败为 False
+ """
+ lock_path = os.path.join(tempfile.gettempdir(), "chongming_scheduler.lock")
+ lock_file = open(lock_path, "w")
+ try:
+ if sys.platform == "win32":
+ import msvcrt
+
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
+ else:
+ import fcntl
+
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_NB | fcntl.LOCK_EX)
+ return lock_file, True
+ except (IOError, OSError):
+ lock_file.close()
+ return None, False
+
+
+class TaskService:
+ """
+ 任务调度服务类
+
+ 基于 APScheduler 实现的异步任务调度服务,支持多种触发器类型(间隔、Cron、一次性)。
+ 使用 SQLite 数据库持久化存储任务信息,确保服务重启后任务不丢失。
+ 通过文件锁机制保证多进程环境下只有一个实例执行调度任务。
+ """
+
+ def __init__(self, config, logger, async_session_maker=None):
+ """
+ 初始化任务调度服务
+
+ Args:
+ config (dict): 配置字典,包含调度器相关配置项
+ - scheduler.job_store_path (str): 任务存储数据库路径,默认为 "scheduler_jobs.db"
+ logger (logging.Logger): 日志记录器实例,用于记录调度器运行日志
+ async_session_maker (callable, optional): 异步会话工厂函数,用于数据库操作,默认为 None
+
+ Attributes:
+ async_session_maker: 异步会话工厂函数
+ job_store_db_path: 任务存储数据库文件路径
+ scheduler: AsyncIOScheduler 调度器实例
+ _started (bool): 调度器是否已启动的标志
+ lock_file: 锁文件对象
+ is_scheduler_worker (bool): 当前进程是否为调度器工作进程
+ logger: 日志记录器实例
+ """
+ self.async_session_maker = async_session_maker
+ # 使用独立的 SQLite 数据库存储任务(与业务数据库分开)
+ self.job_store_db_path = config.get("scheduler", {}).get(
+ "job_store_path", "scheduler_jobs.db"
+ )
+ job_store_url = f"sqlite:///{self.job_store_db_path}"
+
+ # 配置 job stores 和 executors
+ jobstores = {"default": SQLAlchemyJobStore(url=job_store_url)}
+ executors = {"default": AsyncIOExecutor()}
+ job_defaults = {
+ "coalesce": True, # 合并错过的任务
+ "max_instances": 3, # 最大并发实例数
+ "misfire_grace_time": 30, # 错过任务的宽限时间(秒)
+ }
+
+ self.scheduler = AsyncIOScheduler(
+ jobstores=jobstores,
+ executors=executors,
+ job_defaults=job_defaults,
+ timezone="Asia/Shanghai",
+ )
+ self._started = False
+ self.lock_file, self.is_scheduler_worker = acquire_scheduler_lock()
+ self.logger = logger
+
+ async def start(self, init_tasks_callback: Optional[Callable] = None):
+ """
+ 启动任务调度器
+
+ 仅在当前进程成功获取调度器锁时启动调度器,避免多进程重复启动。
+ 启动后可选择执行初始化任务回调函数。
+
+ Args:
+ init_tasks_callback (callable, optional): 初始化任务回调函数,接收 TaskService 实例作为参数。
+ 用于在调度器启动后注册初始任务。默认为 None。
+
+ Note:
+ - 如果调度器已经启动,不会重复启动
+ - 只有成功获取锁的进程才会启动调度器
+ """
+ if self.is_scheduler_worker:
+ if not self._started:
+ self.scheduler.start()
+ self._started = True
+ self.logger.info("任务调度器已启动")
+ if init_tasks_callback:
+ await init_tasks_callback(self)
+
+ async def shutdown(self, wait: bool = True):
+ """
+ 关闭任务调度器
+
+ 优雅地关闭调度器,包括暂停所有运行中的任务、关闭调度器、释放文件锁。
+
+ Args:
+ wait (bool): 是否等待正在执行的任务完成后再关闭。
+ True 表示等待任务完成,False 表示立即关闭。默认为 True。
+
+ Note:
+ - 先暂停所有任务,再关闭调度器
+ - 释放文件锁以允许其他进程获取锁
+ - 使用 suppress 捕获所有异常,确保关闭过程不会抛出异常
+ """
+ if self.lock_file:
+ with suppress(Exception):
+ for job in await self.get_jobs():
+ await self.pause_job(job.get("id", ""))
+
+ if self._started:
+ self.scheduler.shutdown(wait=wait)
+ self._started = False
+ self.logger.info("任务调度器已关闭")
+ if sys.platform == "win32":
+ import msvcrt
+
+ msvcrt.locking(self.lock_file.fileno(), msvcrt.LK_UNLCK, 1)
+ else:
+ import fcntl
+
+ fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_UN)
+ self.lock_file.close()
+
+ async def add_interval_job(
+ self,
+ func: Callable,
+ seconds: int = 0,
+ minutes: int = 0,
+ hours: int = 0,
+ days: int = 0,
+ job_id: Optional[str] = None,
+ args: Optional[List] = None,
+ kwargs: Optional[Dict] = None,
+ replace_existing: bool = True,
+ ) -> Job:
+ """
+ 添加间隔执行任务
+
+ 创建按照固定时间间隔重复执行的任务。
+
+ Args:
+ func (callable): 要执行的任务函数
+ seconds (int): 间隔秒数,默认为 0
+ minutes (int): 间隔分钟数,默认为 0
+ hours (int): 间隔小时数,默认为 0
+ days (int): 间隔天数,默认为 0
+ job_id (str, optional): 任务唯一标识符,如果未提供则自动生成
+ args (list, optional): 传递给任务函数的位置参数列表,默认为空列表
+ kwargs (dict, optional): 传递给任务函数的关键字参数字典,默认为空字典
+ replace_existing (bool): 如果存在相同 ID 的任务,是否替换它。默认为 True
+
+ Returns:
+ Job: 创建的任务对象
+
+ Example:
+ >>> await service.add_interval_job(my_func, minutes=5, job_id="cleanup_task")
+ """
+ trigger = IntervalTrigger(
+ seconds=seconds, minutes=minutes, hours=hours, days=days
+ )
+ job = self.scheduler.add_job(
+ func,
+ trigger=trigger,
+ id=job_id,
+ args=args or [],
+ kwargs=kwargs or {},
+ replace_existing=replace_existing,
+ )
+ self.logger.info(f"添加间隔任务: {job.id}")
+ return job
+
+ async def add_cron_job(
+ self,
+ func: Callable,
+ job_id: Optional[str] = None,
+ year: Optional[int] = None,
+ month: Optional[int] = None,
+ day: Optional[int] = None,
+ week: Optional[int] = None,
+ day_of_week: Optional[int] = None,
+ hour: Optional[int] = None,
+ minute: Optional[int] = None,
+ second: Optional[int] = None,
+ args: Optional[List] = None,
+ kwargs: Optional[Dict] = None,
+ replace_existing: bool = True,
+ ) -> Job:
+ """
+ 添加 Cron 表达式任务
+
+ 创建按照 Cron 表达式规则执行的任务,支持精确的时间调度。
+
+ Args:
+ func (callable): 要执行的任务函数
+ job_id (str, optional): 任务唯一标识符,如果未提供则自动生成
+ year (int, optional): 年份,支持通配符和范围
+ month (int, optional): 月份 (1-12)
+ day (int, optional): 日期 (1-31)
+ week (int, optional): 周数 (1-53)
+ day_of_week (int, optional): 星期几 (0-6, 0 表示周一)
+ hour (int, optional): 小时 (0-23)
+ minute (int, optional): 分钟 (0-59)
+ second (int, optional): 秒 (0-59)
+ args (list, optional): 传递给任务函数的位置参数列表,默认为空列表
+ kwargs (dict, optional): 传递给任务函数的关键字参数字典,默认为空字典
+ replace_existing (bool): 如果存在相同 ID 的任务,是否替换它。默认为 True
+
+ Returns:
+ Job: 创建的任务对象
+
+ Example:
+ >>> # 每天凌晨 2 点执行
+ >>> await service.add_cron_job(my_func, hour=2, minute=0, job_id="daily_task")
+ """
+ trigger = CronTrigger(
+ year=year,
+ month=month,
+ day=day,
+ week=week,
+ day_of_week=day_of_week,
+ hour=hour,
+ minute=minute,
+ second=second,
+ )
+ job = self.scheduler.add_job(
+ func,
+ trigger=trigger,
+ id=job_id,
+ args=args or [],
+ kwargs=kwargs or {},
+ replace_existing=replace_existing,
+ )
+ self.logger.info(f"添加 cron 任务: {job.id}")
+ return job
+
+ async def add_date_job(
+ self,
+ func: Callable,
+ run_date: Optional[datetime] = None,
+ job_id: Optional[str] = None,
+ args: Optional[List] = None,
+ kwargs: Optional[Dict] = None,
+ replace_existing: bool = True,
+ ) -> Job:
+ """
+ 添加一次性任务
+
+ 创建在指定时间点执行一次的任务,执行后自动移除。
+
+ Args:
+ func (callable): 要执行的任务函数
+ run_date (datetime, optional): 任务执行的具体时间,如果未提供则立即执行
+ job_id (str, optional): 任务唯一标识符,如果未提供则自动生成
+ args (list, optional): 传递给任务函数的位置参数列表,默认为空列表
+ kwargs (dict, optional): 传递给任务函数的关键字参数字典,默认为空字典
+ replace_existing (bool): 如果存在相同 ID 的任务,是否替换它。默认为 True
+
+ Returns:
+ Job: 创建的任务对象
+
+ Example:
+ >>> from datetime import datetime
+ >>> run_time = datetime(2024, 12, 25, 10, 0, 0)
+ >>> await service.add_date_job(my_func, run_date=run_time, job_id="christmas_task")
+ """
+ trigger = DateTrigger(run_date=run_date)
+ job = self.scheduler.add_job(
+ func,
+ trigger=trigger,
+ id=job_id,
+ args=args or [],
+ kwargs=kwargs or {},
+ replace_existing=replace_existing,
+ )
+ self.logger.info(f"添加一次性任务: {job.id}")
+ return job
+
+ async def remove_job(self, job_id: str) -> bool:
+ """
+ 删除任务
+
+ 从调度器中移除指定任务,任务将不再执行。
+
+ Args:
+ job_id (str): 要删除的任务的唯一标识符
+
+ Returns:
+ bool: 删除是否成功,成功返回 True,失败返回 False
+
+ Note:
+ - 如果任务不存在或删除过程中发生异常,返回 False
+ - 会记录删除操作的日志
+ """
+ try:
+ self.scheduler.remove_job(job_id)
+ self.logger.info(f"删除任务: {job_id}")
+ return True
+ except Exception as e:
+ self.logger.error(f"删除任务失败 {job_id}: {e}")
+ return False
+
+ async def pause_job(self, job_id: str) -> bool:
+ """
+ 暂停任务
+
+ 暂停指定任务的执行,任务保留在调度器中但不会触发。
+
+ Args:
+ job_id (str): 要暂停的任务的唯一标识符
+
+ Returns:
+ bool: 暂停是否成功,成功返回 True,失败返回 False
+
+ Note:
+ - 暂停的任务可以通过 resume_job 恢复
+ - 如果任务不存在或暂停过程中发生异常,返回 False
+ """
+ try:
+ self.scheduler.pause_job(job_id)
+ self.logger.info(f"暂停任务: {job_id}")
+ return True
+ except Exception as e:
+ self.logger.error(f"暂停任务失败 {job_id}: {e}")
+ return False
+
+ async def resume_job(self, job_id: str) -> bool:
+ """
+ 恢复任务
+
+ 恢复之前被暂停的任务,使其继续按照原定计划执行。
+
+ Args:
+ job_id (str): 要恢复的任务的唯一标识符
+
+ Returns:
+ bool: 恢复是否成功,成功返回 True,失败返回 False
+
+ Note:
+ - 只能恢复已被暂停的任务
+ - 如果任务不存在或恢复过程中发生异常,返回 False
+ """
+ try:
+ self.scheduler.resume_job(job_id)
+ self.logger.info(f"恢复任务: {job_id}")
+ return True
+ except Exception as e:
+ self.logger.error(f"恢复任务失败 {job_id}: {e}")
+ return False
+
+ async def get_job(self, job_id: str) -> Optional[Dict]:
+ """
+ 获取单个任务信息
+
+ 查询指定任务的详细信息,包括下次执行时间、触发器等。
+
+ Args:
+ job_id (str): 要查询的任务的唯一标识符
+
+ Returns:
+ dict or None: 任务信息字典,如果任务不存在则返回 None。
+ 字典包含以下字段:
+ - id (str): 任务 ID
+ - name (str): 任务名称
+ - next_run_time (str or None): 下次执行时间的 ISO 格式字符串
+ - trigger (str): 触发器描述
+ - pending (bool): 任务是否处于待处理状态
+ - args (list): 任务函数的位置参数
+ - kwargs (dict): 任务函数的关键字参数
+ """
+ job = self.scheduler.get_job(job_id)
+ if job:
+ return self._job_to_dict(job)
+ return None
+
+ async def get_jobs(self) -> List[Dict]:
+ """
+ 获取所有任务信息
+
+ 查询调度器中所有任务的详细信息列表。
+
+ Returns:
+ list[dict]: 任务信息字典列表,每个字典的结构与 get_job 返回的相同。
+ 如果没有任务,返回空列表。
+ """
+ jobs = self.scheduler.get_jobs()
+ return [self._job_to_dict(job) for job in jobs]
+
+ async def reschedule_job(
+ self, job_id: str, trigger_type: str, **trigger_args
+ ) -> Optional[Job]:
+ """
+ 重新调度任务(修改触发时间)
+
+ 修改现有任务的触发器配置,改变任务的执行时间规则。
+
+ Args:
+ job_id (str): 要重新调度的任务的唯一标识符
+ trigger_type (str): 触发器类型,可选值:
+ - "interval": 间隔触发器
+ - "cron": Cron 表达式触发器
+ - "date": 一次性触发器
+ **trigger_args: 触发器参数,根据 trigger_type 不同而不同:
+ - interval: seconds, minutes, hours, days
+ - cron: year, month, day, week, day_of_week, hour, minute, second
+ - date: run_date
+
+ Returns:
+ Job or None: 重新调度后的任务对象,如果任务不存在或重新调度失败则返回 None
+
+ Raises:
+ ValueError: 当传入不支持的 trigger_type 时抛出
+
+ Example:
+ >>> # 将任务改为每 10 分钟执行一次
+ >>> await service.reschedule_job("task_id", "interval", minutes=10)
+ """
+ try:
+ job = self.scheduler.get_job(job_id)
+ if not job:
+ return None
+
+ if trigger_type == "interval":
+ trigger = IntervalTrigger(**trigger_args)
+ elif trigger_type == "cron":
+ trigger = CronTrigger(**trigger_args)
+ elif trigger_type == "date":
+ trigger = DateTrigger(**trigger_args)
+ else:
+ raise ValueError(f"不支持的 trigger 类型: {trigger_type}")
+
+ job.reschedule(trigger=trigger)
+ self.logger.info(f"重新调度任务: {job_id}")
+ return job
+ except Exception as e:
+ self.logger.error(f"重新调度任务失败 {job_id}: {e}")
+ return None
+
+ def _job_to_dict(self, job: Job) -> Dict:
+ """
+ 将 Job 对象转换为字典
+
+ 内部辅助方法,将 APScheduler 的 Job 对象序列化为字典格式,
+ 便于 API 返回和数据传输。
+
+ Args:
+ job (Job): APScheduler 的 Job 对象
+
+ Returns:
+ dict: 包含任务信息的字典,字段包括:
+ - id (str): 任务 ID
+ - name (str): 任务名称
+ - next_run_time (str or None): 下次执行时间的 ISO 8601 格式字符串
+ - trigger (str): 触发器的字符串描述
+ - pending (bool): 任务是否处于待处理状态
+ - args (list): 任务函数的位置参数列表
+ - kwargs (dict): 任务函数的关键字参数字典
+ """
+ return {
+ "id": job.id,
+ "name": job.name,
+ "next_run_time": (
+ job.next_run_time.isoformat() if job.next_run_time else None
+ ),
+ "trigger": str(job.trigger),
+ "pending": job.pending,
+ "args": job.args,
+ "kwargs": job.kwargs,
+ }
+
+
+_task_service: Optional[TaskService] = None
+
+
+def get_task_service_instance(config=None, logger=None, async_session_maker=None):
+ """
+ 获取任务调度服务单例实例
+
+ 使用全局变量实现单例模式,确保整个应用中只有一个 TaskService 实例。
+ 首次调用时创建实例,后续调用直接返回已创建的实例。
+
+ Args:
+ config (dict, optional): 配置字典,仅在首次创建实例时需要
+ logger (logging.Logger, optional): 日志记录器,仅在首次创建实例时需要
+ async_session_maker (callable, optional): 异步会话工厂函数,仅在首次创建实例时需要
+
+ Returns:
+ TaskService: 任务调度服务单例实例
+
+ Raises:
+ ValueError: 如果首次调用时未提供 config 或 logger 参数
+
+ Note:
+ - 这是一个单例模式实现,确保全局只有一个调度器实例
+ - 首次调用必须提供 config 和 logger 参数
+ - 后续调用可以不提供参数,直接返回已创建的实例
+ """
+ global _task_service
+ if _task_service is None:
+ if config is None or logger is None:
+ raise ValueError("请传入 config 和 logger")
+ _task_service = TaskService(config, logger, async_session_maker)
+ return _task_service
+
+
+async def get_task_service(request: Request) -> AsyncGenerator:
+ """
+ FastAPI 依赖注入函数,获取任务调度服务实例
+
+ 用于 FastAPI 路由函数的依赖注入,从应用状态中获取已初始化的任务调度服务。
+
+ Args:
+ request (Request): FastAPI 请求对象,从中获取应用实例
+
+ Returns:
+ TaskService: 从 request.app.state.task_service 获取的任务调度服务实例
+
+ Note:
+ - 此函数应在应用启动时通过 startup 事件初始化 task_service
+ - 适用于 FastAPI 的 Depends 依赖注入机制
+ """
+ return request.app.state.task_service
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.gitignore b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.gitignore
new file mode 100644
index 0000000..cd68f14
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.gitignore
@@ -0,0 +1,39 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo
+
+.eslintcache
+
+# Cypress
+/cypress/videos/
+/cypress/screenshots/
+
+# Vitest
+__screenshots__/
+
+# Vite
+*.timestamp-*-*.mjs
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.prettierrc.json b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.prettierrc.json
new file mode 100644
index 0000000..29a2402
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://json.schemastore.org/prettierrc",
+ "semi": false,
+ "singleQuote": true,
+ "printWidth": 100
+}
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/env.d.ts b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/env.d.ts
@@ -0,0 +1 @@
+///
*Y zuO2GFwt0ZV#W~0fncN8d+Z`wsFk;8XrFwSjZe4XI`!{WUDjMsY7Of)xnY#|Eldl&L zuitk-DW1M-=^A+?R5OJ=LL^gUpBk2tLO>rGe?b<+WyORZf%Oi@ogB9I{xnTGF#hu#AM PV2kw{&i&;9*c4 zWoz`hr}qyYg@4fGnG3zDX8C-~%}kedXs4YVm;x9lGtGReB$U52s4oU6=)lJ(F@S07 z5ptCJkSUTdJ$^o9pb#S^nP3BKiwW-tYf7$QW{S@O!5 KNe?h2`tC7XvXL- z^y$d}>Hbw4 R;X zPTwDWwgLuuVaFj^X8Qe>eHcW#rdLl4St>D20;@d*DdstH&@d)oghYe^9B7eDG2@pK z%n}rOZ`#Fjff@7e2SfXdX5D&LFJ&R }NFw^kj~_G2fRzs=bWTj;(u z+vQPGP*CeerFv*B${^vo-u^x@L_R+n$$0^OUp%urJyOQ+e$F0kn>H51bWQh;S_j|t zRn-arKS2~72e NWh z9x@=pXi-+8*FHSR(K%5a2a#_K=}CLg^d24b?3Ufy#M$A7-W?;K?oAr27R5lL6^D;< z25A}FZyD5ED-Ipg`5jxzpZMO{e^^aeQ1Fi)+=PKK-lH3Ksg~T#cwR`g87DLc*ZFN* zNpg<-(l57~Ck6)$k;Ic01^LB-;R}Vgm12} JHb>LK;4ls9bJ*$T*OY+FgwBzFw(7I&UnDj}uv)*_a zT||fcy7#?pv7EXikm2oHj-JpjM)Xm+ZzCu%u~QqoZCZTb``JmH000mGNkl 60#=y-N5>&x{|fGUpXct}x7HF$(G>KMMQ2Q3e`MtnMNb#Hv4} zSu+(dLOlZieI(u_^nN~eP-J>JbF?0Vq%a!Mp`945!!@<)%nIJMaS4VnJ-7X!T5|X~ ze(ID4wruLUsoLLO#FFWORVYf?$8J1!LZ|SGnW-6Xc+5 12QA(feF%g3i={Pn0T zQ&&!rM{5gF#+Y`k =kV@I@n*)jbDA|(R36(Sk}?I?mn`wY zf=E~hH_@44q@d?6nxJYPlXD8Xn8)HkbiBR$BT0$Mpg&Yw&lw;n#X#=ueKj?z$Q%8F zO;80zZcdR$Rmn6EV+ORS)NfX7WL&1DAMVE!-YR`z{wkH>-Q#t2`dqKh%8+?RvkE=2 zX&3x6mFJ8W#j8m^iKoB=>>>dy!a!9Vz _|Hlg0{0GMr{i zLwCBlzN7>yM+^bOAn?)6@;UjU_m$U{0mgLG+&2~Q_FErSk+a00^hW>?434t+y}SRQ zrr?19m=Fx{rqB253>@hfD>us*!Q;@C!}r#6`b3_;pE_E-pPHwO=B&_LmyZ$7V5*d= zDG{*m;~HqB1$&P~UgMejAq3h&q?_EOt@a%|s-x^MG;22trpY4a#Yc`aZBu=I{&E$F z;Hl5gkMdjdW(XA&vLcUAKwq#XV$Y;iGKXL>?@Rc*z)>hwU016MIul+@s28_=sN!WB zh&O%6m*=n4jc4_gM?wMSK4m;*ZVb|uz1k}vXvKjeI>nqT0Q3mc#e-$jH5Y2`CFdx| znzEPI;m*4jIa`iZQw*BgwT+yQ7!wl^1IZ&~TF*|pk;pMiG^k}0W&DY2-u6BG22JnM z8t)f$?ZAF2a6Ii =LL9gxHs~?Z-!`m0hx5W%aQ%|t2LlJMgeqe8XaHvW>nl{p;8H*UF7<9E+P-c|j zRZqEIrULb8&%dsY>;~0@>Zf~mQo;yV4rkJDQHRtL)g$Ac(S7vDhMme73z=Z0xtES6 zH??LBac^JSrwiC11`oe-I)PEaYvu=`I>|c$A%TE$$I>#q7Gx;hq^wlK+qIKlPLeUw z6TNzH-8PNpRdmjW!IYqiy)MN=9vygfCiP=0YG2tzzdUz 0NZlWvP0(fR)0sN{ z;Wf2u)F{fl>%4Q+8E=(ibXPPuCWPa}2(vy&{(dqbZ(Qd#dV0fd_&~r-6Fd+BO{*kf zO-iU!p>I_Cw$NoF960QOlp>uNn3JEmW&uL>VBwatILA9PUk5yOZCW8$+Ssq6Cs0#n zTE|@N_0(MLIZ;Eu3l82BE&kMoJ<8zg<4&i6ZCbI h;nggDBT_4t-Py&_$^D#xb(+G$3<@1@D zMZO-*ohm97Jj&teks)#+0~4VY7K$2pPO5T+Fo-K#+)9ER2wieIW8mW5`}FLE!}aP# zqtqVnc@!KzS%W}GU|z?mkW3EP0eEs?#!r?2jJ0RewUhP1m7^qp!IE6^F U^qdn ziV0LYeHme40AJ1%9e(ou*qzC_N{la(!r+0`Ta+gxI&mg{YQt8`A%nyj 38b!I0HB-SbFV|922u3T3Z1KN)Z$ne^={Eji}!w{C)RJ*AWo`irav+j zQwFA3&OC?ybVR7(nzv;e7K9r-mC^`CVXeg6`pkRw4h5{_KnkGp)!J8c4;LUan47`K zP{#Npd?6_@X+PiZ&h6Mu#K*@%iV_f~o&(-_lK~H2@W`AW zxwm=we zOc6gApRAzxxRd?HROl zcN~!Cw+-&8+t2E$Ja3az_;e*2R)2I{6yY5dBM%;KNI%XqC>qD*96nVm4|~HvhA{#t zh4T~Pb%TR O0?-870`dm(CfM6Nt=xM||FwFP z9%c%(;R|vvwoKKtlR+=&U3L~_1Y{~Ecih$^JV~;ZM292Q^>%<5AdvHUk+~0ADJw{L z_s)4slx6gZYAEIu8!R2vl}B4)2yg4#vZb!$QFcm?j&ZS^we>@d>eN;qISx8Q@wmaT zD7YBk9y5;CDRrM78GzFe0Nao<$we1XzK%4oRZ~5-Zach9=X7bsd;cD~s(U9!B|1U` zCRi{=3_Ltf0R?Lnr$o>_Z>^Aw?~JTj=AMU6U<&X~gfN0YaFbwUn9#`IJa|YVMEH?2 zRm0_FH_X>~QE~g6rTWsVOH@%>78gX{svP*LBOj|@( Lahi1nn1 z*Ol|+uwUJ|k1V63An}EniV!W)l!*BxKzhZ@ %a zd{nImOPj`Jdf?rS`k1FjKT_AS>!hgR7=3ybG6Q1{3_1OB=~|%#E>q+~ `pSIL-PKnq3Wv-$U5)6eWA%I {)5mMR;Ut3A*DH8A|ms!jTQ ze}6d2pbsQ+SDHRIii?Z x=!n>fyD!bc}D1ZTJ8)n1ylJ zqc3YrhgNca1vD!_>@rdyX5hPR)|=3KELr4%3dNg N9(&11obq{CtkrV`+zwMu3^> z9V8ZaCph2&ik?mx?ck0IrA`-9Y*iaxxqoLwFWo-x-H7E(j@6BLUH8=yJYQHqTJa9T zrxah@v{mmM;QfVnAsg1T;(6uCy^)LsA5BAKh*aE52Z~UT8jM1(e!O^v7Vwyv!YBol zTO6B0V*2cW9(rKedi`qA8XZHcZ*mq04RpQ>q?8K^D!|w0ySKR!>Jb$U<9&_=gJ ArdZCUOsS z09HyBXmj8`NGvLX$@kw{t~YoG5J3~Gu85QW`BUS0<(=2fvzMrfclQQukyTJ2lAMnp zKs*;$pwrCc<9$IaVC)omg^)(Y{TvTT?DuI>$ACI8RI%q@$C}f!yi`wLajqB^`DcI( zsN&eZyU>-lsI0&dlQZ+=1Kf&W7s=>Pgtv;+8JQ<2%_x%t2pYu;MMV2|7c9}|{ydBG zekKo`Ad|oDescPGYTl?q-*|Po>JXA;Ib`I3mN1-%fKGr6L{#8hka6TL8YD;@!%r`O z#Wsd;n;E}!XF>IV|G}H9)r7~|M=l+w0&RpT{G}6xOkaL}p1wDKspMyuh&3lMHN<5^ zFcrylHE{)oVm5dt-e>@doP1!AFso!vnZ&Y*zg%ANqJ10h;3hu$g5Dt-L_NT`5y#RQ zS5MIPqt*K6t1DE^OH;|cEDT{1 cv>9`l|WvDg5RFI zOl$U4X~rifs0C+$41V<(^G!aIUi60-^gf5L;2t3*ZXA-)FJyZ30RnCY7&7{ze6~-d znnHu6kgQ^Y$ZrS@;cD1m!Kor6IvMlbc}ulu=Rx6cx!n?Fdhwb`I?NZ|36IUyQQrMU zet-=m_p*Oj$>0kIx{8Vh91v6RR>3QW7;t6Al&aOlKhDv<>Qj2*)01R|5&=S{%<`Rk z^u1RXG059|wV<88%%?SmFvpIPV(ZlDx=a>u1f(LuDR~1t=nzbitRk^wgh+_%K?ZFa z6IBJ; Uy0**gY2x)i=lX*M!Gk(ZVes%4NYpW`;k6nGivv8!iQm z=N{6T8v>6DC9p4ttZJS2$jiEML|^^K#Upj9;Vp2Gbc_hQ 9$J%8a)?yqJW_bp;H_HZ9ONq!-7<4Jqu=zS z*OtJep!*gs*X57Bs82sOGtPZqIPZArO`*L4SPeeWA<^SO=AQ62X9%ZwgWGiyQ5Ex` zC@V4EIRyrodmjb?Ees<0;M3M6x~w+nOT+rg-*OttCo=iv^z(21w30;n>D;$ =OYGtJ$&O+)X0Mnms=M#JxWN@FOGzW-TGe&j{X+OSO)J(=p7Cuiy1 zJ%{1I4GtmiD9ZkH&SGTc^}A2;9 WsNl#@6z+al_P%)jzO}eTnae_f8%ec|^sX5AASK0B#vZZp1sZz
zObV^ zkQ#2B00{=5o&mrT#!|)z@O3i2r-z^!JVmw51@7I{Y}bBH17Yrq;F3+TkG~6 z)|Z}tBYJkzp(AQ{*B{mHr;q7JFTbPUV3?;btax9YY43E;pS1Px5o88Vb_=z1$3ERL zexT0#%?y?E;`Jn-+D~vMK`b=I+nwXzBko$bN@l2o!asj&txjacelv_fF?jAOG4HGp z^x$P YjD;|imd3ujX1%4lU^2_eX#(qkFq~*} z>(;~ZKENF0nV=32&)={^2aeaMgt`CKXXXng(Gd?59m;XKOmh0xYpdi>iw3yQ{%N*K zbEc1Zj-I*cLs*-t(}I3DbCJ@Lyf`{Z{F;h?a=9p5Al!fW;sQB;r^#@JAAkbql2BWa zz!KmeF!&uK`e{hJmhua@utUkaCl1!v_;ej$tl({Q6E=Z9YTPW6A=UD|Y)uFY8B*zY z@)%x_LnP%A{rmiPB{<*u+#7oT;1QjG;s2huLTf%cAv1$4$e;8mFe)geNhFuG>)3Jq zdC~jY$7%9SUW0v|=rN4+%cUFSL!+#SFM}(?{ny7@qIW(#B>6i(4op0dNUnGtI8k2Y zR-5YP!F_b+gdr+8c7_8}FouJqUtc^-KVX%0o|3nb+`{5Qy1C*huR5?Kk`zfA3}i84 z5~dRNkL94h^5Js(u^RdH)3@JOx3 qklFrC+xFUGfyzYF-&0q`$D {FmVZ2<4PWswF`F$TN$tauH1K}H}xm;J+Fj{PH=yOUrM$5^V zauF|@j_cA&i*BB*UrZUHvV0P!qnXLCqDEdKnVHN?XLW3?MYl}Xs+%v>-7I9|ySDXS z38n&Iao#79)&(WG9Q{EuNeN|3C~(8e!BUdBA? 00Hy}1^@s6%hunD0000WV@Og>004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00006VoOIv0RI600RN!9r;`8x zfB;EEK~#9!?7erqZAWz<{#~>8x#cx=3H1Uh2vn#73Dkvpd8&YoE3Ww@acn0}>|f#% z+wm{OaU8c8+cBntdPnps(F8&ukN^Qfs25b;yXEYe_4{LH&8(R{`t({o5n5!qQ=wJkf%>EKVg=TZ=*y5f&mL%)jecPpr5>tKw)y3 zIV?5@z=ZcWzg~r|aV%jV;ax-o=fb)Q^d| L){OdWB8C@(wNw$bq$31uxTH~p&S2^ z{4da%DA1o}2Ndfybs`QYTW2ObOHCLTiuI}bkS$0ky2`Dx^DHJ_toKlw7a|U9&7@UI z6vkWWdzddz3N=oSPlvhYY#Wm?;ZgF E93*AoV&B7_r*0*>fTT z)by0HPbnne8l!3Y@2-tkgg6o47%)wwLUuXbHuw9NjUn9ZjF*d=F6x<{4&n*{?4 GY+dv_{!=Szx9ca; y8>BHI}(#EQN2VvHgNjG`lGiwO|41;>Y z3ORZJfL*fWN$8qw`eN*}EchyeQ=2x9M-(IVw-#KH2CgqqG*hWv#6jp$pV6xhrg2fm zEQKb0y2mH`<4OaB#vBHPtMLqSh< dNg1P}Z^`<`wp|99bz2kYwY|ndkV4TJ zw6M_i^ h?4zO@UXxIdh!NCZcQ`tw`B8#RePBr1YmiLrA zvNnf9eXqUi8CmGh#cPrV)cYKaCAN5o8~oyjKa7t(^#oQ&4H^Jr12m2i#J%6-tQf!i zjR1=(m?~~usxkh7E8F3SP&-%llw76|;L|iX=~EQw%1RTan9(ROlK8nW&Io X=o8ut+D;C=$A1dC zXjhP%+I*s4L Rz)!@^;nfmLFREWwU}?Hb0f9sFYa;(-TX6SxP-Yr0Ldl%MJK zY#POKIp-o0H)%YP^#3Ob^hw&21H(E76VSln9&T{@)tBKeGXl4bmyW5S{NE{8^o?a7 z77J^L85Dv%eI6UH!07@0V#*j=jRu+yY;vLM{e@<5u`p~O>R}cYN6d)wWojI`^+HPH zXR6Uw(Px0sl>MnPyFi R(;n043SdEg%*r7W&PBY1%vv!E1>2y zW;1x7|Jzlq!OU8m0s&6EWDRJgEx`JPD>gl11hx)B($wFVO{1)HL;3eCes)T~gZ%n( z{w=d<8wK5oe0<3iTGaLjs6iC$<@^{z+B$`B9O!6ojZZTYWWwWosQn$*yQfh}gK{nP zrEwZBPgtiK{ln}+J@%ad9{4JfIjmzxV8;Mkj8;0FvT;5B=9Jgrc>p>P8Var{4Dh&r zWWhcQjJ}rk{*MvpJ?H|Q1uHo)9(2HIH(Z0;;XuX$W&~pI1CKc3G{VhFt|7~=Wb*n? zF091tIekAxK|~odsry;TAV8F}3v_?uz)s|8JCw42($EzXsmbeFvjvlhn^2e>iSJZf z2o-@I9jC=hh!7(n=pv)^u~q1oc98NVv(I@iP+W`55sGuL>$6 zK5sU|dnvQ#Fn!Nem0G{QK`KnmDyxxr_PrXPDPD_QpqCJ6)y=5ZUQM9Ky=Iye6bTM< zMAiM*v*zOmGw_%K* c<|fGyWQck*{&vZdvT9Kxo84pm4wIDdO6hYk|#5nVk#xD-mpo6ob* zmy6hjtYbYZOkxCzaVXedx0RHNeExK{MUAghQ=f-?fWp%YA5eV%z#cNid za!q8hKXOgjkMe!Vw|sVnDA53lIY{rG4(0bQ?KYwTmy{Ei3gvUAX%*vA7`*DRZtD`> zdEzNJW49gALU?|%*?-hAUsK8O3=o8WvI4!Q9S6S+fzUa|U8^fN
qy@6A|h{iD22s;F+doXGXM$<)AN1Gn2a!( zp3r8+AZr3$kD=e;{$~ Fur7GDK-bW3xVKc$suuc0fu7bu z^;@;K^nH^f`L&RnjF1dnHG%Fw*TWL@F#jwU=&_O5??DTFrYpigh_OF$tTO%{5s_kT zhJ_1ThryIZYSjB@yoxvw&$Ai;3?>_f-YQx!ptB7aDl3LT7GQFI?pz16Y;no+cEMk~ z=2&cmhz!b>U(ILzY+lb6#6Q%|KRJQkgTUmoppk#}>8EklM?Z+i+6FL1gmW3Go_4#U zNY22ic!p&G6ZJQL5L)#JUzAQD->l0SW;sVb0@OsUGV80UG3?O)K$Fr}+G8-a#l@V2 zdYQUr;K%JM!mwtQ13kttIJY5CETJuwe^<0M^(wl^3f_+SvpwV^ - zvBq*eDq2>^T-yT(Xp8uyt62-cup5OcLP$qdGbbh*C6I7X>42=KyvNb%FZaYGp iE(ZEs=kWM!pY IZ0;YLle-f9rwVEXGYY@VPdry%6MfF82&lR#ICi&IyGSyoXdhb)*Twpl7BwFvXF zA8QoorA3z@ceVJ#-!WLOp-lT o&gJV3K%r3+1H zV8W`dkMR9~vaSD~rC19~)=!C2GJ7aquSr9B3{AE13S$;Dagss<_Qh{RpqHtMq9rk4 z9K(@Ea>;c#bZ!oppLhyh&>HySO!d9@Y+bYDr9YGFpNxgB2R>%6O?9yN;?gQkxb7M( z&(8skiB|bcdA8-6m$aDpxB9LEOZlKXfqprBW~!Q9zb}le!WHUnBX2ZN6zElmQwj7* z&|uG67+^37Ef#@MsZ6Dm3-Jj?|E>xy6zeQPVTFfda4ipwLoCG!5xF^&k_gvIG;&|_ z33^H+$xnWqg)p2N`OLH3SG^j5T)yG+iOH26?W&9cSX!WmzSqcEbrpD$#XqvgLSHzh z_zCQ(q@@z*B+4<-M3^`Vc0<96Qp%Sx{WY)WYX%zoqI<<$55#eJAvN5VDb`U>zyX8S zWs2?(q^fqEdYX<1^qJNZ#s7gYu#G0TyoLo#fzZUhRI+*EATh|B7;E&gPmJv6iHkX< zGEz!U)%)Ku_F%#lC!BznY*-&TMpFrVe74ilp6%;7E70rKh*Z*YxR(hhTzwtx<}s)> zV6@;S2xO{h?;?5C!q5U;;I>%>x(I)lZ*mFFqQX*78ottK3(jF!jgixij{-e4I0&!R zwBTA$Z0SnKY2SorigcMG>Nb8S#u+O{CKihbb_+N|$+8QuGWD^`X$zwea!H8kv2n0` zbr9KLWj)I0D$G)} nF^xs^@xy*8jg)Xg?>qZH+o* woYl0OPH4zzaOXfJwzEqLuD~W~p+H{SE4;)F^dCCooZK z&jg$f%F@b0P)K^hT;AhULot9X6e!hJczRLtoyC3(mzE?nU=ex}e3Iqf4{OX?2=+E$ z4UJHAO#ah2Dd}Oy$rzJZ8dUC+cHMLcJ89EK&7}{lNiDj(t>Rrhf1)XvR8O$=q05PM zm3I#?&-ji>%tJ-D8{^Y5v!Eiw^{Qk&g5|H*3`_@X>Fy<0f>w^gMv7>;Qhu1G210UJ zTTB3~smF)pla$Rx|EvV$LX!td+$Nt_+V8zM&_LI$Ku;1#)!<8^z4Do7wQA7c(y3ho zV*M`R8$P{O_FyayC5oiU*CY?Uq;!Kx)^6DshY*iZC@`6oI~Z(||CtH%NsvBGYzI9B ziYsl1GOZK?;#fx_)h-wkeHL$6zX|{Q=wsmwV*yj#JI|r(IVsRJGZ9A2419WJ702Ch zBWx@7fJu 4S%H%(Sv*a;xmw-jpU;q z7P17>%UPc2qbh5pV+Mgvg@w)rwW?!UsEdKgr9gKm;9;r@^vGZ*3-oCv7bgjHU;kTa z>f&0M;#-XH^$dzdjjpLcNM(`)=_p06SQJejFr?ea1bTrcRJ$|@xs2Tr$lhjpCe;Lb z{FPAUeX*G0H0#C{LT?HqEs!dzbVm#HMC!h!-s^8hmstdQLQDiL(Q78sUGoaAunieE z7u6U=;n6~BNa>prS5jK$g91HvvVhs0+DoFyP4c0spu^~rRDI9)Ev1E4PEeLPq6Bpo zOHwWXO?;~IjYuzL=94jKS61=S<4(e>)^C8RAvr(d*t1#Q_nZ>wWrsU^WSsgG *Z4Ql~%r ziuwvpbxI2Bo+t(Iu~pwsSX+YTh_WcOl(8ULCbcr2`oNVK h;B`utV3p?=!hEKH63U}C zC5kia>;x!O-ZN|PO7>H1)QNB_)CQZ#X$FQVo6x8OSIS3o%SQ)#Wec$AcP}D-%Ec9- z9pe$SRYm#t^0StUr5$Pk8l}F=Pq9gRn~V)~As)F${|HUU7{Hhqpy?HY1`Lz3FNS`6 zBY;Mr#J?KHVmv|>`ZT-*dXCzV9_S}k=2p*fRBucFe;L6Iy#Xs5F}zQ^DEf28UQlHM zqilx&XS|}u;Z(Wan7Q}YOlw4R0G%Pi6-zGAyZ6Ito(j50H-b{OiAOzQ9>*BS8BLE8 z3980X^iSbSxA(^}o8S-^Wm=Q|A4=04cyLr;RNMN!#N?swA&6bpaU2rG7>wFJOJg2b zZy5d<>dcCqW-Wjb*pNr*!u};kutfOYx<%}G{nfba;fG+u{5+#_$z^w^DOeSLIM~*6 z#X{FYqGO9y7#w-!<@m;E9nj2hkVOsh=`m!Vdz@>s3UVzWc#Z#M!E|ZYWd%(J0 (m z9W`9US|moUj>i>-ug|I {)3e>vtfY;ytnU zd_|E<&RM01M3_I3GeZ7}X(kjw`)3lGj~IL5{F>kUf$!tapZs}@z-T0Ep%P28mJEVv z*8W;7_ ?57^7e|?To?M=Pk6Qqo^ID fq##`3JL z!qywRzx>ncsKg< {Y6->GVCpkbsiGmMFOw=o~Cs?epucCPkkTeW^G)dkUyOk3f{7>J@cT%9k+< z6r3i{Z-KNyd{B{%Ysi-wb`vb3%DiL(-S;~M(3z}{aUsW`I~il4FqLUbnlB9PE$aQ! zCspPvRcnwq%9^sl7}ytsRyiu$zO5z@rti{W*2Ru0k?j)%y1sl1&coQ+O;ncNm4a14 zqE`hv!$Z{U;(p1MQ4 }RC^Ft6u?h0a0PjluYr zU;Qfn>bv&`t2{xDKIc1>|AR0fhn^N&AtaTIt4axEd%Nb7CW1Qlve;CN+@Hw0Vfl^2 zJ|8}WLlGn{D6oqfy#HGLUa`{S>y<`35h4<3uoUQAA|r(YRi@Hqv{d7+My!YLrZ|0A zN@1e#nGOX0BlGAd2-PyalP*nrSG?Z|yeZ5w8%c^oDHJPBx@dJyu`oCwP{~RSP4rTn+2`mN`?+4$ zw{l5!!>lVBj-kF1!ozLnI)t?2()yRGidw;kC{qo%0_~^vY;F-xxyp1o0T_umLHAmq z*RmL-|GFOle2`w?w^8mTLPnk_mV0GWiLF!p0*GtZyHzUZNlemyT@e_U3G~WKV+m@t zXT*N1WF ~FA2iAL}uNJ4-0J1vkQAp`3oYdY=yh^y#Ij}?L5zmgU1SR8m^&Vxc!sH4UD5lsSX zyI9Qdfj$kjCu=L~&xAb_=SliZaglkWhE)L|&hHPE=!|ndX~+TxJ@piBzxZN*=nLeG z&utgf)imJeD&$^v*0RqBeiDQ|uDk{-3v)oz`aGNpQDVh1C;$53o>=!dYNo-euUMsh zZ9ca&GEGn*o%nrCP2{4P;+ tJRm9qP-%rW7qI0;d23zqu*gNd@Ew zR~O{+tMcQE@OKEx1dwxMedtXwk7A8QVFOfsjpe+B7iRYjh(KhA%g8iF)1rVdW1vli z)*=X~DK?nJ&=sv_MeFojf+b}@tOYAigDZMmJu)26jI>2P83L>Y%aQecX~qql0YGxJ zu=3yQGOkoA1!*s^{;rtkv Sg9*3zMw{dXyBTCcU^1KurUrGU{~bT;|9Wp-u?OO%%#p3jI0qo2E(asQ0I8Ndc~B zlgFWzi>DOmF(4&aBB_&p;gj&Q$fXr+gib~7bZK4Hu0ptHR#Nr6bn3(QoEnP$Pbhk4 zCX@rAMP7GZ<)xA-`k{T _fjq-s;482gEypKC2BnLW2NU?_p7vri3biHshLTD6B z(^9@Ia&H~ObPf-laSk>b-_yIcY{5kzxDj{GZGde8{Y|nAd$ESnB#CAnXu-vOp+Eho zc{TUpIc5iHiaixiB=ZeY@DKONrIdCLDGjc%9-S$zb>SUx4a->6RA*OYJ;_xe?xNh) zCcbo8ogpw&DgWMxae ox=ZFwkC(SrqR=R8}ceDr9*bH3f3U{kEN-8`D`__KH{F?A>+*!N3A& z$*f5*KO6q{ABjLO+aEKdabx_|J@?^XeBmpwqXrnY-o;tu%lZYa2rAaOKzYP5?L|@> zVkVZz8ZLZ*Sq_x=yprZcOuZr_>CpiJ9l94D0H#W=aGyaa`l1>U=urL*kdrBxeP$tt z_7db? Of~6;Bl13#f40?w6kvz&D zY3)nIH^VQ6F)+6>#?$AYhX#Nl&(+4vSYpQBm%bNIt=k@K)Zs)V>3FOJx?W^uMgqk` zE2)xKB0<^vc*0(BTALIpV)6S|1bXg=l^+uz vlP=T+aoc$0(!3WYg+|+p)XDac58xtTd%Ubvnup6H9n&>QZm0z zP^i>lOo>k_MPBmOYiC+WnzM6g$AmxJcQ5?w!(Iq$3>wN2ms$GxOy%&8G!LDvhg`{Z z_*Y-~I{w*hx5LggKx?vy=*p82D qIL@-hq6pLx;pal+=>GHTG1#WIf3%Mk4t{i z3ZHOVmk!Dx3W8Q-Nq~MuadCk`md2S_yawTW0~eGP2Zk%}6*2(>%8Q&`oZnCvHoi?( z7KYcKB>y`jW@X;Q=Z5^J>~B ~}=oWahjd-UmH z{sDq_m~lu~^O3_2%$;hv%V^@6K#{W_CzP^#ktq{dM{@#_vPdQc`;-KVB;eSV-U7a; z3Zv;dn2Z4u3=!6o!DAO*jI-KM^hIi&b6Qhen@Y)DXP-k(Fd%0@97-KWq|8qqbAp|- z9C*vgGKP9}K`wU1=d2l|AA1a%lB$g5zK}*(E!wb0m~HZH6G!8*pYJV7H6f3L(Fjf- z@{VYZ%+&-JSULN?50zz9lF72KRWIR3JR!v~hfJ~ o>XnI@;G(nh>W29p0B z`I2*rM@<`-KCeBcp|bic=6}N`WKON=&{;1$8i#Jq;WxhbZT!L~Zt?b^ 1;07Vf*a sIjf0$(bt^(<;j-D{iEo zkaMtg3`Gw%B@~KdQt+tq!rX0=`?E{okxnX<^^n|RQN&mD;AE7C<2{4BFsgneyu%|5 z%vxEPaD}eW;0x;)E1sFntqArIe5wn-Xnvm;Tbka@KCScq5B@EmO_L$a`SX--uEalr z^ _Y!z zC8fA=jM@OmI8;(DRKI3fZQ50)V5r0#`q*qQkVGTQK`=2VAr;qxq7?@S&I|T{;fEYo zphNP@>!8E%em3w?WIEp}Fm17j$ y2@bZGgq5kTL!Yy)4k*yDLA35D4A*50I_P+PK_8%E z*_|X~(aPb()1v@HE3^1&Qepr2Y+Z5cVM@w~i?px$QqoVXQ0NNPqzACcdJVq|-DEG! zVp&CZuE-mL7V3Eq-0%;Tds7rr4t$bvV>8A9S6_xzSU9!;?N5BFI!zU$PAdY{*oEGF z{t%+vdzEt>gL#aLKl%w={@5ybKEULS36^!af^#29^do1N%1w;Ek-lKb+XbRUpUqv} zLX2b3z9XV)^m#1rsQimFp$_p3_M&iz_V*0+PcxeGtl>&pOS;+BD>y0taAslmv|(jB znn2jhPuo|FQqdOuIp_Txidz^`;(&T?Z5at2tQm@%Khe*Rq$$WN%)u8@Np3f(EakHJ z9OAZkze+hO=1JNCrvfn1cGRd3slFHy1m&ceh_)oiaI)4!TG5Nbjes%Z@y%yq``}L; zs>0_-)?vF#-;3qN4gMZuz$OV9b<`LzBjgA}v5-ni6)W~xS>>*B%xsK!mjFz4ni@jf zLSMd^USDRubWs&bP;5$IO0?GJq5G7+R7hqPkd#@BkKu!q^jziN3&NNb-W=iR-|JE4 z2^~|cAt5!z9LD%jMW vd5nW zw`1iO9`bQ`Yg`L+(w2)5nAA^lI0Fj8gB;%|_m_PQnqi%|UVY5{a>3ejsp^$5REp({ zhT-Pkd$hHyXh$Y2Ano2AoMizCO(x8vHKBd~%vG{Wu?xiX88M4Yk8x56E)w0yTO1k% zsf?d8DV3fh^)BUC&0QXq4l83>-#e6!BN4}pP~RZZ#&;%JIl7douPo(#hiQH3^D2B2 zMwid6aY1>Yh{*KH3ODi=pEkp)Mtm7d;nxGIr8_y?BbI1w6>I2|wdJhbM>}+@*uZ^; zQ! R01s zvIw;>ui^gsUfmzZdECX&8WY|X{-qe$G%UQTZcY<~czS{2VJc^z$rl^43|I>|>uv3L z&N&hcU2kbzxGXbcDdDg5Q-25^)PwYeM*m=BZU(wq&A}vFNep<26$m zPS&<4D2_mmanot1qCD+N{w-q`h!}e`gpJEvg1?%h-#W&sWIrN2IWl2m3C 6#QbP4ab-C1wnSN<%zt7weaTryB=?tA?1=M%vmi^OWZu(|JdfSDe9RD5F6-{ zN8U#_M&;_o$4t4Rg{=I}y@Rl*PZF-QGt~YN-{l(pedptMxf?YZWyTaJ=Y)$IO7>qs zk}{$0@}FK`t*fsslA>q^Xb6w(v@`a(?A@?z(M+o~G1S*${%TS4MD*+mbR7ll*cVKA z*L@G--+t+K@O-0A=xGqc%42>rA%t)jmp~ag2c=nXn}W#dk7!A=5Q#YWuXK=y@z$t< z5m} {5U&d?z*Z9b5YT#4LY(!a- zwjDla6-U7USpr$HcmX&Mv!@NOQ5V0i*%62v|9g))n)qB?ZxseymS)WbZ8*sdGhE7* z(^!j*E2}ti!+LL36jz-gduaJT9r +t01r{bPdPs2&0 z2932aF6@V}FC^AsNI=1Ik~I;eQ#7kaodp5vd>LV4ih3n+Db!J4%f&%EdHW{K%a9^5 zX<9 X5(EHwKxhBTme&sdT8I5j1_lO2+J$?k^Gd9e2GT&n_t@% zwPDWxmcANfMT|0~SLRVq>V$d)q);TP#<0lR7R0wijRBBLd)p9T8aQ%zV8 %vl^4NV8db@&$@-~89t+PW8D1o3Vz}fp8@6!&~UKOn+m1F#z%+fbL;sF zYjA^83tasOp$9i!@WBMQU4r2>v@VEn3P#aBCuIL>Ez*Dx_f*d+l0c3Ek}iaLR0iM| zD}!Sn^L#avP=U}P-^T$V*&~DU`LSM{^?WlP*sMbksa|+f7w?o~Ktm&$>!5sB?_c)q zCcTx_zbOsL`@BuEpvl3P%$)516+xdao!SIw_&%;P87mrq+X&J1hZ^YRI6} oCt3?@ZgYA7J}N`fSV+4$pGDsEfx)7cQUx{l-J>rGta`l zt1BSa0j>jE>#b{cs8%p6`c_+*i7m+YlgLq_ZNa0wAYZ%BGpBid?e_cN)BL74sog_} zP`A)VE{9 U!#s`rTQw`u%O+4eRlGFjWv?SX>VL9JWFv|?hH;TqYIvqRkwtGq-^ z^pChEGxtYW=TTPGuegt*(&H=VeQmzMsUN!u&gj}IGPGxBZ$E1SJw{@N&HJQlaolw` zz|B+0Gj9PCh(S{v!>J1XOmJ&rtX@+iCwbqT`@i(Rg@q~Frk8PoBm|#Ji0+oSj<}d6 zEL_75h8^HKwMdoV6Zb &8f+(gy_Gbif?LCJ8`Ue(S{7dbNvmH^XEjM1Y0`563CeVmk(PM84T+@+&uBD zohWaDg($)yt_vNgMLDg+Ckcb{0F`2N;WBDW2?0rNgrH0~23dzaSC(<$!kpx @ z!4nZSfiUWpy^&^X5!8r*{81rrz@qj0vM~f>2-^|i8=KF^&+o8(+E>oA2?>Bw!^`^7 zUvr|Q7U)lO^VP>m6e`Y%Z4reVPbqHEr_nTH1V9j2xiUy(!X6IRaj?3j4OR-hN0dS~ zK|tA}+fNA+7!&t+>W-O+%0-uy)|D8cz19nIt)L(Y^&wAMj5~1y3K~%m(Aog^^FhHa zgqU%Zwe0t5K-Ww|32CdPF>9J@1XUY?Iw-K$R|jMVG;zkuqPrklvjjE`IuJgFF@EZE zp8_nSgVUk2YZB^1k?zlmK+pXx4od{={l53X&5yh=CrZanYstj}KdE6QfYr=sEs!~; zQ%PzWqzH`sIHhf6Q(-boy!W0y6}=_Tb%wAOdg 2s8#sK}bYoHHt#~va-Jq?(o zOiWT*M5E7w=m!)6zg1kC8r{!0UBmwHS+A-~BJ8QQGiaFbwM#Az+2{E)L-g0b8v=GE zvCt>ePQE8+@ut1@z)Dt)kI=y2FONA6|9ZFGVYovc6sxXGxQ0oM=vSU9r=#Ixa8`XG z#O$)(w*|^nL*i?-+6U PNHQ1r`H`QBbE`m$xJXMz z5dBkHN`z@k8d4QDiqji>aum{&SX>LN`b@}k&(-3N(6vtOFiGHZ`HY2!O5;A^9+D70 zpY;ln&BBz~rZ#noj4xEuo2<%@FbI=b1ZmFl{tpN5)RnvVeYDKA3&h8a5NU)%tv>}N zaQ?E{Hoipinx?1F#M%q413QcVu 0p{^+Pz;rZiGu`XE0L;Z^|cy!lrkngnsG{rR>_e{`C!HnC^-{+}j;B=-# zp)8ffn0kq7YOZlXnLi=M@~kXN+ iU*wM5Zyyt`34BK&PUq3zVuAos?(X;h*ofA1>H#lV=`IEoAY*PpV>zU@aC*!@KHP zJ;-RsTk(Nok4K}?5JqAywK#}^wjR!Jir~a#$tPuv^MfbK^%0qTZE&G_UPuGd{h5>k zSDmY3JRBhOTp^V{hytd4_m%z)1awVPf^ti|5|h9j(t8>#tgPa TG*n@&cbWO zJy+D&?*(NBq0x*xeEFo40M`T;WOz14z3URev&F T9;)7bc?qwbTTJJAv&Mf6 zh+F?NNm-0K9Ooa-!qZWEcEV?quAqHzL|e+{1|YHGeOcy$!1?7^OCyAeSJw7v(Fc-X z?+Q6z_Tl&Ak wjBo5Z{EJ&}#n&5zf-y!#wL|&CbwspKsAN17 ze0gpH!zEu-0?K+6INmpz{3G?o2l`W;4%Jtt6#ZU }A@Im2`ozq#LmIB{_i z|9+3Xy%2XcDocZwE;Dp>z?37bYbEE3Q}mNdP3lTSeC9@}7UH1(Ln<$onPDiUP?_qw zNmbyqMH)RzMLu)t*wC6{h)U)a8K0-DQLv0Q)%B?Q<~Km7Wao3jjWd{c7WZGY8B&te zxm;q+4rvl>w^H~t2WSMJE$}-pITFVBN(Nd<&}(ba7`%LL&Rgk(3`H>m_WEp$aJV+K z#LjT9kP4>?W2LWDWtebuG&`?wGoyD3n)BAV=`22Z!tpp{)L_4-9!>j=B^WMusZG6d zxH9qZgELR@;>31GV#0)bsbO>%*1z}$Fi96l?<|CoewN=(>5Wz2r<0L`5! zS>`&xB1G2bO-&wm|9e$=Q0+DDKd9D%4fC1MzKos|)|9Ub7+j8=e#9V=fMb?g3|b)y z)4ggMyx_7+(LwC2wF~u<4$s&^C-SW9qfc+eUw-%7XiXdQ?{Y q8b)3U10FmGaN@SOt|f|*I}+@Sn-9M5`CEsgeP{mcV*QBxfKgOrz>N`2LIx~ z147W$1f_w1>rX$|)5Br%JPgilsVvX39wuAwlT#$MAcj&H%b^i=7 4mBoVSevfg#H)4==u{UT7c^Gx-Za(|3+ucgRR z=U^P8v16 gCELEn4b8LC&fj?IaB^tfp-na$Me|2hnFD? zeWZtj-;LpdUX%yP`2sh7fs{UbU2}k)hT{uiHlc7xB7`s(1w%8V>HK-;|MRdzu-`(9 zc{FITMi{@OpdsA(#1FhOY!c(lp~ }`U2)bK0QuO3b;jYOwu9-s_3r)OoDD&* zdmTqKlq>I-fdg5CUpefh7zK9xfpaf`fkop)QS! `r5eA%??1r5;#Gik(Tc#1Nl(DVZp{Z!LX{G&`pI;TTsEub{vB}vHkkc=cBvg50Z zwIb*gGlN_M!);W81dOvF+=T}3d+1?wItvQhCe(Y}ZkqqP^ba@&_PFd)baMt)tn(gf z#TkL}$m5!yxk5meh~x=Ey%yjyD WJYu~9IVBt?gc zW@w*~a~6()ubh2m#!==$*u^d@i?V{21Q=1YS2-UzFJdMNiht+a>Jt9ztB!#`Yuay+ z7c*h57T7qT(a$zT6`Dz <||Gm2gsUeE8FrvQ2n z6Z^DD3pm^1;_Vjj=Kc2aO4lTs4t2j++vI?i(T4C>NTCv!fG&u_jD@kq@4xKuu;d0R zyQy)u0A5&@OASnPrao6)T9w-(V=ni9=`c<75+tZ@C0A1B;=`VqA2rv;VAB>2t~&ip z%-OA~zX)zQgkxhV>csgmvi*1H`DwZ(Y*Kr0L JzrM!Kv@b@YGo?d%E654BsiKkiL z7$gu>$zQ`D j=Xh#_D62Q*10)wGeXpUd+v>4 zMjU5S!Hz=#M zOS)ErAerLXvKZnAHJCXAWeSV07NA+J7mz8qp7zvP9N7YId+lq{QiFDeAUNG#Js=KP zf`gM>XOab?%aQsUnF*I2^BOEtSdZKvhrBDM#DVaOFFqV5rjn_4OXO@Oc%9*+y *_@3G;jCSE#mknK1EiPMY>dW^^QnRkg)SO-5Uf3uk*r{LiJcIe zzM=qe@;PNizS4jvu15g*Sr;0vj2{&5i(i!rF|zNdI3hRkJ__|^N~BZVV!jqgd3L>L zo9yGPSdQ*#L1e%Uffs-mz3Xjo+~BT19J9TPL`_VnL7eJQqUMPdoxc(SlZ!xzI2(hM-Eg&PbQu*g!dhEi}^UR|2?* zG`CO6P#)RMAlqTrEl=Wu7oDF+yu+`-y*F&9#*`NxkywI$&T+E7qDJo+)DeDfpM7!g zeADj>2)}d8iFuJ1-EJ9pw}xw4x!1L}HS;@5bfG3dw4j(0oIDv4 1_?05_j^F*YqPrTY2#fD`JdD$iv{??y!+ zCu@y1QkcufR4GM01_Jh`W~M8PlcLFO)UA5dgP12~E)_3Pvd!nGH@)?m8DyYdKYb>c zfkA_NH*LUG4?c`lSd_;sZL@_wm4FnqL>QcS-L*g~$405D44gEs*exAU-1A4IGpqfT zYjepKH$CORrv=OoOUN4w)wj5G +Q{3Z;LmPCPO&VA5-# aZyt77m6;MT z_8g5OJVKn5qh88V5#{ProGSiN->_mbD*8Cq{I~J
_$`9`VjNB-SeaOk-BGRv zASg^hX)*>d78iZ=daR0Kyhh_hR>9QGU24z_;Ag(@SuBkxmPt(z7DcO=T{wDF1V|jX z{9SjbrbW?2pUtd&n~4b7EecAS1lt9HoZK-LjdkjKr}s_6&HnabKUGXmVF%+nJb!f= zcVBoB78}30OWHJGeD(3iQ(vf9V6hJae!x%`({vWcvBl-bAFm3Q^yh>rFontxSQTvm zCi~U^a6pg0hjL53SET{Z6xA-Bph{D+cKTTu6X$jYpIjyw%j|@KY=shHs|mt>TtgBb z }Qwpo%7C6i6qJ@TqCo{Py#qCgYbQB6GH-!iZliIj~(tj`*f@`23!cqwJ6`Y zOJ%0c3dY%U%SGiu33`tl%h9gVFIQa)B<&B-C%Z_{$F$Ejw4=dp8v?FB?Q|TmWh+{X zP}AP^s;(tP0Yy1r$zk``BX$f^K*!9ChJcNR@Xa&M!J=Ks=p}sH^;}B*wH{9ntc)cg zI}NBn{rvOl+zxD;@R})Bsrq0Y=BgTD<-4->Lu#%@AO{NM`0oRhUdC#OtJ)awx _z=eZNOmIHEQ`@t%~k zTP=GjJ?gZkE8n|ZstozJ0|f3Alr==!$UxSBva|Ts(buk{UpLUjJ98Mu0!PB(j&m=- zLWrI #`n!$*Dhh@>`Vw7y* z^UV6@s%xcJ?aNWDc<0wluuNi|@}hn>w8d3mb1Z<8LSZzV2vh}YJR4mHL}uDOg`+!@ zVq4}4`#RkPJM1HJJzAt z~{8;7Mm_V!u*PCqp3>qdts uCJVVH?t~A zWDM@yem!n`>PgrlKyk2^M>e;pYe%IvFPiNNGY-4@GQb#gCT%n(Il{BAvkh0=La}(# z{hTrV87d`lW$~!~r* m=Vb4@yjxB*RH*Yw~Fh*cDt~XFVUUY{2+;gLHhU#5aT-VwM9am4Zp) zYip|StD#MC4?_5zXQriPZaUy6w%deT&pj91wAHb!MdL4nVTA|$*ROpY6pn}VmI>m1 zCI|e)j+=17UeD_V7uCi&^!=6Jyffs~>-}4lQl1b%S 7+3a >+0_hsKW*%;|h-0q39AjvtW7xN^uc<+CLMX@H z%6Q8I_u&WaTnO@0RoF--JS&}n>{PH`R4Rt04|7nFD`mjy)Q`Tbl7Jit#>zR_vwjn& zOdEEnYJwu4SZl%@h#DBvhV>ari)Iz^)&h#;LRB`KaR9LeTgIOqbTBSE;S@;jruG$< zP}@7d|9v{Aj>Ot;?y3;$S |~L4qJzW`t!pS)HX$-{4zfQrO9|D?|K9|^ zW#6*YR%H#f@^frsbun}y&wQ2eXGa`?zdQC=G>yUB1oy(MSC-iVh6Uq4-F`c4I|7># z;0$0JpaaM{zyhZ&H29m>yc#2Ft4Pr-?_*~C-Pgb2U3$HBI<$vWxzs4L3fMiw_c-SU zgn$X}ER_UCHRrg3HsvfstbLE3-0$2(V?tp4#?0ZGb1%T(zvu-ZYhi4N(C-!I(!ct# zi{j@R#+Bduj!z{m1p+fhWMIf(`(}iD&%Y2a*t!*PfU!P%1&$!0FUi@O^Hq{`95L>s zf}`A^Sa;U-EQ@5?sxZnm-Y+T?kCBz2K_};@OmLy1wimca3mlr`O2yHTl@fVC77(s2 z^88E&mW9d`PO|fVE^SzcU%mMg$wiHPZwymv!j%QWG4P}Ve(mO4)C}pVP1W=JkhjS< zNc0T{r)-$5!6?FQJY%
tKL#BkSqY<`9u@Rag*iC~sJpTpw=4mHmgcY#s;CSrQK&>*0kXJ{O zwp$1Mvs*upF^llc`GhDBOoX|H@U;ui#qaO)JP?n;uJdav2F)?nguYI?24WC0c{890 z_HDo#&kOyM?oJ5M$=;(1h5|}?7(Pcij70(~E!cI)&uHk!j>OXm |o;s#tWiHsx_O T0M*eV7-nU)D^&dgGfY4Cfu ze%bF?m7U{|e&lKZ%r!0cTUduD&)$r^o_ZY3Dm?sdeMLw??`reif$;RG!PWPD-y^KU z9fkp+r3Sxs*b(^dS*K&&3d7hjn$G&Qrr~(~sLUI;0NOAYKI?%7m922OAR!8?wSU!o za c$(u;lLb;q)(fOl{W4bk9D@g?WdoJ zzj*me(Qq5~@^m5L$icG3v6o#6ZYmYHBF#b$s6NEU^nJ&@=R&DUnUh@DDQHV;DN5+~ zQ^FLQga*}2P7ydx=Cy>$b+o!zUo!|)F&nuJ=3}HHM5*gV*E$=8xfzdfB8~9q`DbAV z(lNtpht_L;doA3V84oby+DD%RnmIsXARL3nz`uFHUijPB9D@c;l`&KUR0sZM0KfdP zk3>UT3YFw>HDNA#*%-18%1#u%jHv_(i$(m}V4!NzuaU-V@jt%tO?0quWYyYBw6H<2 zi?9@==$mhNJ^tga+o2t=0&Wa+E(Ei=ST;6OAdDASKX&si*y_3f?oM{YFaf(U;jzuH z!>d=Hf^!zkuykDapM;*u2>+yxR<9Kyt1ZC-cvoq zqz0#!V^DRVnUIc7bd|hk3ZFOF&Mx8ZGdE*X%V;o~s)hQldVk&j{NDHRD_^=T2z1eE z1w}xxJPZso*czO$afEA*Jq~k3XemML(*jBS_YU_thrO=$H43OPQIFEO?oj|{)#wCW zNps3 #9;rqKuh$-=bUlK+VK3up j1^4jaLJX%E4z|ToER1~e^IpP2y!FzzNXqi>o41%%r zpO>fI4c3AvQIvss@q3R3Tz} 9b8#UdXy%abS^9ZTvXDt>}CnV;Md|A|FaoR3Lpt;4_BeOKIf z&Sq?C3 F3K1)qYlPnjp*#C#L52i^B`-7>}KECSJL?*y~p<&1GAzw4c4Tc+m$whOMJ{AM6(b z{>TCEe$C7AD+fIvM5A71!f98}S0C2@JMMoFR_fAcUbE5#Rqc4~MT2vpk4 ss)r@e8sE6VxQLD>bfsR ) zW8jkl-;|~l-xUSMK%TMO3m{9FpLhlTW{gCLlq&ivffU6!#EPIiuR5U#Mb2VeP>6>l z5Idj)_GiZD&OHY^kco_DmI`0Y4jl+%w%F U@&Ka|M-G^@GlQ~ z0UF~YU)L%K9C@wv`p15N^FRJ+*fzXVMTGb1?~i8GC%VNkhU#Zo;8G3pj7nn?zG=?G ze8!MxT|q9sEJ6KZL1^<{?nwmh(L2p=QT7oH7 ZZM{u%0$+hqE>sZV~3AC@+4k&( J>nClpOFFl1j-f%t|jN+bSut!CS(7-5Nf-{B57=z9^i~*c< z+2y#!G`?!5VQ}OZ0kL!z7<=R3_S4V6{_7XeI-oJ8Iybe;IfpMl@i FV&xviKJ6t@HxF>|1GS5ncgynn)sRUWlSxZJ~U|{ZMEqxrlH)^wnIUzU6 z&DrGf8CfO_#e9oAIMEHbmN{DKv^vJ7C${3@pZIZ%h}4?MV^5g59uz0-F#`{b$9Q P0%Q{#CX@fG)%(xGP zGj6!W`}G wJV82bS3`pC$AdUY_GFp^@19-o-A*?pQcNaI{cRu|k z-(NLfh7#VR!+1*C5iu4B`1twf;qEg}$L>!)j%GDPrCW6*Dz!i}f^8ZcdEE_o)XJC< z*YvdcBLWWJeg{0X`C|O~x&<`d7;v3G?v>RElq^US1%WSoyn*8ZIT7eG _oUV%w+)`!FprNt_E@$C%HMr9 znwr5(TPEMJL*Drgd~ ^plO_W6}PqMgtyn}^>fpr%9 zUip4JEDBO17AS)aVKg?_w_Cy|&pQh{H9%|Hyw7X6L`_;@#;%uMfvxR)N@+4pS_YY4 zGtin53$FM%V#G48LN{lXwO;tpST`q{pi(-y=_4zZHIgze9EPmPlGb`Yde(XA8=n-g zse^VLc@vNw$XSrHm|t1NXU{wnFPh*hpVi7*%jMYNAtLPg)@#6{IdID`JdZc-z9atj zn8PqK1`L{3j$mqk^_qU%TI_tqbzX638lcg22*Mg-fGp$lCmoOP+ 8t%YMa80>BxDZ55PyoEpCD-B8uRRv8 z++q8uY`B0t)ZB_@f(1IZ9X@pb{dmKtK8dI2H=t_>WNfeu8^C)1ebX^+KjTy!yf_br zui`iRy70nii5*_~zPIBW8+U~>1ZP6kxQ6^XCIgW3_!u)7HX&{yE=I3~9#8JYUIpZZ z*UqE*3Y?&w0Y#Np7)t*0a)~gj8t=lB`+Ot@aF($Er4L>Hipr!Ozdkp^2P;$OFu&z- zJpG2B@;uYjGmeG}%P`?F0N E=U|t%$>8 uKNCIbF@uYfPEh% z!rp&-jSqqaqaC;S%vrC(TaP&cBNJdh8eTU;mqvdy4J{a_-1reM%njuQjx;RE4*zD4 z=i!KT3pn@S=i}!#EU5G$MNfvZ+&qdvHT_!X7xTvRLtQ40&-(~)&GE0n&hhXf=g+Xn zB99AJ6Ei#r+>G$rkA4b|sy%4JzM5s<>;5&S!Kr)hiTf|S5Py2$0ob^-9P-uS*H;K; zb1mV>8{Ut1eB*9VKnEuAfINl;FvM6egxfBB6Yf0r80_-!qo6LlqYE@<*W;bBc7!45 zv+ZN7%A}YT9j{U=@H0RsnI<{lM}lJ|K(_aUjtfBb%RvP{qty`7k^P9`G67*T2A~$) z5LV`Qz-O11vQC#VGsOPtHRjmi=fC`A{N1;|1!|g ~HUX5Wo28ufdIMm|UNB(O6)kwYcKsqw$&@Hu)O-l||^HGFBgt z)(JUUOb$m}dp+(LBc!6KHPRIHP+G0AKC*t7 |+ywubwz#u?rJ_sOTynBHnZ44r_sSN0>Eg}U z2=wNvy0*5_SKfctSA@I=gaiNnI&>pO>sma2WN_OhCt(DFG0ntwW+|9b($BKRRS!Lm z3qJk{uRt^u<8-ppvmN97P3!R9V~+(JLPNl^1$MmpdMwR1YVR_<;!n`k0}17LQ=M~1 zIb c>006J? zu^d=3I7>|&Sw^i2C{SZ;QJ`Dz>m6Cfzujve{QDOl0>dV>Vd}>Y6z%!neQz8yR$y_+ zWjEm-W6 I&7&qY|;?daQ))P;_3pXr=`S9)5 z=l|J**HbVn<0D&FaO%512-7l{fsbE!GG4LhU2O&o8a#J=ZSNB|3jui2I_&t~t6}Hi zkw6_g19&SFjGx vDn2DzJB2u*govtSyon3 z;QZU21DGt{@DK0C6+7($+m14GG ud>2E!*imxIGaOH2MJoh-CM0E;L z+~6ev)u~Zx@)BKC6m~hu>RlE4Kf*i|Ch )56>J@i(NH+B108nnw%|(_o{OE(fy}7-4rcA&OF~C =g7BHJ_l<4v7e~GjKezARnL;6LKH?a=U2~n!!ijSrb@FvAkGGU_e%Id< zJzDqHRxnT;p Y2zQap+ZbBL7UMHq0mmWk>dt@KF|=R+5T zy6~a~mAsscnK>GsoK~p%uD&d`MprV74NM5+Cav^@&pHO#4ySIv5!W7j5}L|<^euD- zIxx1s>@sX!H|IMLtu5^VSmVV>;71`BDVi*VGNmZp>yoUEj9!^iM2a!@%MNn+8!6(l z_?HTTdkTkt5<>$~T6{{$(!O&59#$iS6gS3tW_ 0KDO&U%_?Xya!l%8aMysSvYjVJWM#pyjGWe zQh?sH{t3FX%7i`N`8GVcupYJ mf}SKI%jdOB^-CvHMp1O z!OR%JBXtdr@YhEjfj8~DODF&pl=85zOQo)yZ^s;Y%4J`_6F>g7J7DM6gW5JKhoDg3 zBATC+TxlW$Vv~9 u}gRuEWEN8{is$UNo`8OBPyu@T`+D&lZa& zhed6G(1)~Z8)UZ$^CPD-c|9rk7@1+w;ZN@PCVu0NuYpGdz{8`|2H1@Jv8ZvAS)Y6A z9MCx6joZ!RZ;yYir$OjrgbmqZ-R1AW>Sz&6qY$+$^Q$!KL94atr?m_TxNP1A4um0Z zIV6D|`oq(5^cB2b7NZEy84E0S7N0x)WE?y<0zfcSDqfQ %EQ{s|+0Y?)T#Rb0dGut5|UWjI$tTu+XjG-U~Kk zJ7%zo>MgB(2C(6MS72#AM7xWNa3-`QKQBDdUBx`qDorv2jzD&dC(b$t8;kuc=PUft z=RS+y{q8-mG!Gs*e+*|MrxhmmlrD5)+zOmCc;(Vl_}Il4W4;peqG?dBdozQP;A_c` z@d!pZ`SNRUmo>0tU=08_!UB(R)9a4K(c3LVCCg0UA$^qBfgGVDi}S9&8aFlzAk)G& zh{ziWO_}@hQY!vM9+=7@XY 10xY0-;KQPC*gMEiXlx_^)?gDQ&4|l{fvzvZlm>D117ifvHkt *9KuQb(JVzcho`g|Somom9htBAI(d z=Co28l55GZnSVdcf6V<4O&Rt|&M)t|8~*!|M}~km?}D=A5(=Ui@GOC5Yq?~v1HgAI zaKxWqhaHw5!!5u1M(k=D7{ehQEk6hSnOw2|&IgMv1K8tT@4!=|^&v$q_*>ZlWMIaG zOExXwA5J_0jq&T!f~ycC0IU+Q<9pxdE7>*V1M8ye4~p^Dc#O-G>m(@CahO~yO2xr- z=sMha@@Y75-YY;ZAY;rP* L0AY83T(3%pLY# zwYcTt)3IBtTTT~Ijdp&Xqk+H{55VBi_g;!8H*Ocw!+?wjQ|_vZJD6=wo1T)!3dxm` zm2)h6Y1k^4&-0ip-G+W3v>De_g5@Ggtj3jGKY8EDm?=3{E?>g{U;)#0*nVXR4_|mm z7DV0ip%W37U58EAUW;)X7-XaC(ALT#%D++vcVP1u0$n u8*aiEm$&-d zU|7J+t4-(&A~2j^pyVx3a)j6IIFEO~=C#-W3qzxjK_G XcMAE@ znHTVifFm$loH4(EYfm~6HZXKH24EMGWJS1k*vbt~yY6~?Zp`4OfomwNJ*4%nln);? zM}T!Wcx45jIOk04)WW+!2HYC?h)T%aqi$s;jnImn#h3`c|M}bSpYM4H7A=e$qhrST zJ8y@#AO9+}L}($fb$^Zr*PV0d$l*6{{tW*7(WfyMWm|mfLR(5MhUkbbqvDJC0`#68 zR{WKkpRFI3`slN)`S||2oQeI=1<{hPxc-jHXMIG45Yu@rgT||P^1Sn~p$Kq}0^LKe zj61pxhrj>&JcOU*umEipYtk6>0+%w)LQY_E(lxdC`u@;Tuv0jDD0;^VVV+NrQJPxr zGfVe0+2 {9uekPV+`767ZX3Y(ng?VO zG#uL~*I9J41SN){iiGHMqP0>g3`8`tMrqP6(kQJ{VOVYSZ)F22E9)^}oR3H_VC-R6 z@VV2^#xA1<4IC`s%X-S~GYHUU&qBBOFJFHUhi+(a#$LOG`p_wu%xr@KF&Kdz7(Y7) zCg1@F?DwwA;6@|3mV)pl5EIsoJN)(0N8ys)cJa9$RDVy;cS{847z^*c4BdQ)kZ&55 zdP?#CInT)vkT{>Ax*HkKbNrqbb5lX59DKiSjKwkG2j`rI7Jws)M-($NBuq646T&JO zzkbWD_}lN_kCpkkP{`3l2%6Qaj0K$YiYz+d)op`oPCWtJ$5RviSfUD>#YsrYn_y0! ziwi4@Rhb#_8-@j_fn(t9-~Jwc?Tg>QlSG(ji 2jqZLks1>fzupSd=`;GbV3M&X96Gs-J#(Qzjf$g_?-iuA9!}*L-(4l z!@gHth3_wfLn4I9nF>SZ!2Ci$)x H3=5_wkaG&EWf=-Fzn z!tefFM71Z?)*}^tsR JX`Rj1*2PYVFqvip-)b5vQ;x=Ej7pU-eR)vfIvZFri>j zJ} )&@7FxRHEw+ciq%Vcs)Z}Oe{+fyEU%)Zd= zS%3247fwD2htGxVgkaTsPYZ|Rw3C~SLwg+zzVL%5amj~1jQiGYf+GVC0hr!%W0l*6 z0a&18jJh$77?1Go%_n33k&m3G0h(EMkyn)^KPYIehEa8~jM;&harO5e!ka$+S*)xu z8Ul7=i#vYmJZx$l2&0({+j6~Fbok4=zJp)8;~N;abBGn%44?zZ$RNpyL0-ZMA&!dp z3`gB+rLPwc6~39k2RK6cNh!<~8yC(y$*De#U}RdKnw>ft)@`U=;2h9dY#eXJqnj^5 ztBkZ*`js7H3mDs7dkx&ERm_pnDLOLVGC)DmRU3dwE7p)kAKLBjvLgC;C=g@nj1}f+ z@!cf4dJDq@-*C}N)XqaBt^;;nS-~9_oR1xwP?ai1NLTt@Fmj0=XJ*{`)Cx|#@+LT* zM@JSH?7bcS?u4T;5AqgybWS0{QnOrIpfmiQ@rgZt(>@J@pZnxzaOne&!Zm)bm a7F88Lfb&P3Y!`wW;ZUV7t=xP8l(Q0`L+bUlsb zMvDQHvQD-26<~KtpNN9zjd+3iWOK 0rOQ!(GW>&mo80SJt664UZ#b+ ?C@WX zemQ=A&pj}b8Sr@Z)O{J}A@uL_%S(9WbvFQ`Rt21852@chK9U7jnSd}xb5)~@t@_;b z%lJ9~&SLKAF}7ZG4#;?azeK9I;M{F|OR@NnSbYWr3x`!U_^WT;jsNG)JF(I(Mhh?E zj1C}YeV6QNc(21gD~#(eI1Pu+c?G&d6ZSO082^sWg*uW&>Mcd(#bclHw+Zz<96RMQ zV7&X=-@`B7^f@fsF+d~y(joiePfmD=PfnoZ9^nwPGi{9k4o?9%@SX3)qZ_slX^Y6w zv)Ifufi9DQWIRsb{eC_rMKVf5PX|`_3^XBzrvJS_c;oWp#A8Ykr-S|Cvr6V77*IMc zM~VutuOvyUt9X2~FF-|MKSY7vvBM{x+=3H7`B5~~_-IF>e)KRSo(fhI=pwkM3gZck zA_k-&XDs@8dEK^NLkzTE5G3z&7Qp8$2!uCm*nqd5Z~}-Z^OetHq55$!I@jThYd?sO zKD3O+7#uL);8U+Z4I2!hE$YTkv&e_u70*>>e=Mg3@C^VLUU?Jla6n52of&(&ij`xW zPqhJsLot9?Y;5u8M;(I~EzF^1kD4?^ppNvhUU4|iF79-U7rysOV2 $ywW_Kxh3f(Uv(fkhWvMcDV6`<8j }U3uj4Gk_*DOESkfa-r|iXi;fK(Gyd@A&*Kko{U$Is zhfSTuw}0^hY&7-ilhfAL3j4Fu|M^WH##>ezaO2I47^*~JKO*e4=qA%pw!rS{G(|4l zV%c;yV75KkX3)=TpUA)EdsiyPA*)D`;OGiu3~H;|fQ}vN7saCBcI3_ic8qI}JsGEO zw;>|7QJ}A~!x2|pj<1jAfmR?%2_Di7+oS;n0hjp=I1sk<6_grE3_j^!39^$9T>6`{ zFR-oGq%@H9vI4S?&{weHRv8~W=>#0NX$Q1HW=s>}@!bxv##iHdiWz(U&3mytX6&+P z@Wr3F0J}AQ>q~C51aw7z4&;QUJaW1EzF^(MdAng12JgA|A^h`OzKEwy12#U=)ddZZ z8WBVuogriK#=Z8!Z@=(h?AMxDpvcK)xuE#tjVm%A2<&7%M!+_V2s`-8?02*(Be& z7SbHr4~sUvfy4hOjW&;V7Xcl<(^{`l0$xL_x?Mf@@+uZaE_=a(#*Ez_e+=LL*`LN- z*oZ{F