feat: postgresql支持

main
NoahLan 4 days ago
parent 3f41f4a2d1
commit 26ffde8db3

@ -20,7 +20,7 @@ iTi-Flask 是 FastAPI 后端框架基座。
## 代码入口 ## 代码入口
- `iti/app.py``create_app`、中间件、错误处理、自动 envelope、生命周期。 - `iti/app.py``create_app`、中间件、错误处理、自动 envelope、生命周期。
- `iti/config.py`dataclass 配置、`.env` 加载、MySQL 默认值 - `iti/config.py`dataclass 配置、`.env` 加载、数据库默认连接串
- `iti/db/*`SQLAlchemy 2 base/session、Alembic metadata。 - `iti/db/*`SQLAlchemy 2 base/session、Alembic metadata。
- `iti/auth/*`JWT、Principal、Actor、权限依赖、服务 token 依赖。 - `iti/auth/*`JWT、Principal、Actor、权限依赖、服务 token 依赖。
- `iti/modules/*`:模块协议、权限元数据、菜单 seed 元数据。 - `iti/modules/*`:模块协议、权限元数据、菜单 seed 元数据。
@ -58,7 +58,7 @@ iTi-Flask 是 FastAPI 后端框架基座。
- `copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja` - `copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja`
- 需要保持一致的已生成项目 skill 副本 - 需要保持一致的已生成项目 skill 副本
- 生成项目必须保留 `.copier-answers.yml`,否则不能用 `copier update` 同步模板。 - 生成项目必须保留 `.copier-answers.yml`,否则不能用 `copier update` 同步模板。
- 已生成项目同步框架依赖用 `iticli sync flask`。 - 已生成项目同步框架依赖用 `iticli update framework`。
- 已生成项目检查和同步模板用 `iticli template check`、`iticli template update`。 - 已生成项目检查和同步模板用 `iticli template check`、`iticli template update`。
- 模板项目的 Alembic 命令必须显式使用 `-c migrations/alembic.ini` - 模板项目的 Alembic 命令必须显式使用 `-c migrations/alembic.ini`
- 模板项目的测试命令使用 `uv run --extra dev pytest -q`,避免未安装 dev extra 时找不到 pytest。 - 模板项目的测试命令使用 `uv run --extra dev pytest -q`,避免未安装 dev extra 时找不到 pytest。

@ -12,7 +12,7 @@ AI 修改框架代码或文档时优先读:
- FastAPI 应用工厂。 - FastAPI 应用工厂。
- dataclass 配置和 `.env` 加载。 - dataclass 配置和 `.env` 加载。
- MySQL 默认数据库配置。 - MySQL 默认数据库配置PostgreSQL 可选
- SQLAlchemy 2 和 Alembic。 - SQLAlchemy 2 和 Alembic。
- JWT、权限依赖、错误处理、响应包装。 - JWT、权限依赖、错误处理、响应包装。
- 用户 token / 服务 token 的统一 Actor 依赖。 - 用户 token / 服务 token 的统一 Actor 依赖。
@ -75,6 +75,7 @@ iticli run dev 8000
```bash ```bash
iticli create ../my-business-app iticli create ../my-business-app
iticli create --database postgresql ../my-postgres-app
cd ../my-business-app cd ../my-business-app
iticli init iticli init
iticli run dev 8000 iticli run dev 8000
@ -83,7 +84,7 @@ iticli run dev 8000
同步框架依赖和模板骨架: 同步框架依赖和模板骨架:
```bash ```bash
iticli sync flask iticli update framework
iticli template check iticli template check
iticli template update iticli template update
``` ```

@ -22,7 +22,7 @@ description: "{{ project_name }} 业务项目 skill。用于当前由 iTi-Flask
- 当前项目未默认注册 iTi-System。需要系统域能力时再显式引入。 - 当前项目未默认注册 iTi-System。需要系统域能力时再显式引入。
{% endif -%} {% endif -%}
- 修改代码、架构、目录结构、脚本命令或测试方式后,同步更新这个 skill。 - 修改代码、架构、目录结构、脚本命令或测试方式后,同步更新这个 skill。
- 同步框架依赖用 `iticli sync flask`。 - 同步框架依赖用 `iticli update framework`。
- 检查和同步框架模板用 `iticli template check`、`iticli template update`。 - 检查和同步框架模板用 `iticli template check`、`iticli template update`。
## 项目结构 ## 项目结构
@ -36,7 +36,7 @@ description: "{{ project_name }} 业务项目 skill。用于当前由 iTi-Flask
- `tests/`pytest 路由和行为测试。 - `tests/`pytest 路由和行为测试。
- `Dockerfile`:容器镜像构建入口。 - `Dockerfile`:容器镜像构建入口。
- `docker-compose.yml`:本地 Compose 部署入口。 - `docker-compose.yml`:本地 Compose 部署入口。
- `docker-compose.with-db.yml`:本地 MySQL 叠加部署入口。 - `docker-compose.with-db.yml`:本地数据库叠加部署入口。
- `.env.example`:本地和 Compose 环境变量样例。 - `.env.example`:本地和 Compose 环境变量样例。
- `.vscode/launch.json`VSCode FastAPI 调试配置。 - `.vscode/launch.json`VSCode FastAPI 调试配置。
- `.dockerignore`Docker 构建排除规则。 - `.dockerignore`Docker 构建排除规则。
@ -68,16 +68,16 @@ description: "{{ project_name }} 业务项目 skill。用于当前由 iTi-Flask
## 命令 ## 命令
- 安装开发依赖:`iticli install` - 安装开发依赖:`iticli install`
- 同步框架依赖:`iticli sync flask` - 同步框架依赖:`iticli update framework`
- 检查模板更新:`iticli template check` - 检查模板更新:`iticli template check`
- 同步模板骨架:`iticli template update` - 同步模板骨架:`iticli template update`
- 运行测试:`iticli test` - 运行测试:`iticli test`
- 本地启动:`iticli run dev 8000` - 本地启动:`iticli run dev 8000`
- 构建 Docker 镜像:`iticli docker build` - 构建 Docker 镜像:`iticli docker build`
- 启动 Docker Compose`iticli docker up` - 启动 Docker Compose`iticli docker up`
- 启动 Docker Compose 和 MySQL`iticli docker up --db` - 启动 Docker Compose 和数据库`iticli docker up --db`
- 停止 Docker Compose`iticli docker down` - 停止 Docker Compose`iticli docker down`
- 停止 Docker Compose 和 MySQL`iticli docker down --db` - 停止 Docker Compose 和数据库`iticli docker down --db`
- 查看应用容器日志:`iticli docker logs` - 查看应用容器日志:`iticli docker logs`
- 创建 migration`iticli migrate revision "alice add order table"` - 创建 migration`iticli migrate revision "alice add order table"`
- 执行 migration`iticli migrate` - 执行 migration`iticli migrate`
@ -85,4 +85,4 @@ description: "{{ project_name }} 业务项目 skill。用于当前由 iTi-Flask
- 查看当前 Alembic 版本:`iticli migrate current` - 查看当前 Alembic 版本:`iticli migrate current`
- 初始化项目:`iticli init` - 初始化项目:`iticli init`
{{ "系统包相关命令:\n\n- 同步系统 migration`iticli sync system`\n- 初始化系统项目:`iticli init system`\n\n" if include_system else "" -}} {{ "系统包相关命令:\n\n- 更新系统依赖:`iticli update system`\n- 初始化系统项目:`iticli init system`\n\n" if include_system else "" -}}

@ -1,5 +1,6 @@
APP_ENV=prod APP_ENV=prod
APP_PORT=8000 APP_PORT=8000
DATABASE_DIALECT={{ database_dialect }}
MYSQL_HOST=host.docker.internal MYSQL_HOST=host.docker.internal
MYSQL_PORT=3306 MYSQL_PORT=3306
@ -8,6 +9,12 @@ MYSQL_DATABASE=app
MYSQL_USER=app MYSQL_USER=app
MYSQL_PASSWORD=change-me MYSQL_PASSWORD=change-me
POSTGRES_HOST=host.docker.internal
POSTGRES_PORT=5432
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me
SECRET_KEY=change-me SECRET_KEY=change-me
JWT_SECRET_KEY=change-me JWT_SECRET_KEY=change-me
LOG_FILE_ENABLED=true LOG_FILE_ENABLED=true

@ -69,8 +69,13 @@ iticli docker logs
iticli docker down iticli docker down
``` ```
{% if database_dialect == "postgresql" %}
`docker-compose.yml` 默认只启动应用,数据库使用外部 PostgreSQL。
需要本地 PostgreSQL 时使用:
{% else %}
`docker-compose.yml` 默认只启动应用,数据库使用外部 MySQL。 `docker-compose.yml` 默认只启动应用,数据库使用外部 MySQL。
需要本地 MySQL 时使用: 需要本地 MySQL 时使用:
{% endif %}
```bash ```bash
iticli docker up --db iticli docker up --db
@ -78,14 +83,14 @@ iticli docker down --db
``` ```
应用容器启动时会先执行 migration{% if include_system %} 和系统 seed{% endif %}。 应用容器启动时会先执行 migration{% if include_system %} 和系统 seed{% endif %}。
本地运行数据写入 `runtime/`MySQL 数据写入 Compose volume。 本地运行数据写入 `runtime/`数据库数据写入 Compose volume。
## 同步更新 ## 同步更新
同步框架包: 同步框架包:
```bash ```bash
iticli sync flask iticli update framework
``` ```
检查模板: 检查模板:

@ -1,5 +1,9 @@
from __future__ import annotations from __future__ import annotations
{% if database_dialect == "postgresql" -%}
import os
{% endif -%}
from pathlib import Path from pathlib import Path
from iti.config import ( from iti.config import (
@ -22,6 +26,33 @@ def apply_project_config(config) -> None:
config.base_dir = BASE_DIR config.base_dir = BASE_DIR
config.file_storage["LOCAL"]["base_path"] = runtime_path("uploads") config.file_storage["LOCAL"]["base_path"] = runtime_path("uploads")
config.log_dir = runtime_path("logs") config.log_dir = runtime_path("logs")
{% if database_dialect == "postgresql" %}
apply_database_config(config)
def apply_database_config(config) -> None:
if os.getenv("DATABASE_URL"):
return
database = os.getenv("POSTGRES_DB", default_database_name(config.app_env))
config.database_url = default_postgresql_url(database)
def default_postgresql_url(database: str) -> str:
return (
f"postgresql+psycopg://{os.getenv('POSTGRES_USER', 'postgres')}:"
f"{os.getenv('POSTGRES_PASSWORD', 'password')}@"
f"{os.getenv('POSTGRES_HOST', '127.0.0.1')}:"
f"{os.getenv('POSTGRES_PORT', '5432')}/{database}"
)
def default_database_name(app_env: str) -> str:
if app_env == "test":
return "{{ project_slug | lower | replace('-', '_') }}_test"
if app_env == "prod":
return "{{ project_slug | lower | replace('-', '_') }}"
return "{{ project_slug | lower | replace('-', '_') }}_dev"
{% endif %}
class DevConfig(BaseDevConfig): class DevConfig(BaseDevConfig):

@ -1,13 +1,35 @@
services: services:
app: app:
environment: environment:
DATABASE_DIALECT: {{ database_dialect }}
{% if database_dialect == "postgresql" %}
POSTGRES_HOST: db
POSTGRES_PORT: 5432
{% else %}
MYSQL_HOST: db MYSQL_HOST: db
MYSQL_PORT: 3306 MYSQL_PORT: 3306
{% endif %}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
db: db:
{% if database_dialect == "postgresql" %}
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
{% else %}
image: mysql:8.4 image: mysql:8.4
environment: environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root-password} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root-password}
@ -26,6 +48,11 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
{% endif %}
volumes: volumes:
{% if database_dialect == "postgresql" %}
postgres-data:
{% else %}
mysql-data: mysql-data:
{% endif %}

@ -7,11 +7,17 @@ services:
APP_ENV: ${APP_ENV:-prod} APP_ENV: ${APP_ENV:-prod}
SECRET_KEY: ${SECRET_KEY:-change-me} SECRET_KEY: ${SECRET_KEY:-change-me}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me} JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me}
DATABASE_DIALECT: ${DATABASE_DIALECT:-{{ database_dialect }}}
MYSQL_HOST: ${MYSQL_HOST:-host.docker.internal} MYSQL_HOST: ${MYSQL_HOST:-host.docker.internal}
MYSQL_PORT: ${MYSQL_PORT:-3306} MYSQL_PORT: ${MYSQL_PORT:-3306}
MYSQL_DATABASE: ${MYSQL_DATABASE:-app} MYSQL_DATABASE: ${MYSQL_DATABASE:-app}
MYSQL_USER: ${MYSQL_USER:-app} MYSQL_USER: ${MYSQL_USER:-app}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change-me} MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change-me}
POSTGRES_HOST: ${POSTGRES_HOST:-host.docker.internal}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
LOG_FILE_ENABLED: ${LOG_FILE_ENABLED:-true} LOG_FILE_ENABLED: ${LOG_FILE_ENABLED:-true}
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
@ -21,6 +27,6 @@ services:
- ./runtime:/app/runtime - ./runtime:/app/runtime
command: > command: >
sh -c "{% if include_system %}uv run iti-system migrations sync --target migrations/versions && sh -c "{% if include_system %}uv run iti-system migrations sync --target migrations/versions &&
{% endif %}uv run alembic -c migrations/alembic.ini upgrade head && {% endif %}uv run python -m alembic -c migrations/alembic.ini upgrade head &&
{% if include_system %}PYTHONPATH=. uv run iti-system seed system app:create_app && {% if include_system %}PYTHONPATH=. uv run iti-system seed system app:create_app &&
{% endif %}uv run uvicorn main:app --host 0.0.0.0 --port 8000" {% endif %}uv run uvicorn main:app --host 0.0.0.0 --port 8000"

@ -3,8 +3,8 @@
本目录是业务项目唯一的 Alembic migration 流。 本目录是业务项目唯一的 Alembic migration 流。
```bash ```bash
uv run alembic -c migrations/alembic.ini revision --autogenerate -m "alice add example table" uv run python -m alembic -c migrations/alembic.ini revision --autogenerate -m "alice add example table"
uv run alembic -c migrations/alembic.ini upgrade head uv run python -m alembic -c migrations/alembic.ini upgrade head
``` ```
`versions/` 下的 migration 文件必须提交到 Git。 `versions/` 下的 migration 文件必须提交到 Git。

@ -11,6 +11,7 @@ requires-python = ">=3.11"
dependencies = [ dependencies = [
"iti-flask @ git+{{ framework_git }}{% if framework_tag %}@{{ framework_tag }}{% endif %}", "iti-flask @ git+{{ framework_git }}{% if framework_tag %}@{{ framework_tag }}{% endif %}",
{% if include_system %} "iti-system @ git+{{ system_git }}{% if system_tag %}@{{ system_tag }}{% endif %}", {% if include_system %} "iti-system @ git+{{ system_git }}{% if system_tag %}@{{ system_tag }}{% endif %}",
{% endif %}{% if database_dialect == "postgresql" %} "psycopg[binary]>=3.2.0",
{% endif -%} {% endif -%}
] ]

@ -33,6 +33,14 @@ include_system:
help: 是否引入 iTi-System 系统业务包 help: 是否引入 iTi-System 系统业务包
default: false default: false
database_dialect:
type: str
help: 默认数据库类型
default: mysql
choices:
MySQL: mysql
PostgreSQL: postgresql
system_git: system_git:
type: str type: str
help: iTi-System Git 地址 help: iTi-System Git 地址

@ -6,7 +6,7 @@ iTi-Flask 是 FastAPI 框架基座。
## 分层 ## 分层
- `iti.app`:应用工厂,组装 FastAPI、错误处理、模块、服务客户端、任务 runner、健康检查。 - `iti.app`:应用工厂,组装 FastAPI、错误处理、模块、服务客户端、任务 runner、健康检查。
- `iti.config`dataclass 配置,默认 dev/test/prod 均使用 MySQL。 - `iti.config`dataclass 配置,默认 MySQL可通过 `DATABASE_DIALECT=postgresql` 使用 PostgreSQL。
- `iti.db`SQLAlchemy 2 `Base`、session、Alembic metadata。 - `iti.db`SQLAlchemy 2 `Base`、session、Alembic metadata。
- `iti.auth`JWT、Principal、Actor、用户权限依赖、服务 token 依赖。 - `iti.auth`JWT、Principal、Actor、用户权限依赖、服务 token 依赖。
- `iti.responses`:自动 envelope、`@raw_response`、响应工具。 - `iti.responses`:自动 envelope、`@raw_response`、响应工具。

@ -21,22 +21,33 @@ app = create_app(config_name="test", config_mapping=config)
## 默认数据库 ## 默认数据库
dev/test/prod 默认都使用 MySQL。 dev/test/prod 默认都使用 MySQL。
设置 `DATABASE_DIALECT=postgresql` 时,默认连接串改为 PostgreSQL。
`DATABASE_URL` 始终优先。
```text ```text
mysql+pymysql://root:password@127.0.0.1:3306/iti_dev?charset=utf8mb4 mysql+pymysql://root:password@127.0.0.1:3306/iti_dev?charset=utf8mb4
mysql+pymysql://root:password@127.0.0.1:3306/iti_test?charset=utf8mb4 mysql+pymysql://root:password@127.0.0.1:3306/iti_test?charset=utf8mb4
mysql+pymysql://root:password@127.0.0.1:3306/iti_prod?charset=utf8mb4 mysql+pymysql://root:password@127.0.0.1:3306/iti_prod?charset=utf8mb4
postgresql+psycopg://postgres:password@127.0.0.1:5432/iti_dev
``` ```
可用环境变量覆盖: 可用环境变量覆盖:
```bash ```bash
DATABASE_URL=mysql+pymysql://user:pass@host:3306/app?charset=utf8mb4 DATABASE_URL=mysql+pymysql://user:pass@host:3306/app?charset=utf8mb4
DATABASE_DIALECT=mysql
MYSQL_HOST=127.0.0.1 MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306 MYSQL_PORT=3306
MYSQL_USER=root MYSQL_USER=root
MYSQL_PASSWORD=password MYSQL_PASSWORD=password
MYSQL_DATABASE=iti_dev MYSQL_DATABASE=iti_dev
DATABASE_DIALECT=postgresql
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=password
POSTGRES_DB=iti_dev
``` ```
单元测试可以传入 SQLite 配置。 单元测试可以传入 SQLite 配置。

@ -24,6 +24,7 @@ copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-pro
```bash ```bash
iticli create ../my-business-app iticli create ../my-business-app
iticli create --database postgresql ../my-postgres-app
cd ../my-business-app cd ../my-business-app
cp .env.example .env cp .env.example .env
iticli init iticli init
@ -48,6 +49,7 @@ iticli run dev 8000
| `framework_git` | iTi-Flask Git 地址 | | `framework_git` | iTi-Flask Git 地址 |
| `framework_tag` | iTi-Flask Git tag | | `framework_tag` | iTi-Flask Git tag |
| `include_system` | 是否引入 iTi-System | | `include_system` | 是否引入 iTi-System |
| `database_dialect` | 默认数据库类型,`mysql` 或 `postgresql` |
| `system_git` | iTi-System Git 地址 | | `system_git` | iTi-System Git 地址 |
| `system_tag` | iTi-System Git tag | | `system_tag` | iTi-System Git tag |
@ -109,7 +111,7 @@ iticli migrate
`iti-system` 的项目还会有: `iti-system` 的项目还会有:
```bash ```bash
iticli sync system iticli update system
iticli init system iticli init system
``` ```
@ -118,11 +120,11 @@ iticli init system
业务项目同步框架依赖: 业务项目同步框架依赖:
```bash ```bash
iticli sync flask iticli update framework
``` ```
该命令执行 `uv sync --upgrade-package iti-flask` 该命令会更新 `pyproject.toml` 中的框架 tag执行 `uv sync --upgrade-package iti-flask`
需要同时升级框架和系统包时使用 `iticli sync all`。 需要同时升级框架和系统包时使用 `iticli update all`。
业务项目检查模板版本: 业务项目检查模板版本:
@ -151,8 +153,8 @@ iticli template update
模板生成: 模板生成:
- `Dockerfile`:基于 `python:3.11-slim`,使用 `uv sync --frozen --no-dev` 安装运行依赖。 - `Dockerfile`:基于 `python:3.11-slim`,使用 `uv sync --frozen --no-dev` 安装运行依赖。
- `docker-compose.yml`:启动应用,默认连接外部 MySQL - `docker-compose.yml`:启动应用,默认连接外部数据库
- `docker-compose.with-db.yml`:叠加启动 MySQL 8.4。 - `docker-compose.with-db.yml``database_dialect` 叠加启动 MySQL 8.4 或 PostgreSQL 16
- `.env.example`Compose 和本地运行共用的环境变量样例。 - `.env.example`Compose 和本地运行共用的环境变量样例。
- `.dockerignore`:排除虚拟环境、运行数据和本地密钥。 - `.dockerignore`:排除虚拟环境、运行数据和本地密钥。

@ -7,23 +7,23 @@ iTi-Flask 使用 Alembic 管理数据库 schema。
- 每个业务项目只有一条 Alembic migration 流。 - 每个业务项目只有一条 Alembic migration 流。
- `migrations/versions` 必须提交。 - `migrations/versions` 必须提交。
- 已发布 migration 不回改。 - 已发布 migration 不回改。
- 生产只执行 `alembic upgrade head`。 - 生产只执行 `python -m alembic upgrade head`。
- `iti-system` 的 migration 通过 CLI 同步进业务项目 migration 流。 - `iti-system` 的 migration 通过 CLI 同步进业务项目 migration 流。
## 命令 ## 命令
```bash ```bash
uv run alembic revision --autogenerate -m "alice add workorder priority" uv run python -m alembic revision --autogenerate -m "alice add workorder priority"
uv run alembic upgrade head uv run python -m alembic upgrade head
uv run alembic current uv run python -m alembic current
uv run alembic heads uv run python -m alembic heads
``` ```
多个 head 多个 head
```bash ```bash
uv run alembic merge heads -m "alice merge heads before release" uv run python -m alembic merge heads -m "alice merge heads before release"
uv run alembic upgrade head uv run python -m alembic upgrade head
``` ```
## 模型发现 ## 模型发现
@ -42,7 +42,7 @@ class Example(Base):
```bash ```bash
uv run iti-system migrations sync --target migrations/versions uv run iti-system migrations sync --target migrations/versions
uv run alembic upgrade head uv run python -m alembic upgrade head
``` ```
业务项目可在同步后继续新增自己的 migration。 业务项目可在同步后继续新增自己的 migration。

@ -1,7 +1,7 @@
# 测试与部署方案 # 测试与部署方案
本轮开发验证不依赖 Docker不依赖真实 MySQL 本轮开发验证不依赖 Docker不依赖真实数据库
真实 MySQL 和 Docker Compose 属于集成验证阶段。 真实 MySQL/PostgreSQL 和 Docker Compose 属于集成验证阶段。
## 本地轻量验证 ## 本地轻量验证
@ -31,12 +31,29 @@ CREATE DATABASE my_business_app_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unico
```bash ```bash
export DATABASE_URL='mysql+pymysql://root:password@127.0.0.1:3306/iti_test?charset=utf8mb4' export DATABASE_URL='mysql+pymysql://root:password@127.0.0.1:3306/iti_test?charset=utf8mb4'
uv run alembic upgrade head uv run python -m alembic upgrade head
uv run iti-system migrations sync --target migrations/versions uv run iti-system migrations sync --target migrations/versions
PYTHONPATH=. uv run iti-system seed system app:create_app PYTHONPATH=. uv run iti-system seed system app:create_app
uv run uvicorn main:app --reload uv run uvicorn main:app --reload
``` ```
## PostgreSQL 集成验证
准备独立测试库:
```sql
CREATE DATABASE iti_test;
CREATE DATABASE my_business_app_test;
```
执行:
```bash
export DATABASE_URL='postgresql+psycopg://postgres:password@127.0.0.1:5432/iti_test'
uv run python -m alembic upgrade head
uv run uvicorn main:app --reload
```
验证: 验证:
```bash ```bash
@ -57,7 +74,7 @@ iticli docker logs
iticli docker down iticli docker down
``` ```
如需本地 MySQL 如需本地数据库
```bash ```bash
iticli docker up --db iticli docker up --db

@ -9,6 +9,12 @@ from dotenv import load_dotenv
BASE_DIR = Path(os.getenv("ITI_BASE_DIR", Path.cwd())).resolve() BASE_DIR = Path(os.getenv("ITI_BASE_DIR", Path.cwd())).resolve()
SUPPORTED_DATABASE_DIALECTS = {"mysql", "postgresql"}
_DATABASE_DIALECT_ALIASES = {
"mysql": "mysql",
"postgres": "postgresql",
"postgresql": "postgresql",
}
def get_env_name(default: str = "dev") -> str: def get_env_name(default: str = "dev") -> str:
@ -33,6 +39,20 @@ def env_bool(key: str, default: bool = False) -> bool:
return value.lower() in {"1", "true", "yes", "on"} return value.lower() in {"1", "true", "yes", "on"}
def normalize_database_dialect(dialect: str) -> str:
normalized = _DATABASE_DIALECT_ALIASES.get(dialect.strip().lower())
if normalized is None:
supported = ", ".join(sorted(SUPPORTED_DATABASE_DIALECTS))
raise ValueError(f"unsupported database dialect: {dialect!r}, supported: {supported}")
return normalized
def get_database_dialect(default: str = "mysql") -> str:
return normalize_database_dialect(
os.getenv("DATABASE_DIALECT") or os.getenv("DB_DIALECT") or default
)
def default_mysql_url(database: str) -> str: def default_mysql_url(database: str) -> str:
return ( return (
f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:" f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:"
@ -42,6 +62,22 @@ def default_mysql_url(database: str) -> str:
) )
def default_postgresql_url(database: str) -> str:
return (
f"postgresql+psycopg://{os.getenv('POSTGRES_USER', 'postgres')}:"
f"{os.getenv('POSTGRES_PASSWORD', 'password')}@"
f"{os.getenv('POSTGRES_HOST', '127.0.0.1')}:"
f"{os.getenv('POSTGRES_PORT', '5432')}/{database}"
)
def default_database_url(database: str, dialect: str | None = None) -> str:
dialect = normalize_database_dialect(dialect) if dialect else get_database_dialect()
if dialect == "postgresql":
return default_postgresql_url(os.getenv("POSTGRES_DB", database))
return default_mysql_url(os.getenv("MYSQL_DATABASE", database))
load_env_file() load_env_file()
@ -65,7 +101,7 @@ class BaseConfig:
database_url: str = field( database_url: str = field(
default_factory=lambda: os.getenv( default_factory=lambda: os.getenv(
"DATABASE_URL", default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_dev")) "DATABASE_URL", default_database_url("iti_dev")
) )
) )
sqlalchemy_echo: bool = False sqlalchemy_echo: bool = False
@ -131,7 +167,7 @@ class DevConfig(BaseConfig):
debug=True, debug=True,
database_url=os.getenv( database_url=os.getenv(
"DATABASE_URL", "DATABASE_URL",
default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_dev")), default_database_url("iti_dev"),
), ),
sqlalchemy_echo=env_bool("SQLALCHEMY_ECHO", False), sqlalchemy_echo=env_bool("SQLALCHEMY_ECHO", False),
jwt_access_token_expires_seconds=24 * 3600, jwt_access_token_expires_seconds=24 * 3600,
@ -147,7 +183,7 @@ class TestConfig(BaseConfig):
testing=True, testing=True,
database_url=os.getenv( database_url=os.getenv(
"DATABASE_URL", "DATABASE_URL",
default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_test")), default_database_url("iti_test"),
), ),
ratelimit_enabled=False, ratelimit_enabled=False,
log_file_enabled=False, log_file_enabled=False,
@ -162,7 +198,7 @@ class ProdConfig(BaseConfig):
debug=False, debug=False,
database_url=os.getenv( database_url=os.getenv(
"DATABASE_URL", "DATABASE_URL",
default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_prod")), default_database_url("iti_prod"),
), ),
secret_key=os.getenv("SECRET_KEY", ""), secret_key=os.getenv("SECRET_KEY", ""),
jwt_secret_key=os.getenv("JWT_SECRET_KEY", ""), jwt_secret_key=os.getenv("JWT_SECRET_KEY", ""),

@ -47,6 +47,7 @@ storage-minio = ["minio>=7.2.18"]
erp = ["pyodbc>=5.3.0"] erp = ["pyodbc>=5.3.0"]
excel = ["pandas>=2.3.3", "openpyxl>=3.1.5"] excel = ["pandas>=2.3.3", "openpyxl>=3.1.5"]
image = ["Pillow>=12.0.0"] image = ["Pillow>=12.0.0"]
postgres = ["psycopg[binary]>=3.2.0"]
prod = ["gunicorn>=22.0.0"] prod = ["gunicorn>=22.0.0"]
dev = [ dev = [
"mypy>=1.0.0", "mypy>=1.0.0",

@ -1,4 +1,13 @@
from iti.config import BaseConfig, TestConfig as FrameworkTestConfig, default_mysql_url import pytest
from iti.config import (
BaseConfig,
TestConfig as FrameworkTestConfig,
default_database_url,
default_mysql_url,
default_postgresql_url,
get_database_dialect,
)
def test_default_mysql_url_uses_mysql_driver(monkeypatch): def test_default_mysql_url_uses_mysql_driver(monkeypatch):
@ -12,8 +21,20 @@ def test_default_mysql_url_uses_mysql_driver(monkeypatch):
) )
def test_default_postgresql_url_uses_psycopg_driver(monkeypatch):
monkeypatch.setenv("POSTGRES_USER", "u")
monkeypatch.setenv("POSTGRES_PASSWORD", "p")
monkeypatch.setenv("POSTGRES_HOST", "db")
monkeypatch.setenv("POSTGRES_PORT", "5433")
assert default_postgresql_url("iti_test") == (
"postgresql+psycopg://u:p@db:5433/iti_test"
)
def test_test_config_default_database_is_mysql(monkeypatch): def test_test_config_default_database_is_mysql(monkeypatch):
monkeypatch.delenv("DATABASE_URL", raising=False) monkeypatch.delenv("DATABASE_URL", raising=False)
monkeypatch.delenv("DATABASE_DIALECT", raising=False)
monkeypatch.setenv("MYSQL_DATABASE", "iti_test") monkeypatch.setenv("MYSQL_DATABASE", "iti_test")
config = FrameworkTestConfig() config = FrameworkTestConfig()
@ -23,6 +44,31 @@ def test_test_config_default_database_is_mysql(monkeypatch):
assert config.ratelimit_enabled is False assert config.ratelimit_enabled is False
def test_test_config_can_default_to_postgresql(monkeypatch):
monkeypatch.delenv("DATABASE_URL", raising=False)
monkeypatch.setenv("DATABASE_DIALECT", "postgresql")
monkeypatch.setenv("POSTGRES_DB", "iti_test")
config = FrameworkTestConfig()
assert config.database_url.startswith("postgresql+psycopg://")
assert config.database_url.endswith("/iti_test")
assert config.testing is True
def test_default_database_url_accepts_postgres_alias(monkeypatch):
monkeypatch.setenv("POSTGRES_DB", "custom_db")
assert default_database_url("fallback", "postgres").endswith("/custom_db")
def test_get_database_dialect_rejects_unknown(monkeypatch):
monkeypatch.setenv("DATABASE_DIALECT", "oracle")
with pytest.raises(ValueError):
get_database_dialect()
def test_base_config_can_be_overridden_for_unit_tests(): def test_base_config_can_be_overridden_for_unit_tests():
config = BaseConfig(database_url="sqlite+pysqlite:///:memory:", testing=True) config = BaseConfig(database_url="sqlite+pysqlite:///:memory:", testing=True)

Loading…
Cancel
Save