mirror of
https://github.com/fastapi-practices/fastapi_best_architecture.git
synced 2026-03-13 09:31:31 +08:00
Add the auto init project CLI command (#978)
This commit is contained in:
186
backend/cli.py
186
backend/cli.py
@@ -1,8 +1,11 @@
|
||||
import asyncio
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import anyio
|
||||
@@ -15,6 +18,7 @@ from rich.prompt import IntPrompt, Prompt
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from watchfiles import PythonFilter
|
||||
|
||||
from backend import __version__
|
||||
@@ -39,6 +43,163 @@ class CustomReloadFilter(PythonFilter):
|
||||
super().__init__(extra_extensions=['.json', '.yaml', '.yml'])
|
||||
|
||||
|
||||
def setup_env_file() -> bool:
|
||||
env_path = BASE_PATH / '.env'
|
||||
env_example_path = BASE_PATH / '.env.example'
|
||||
|
||||
if not env_example_path.exists():
|
||||
console.print('.env.example 文件不存在', style='red')
|
||||
return False
|
||||
|
||||
try:
|
||||
env_content = Path(env_example_path).read_text(encoding='utf-8')
|
||||
console.print('配置数据库连接信息...', style='white')
|
||||
db_type = Prompt.ask('数据库类型', choices=['mysql', 'postgresql'], default='postgresql')
|
||||
db_host = Prompt.ask('数据库主机', default='127.0.0.1')
|
||||
db_port = Prompt.ask('数据库端口', default='5432' if db_type == 'postgresql' else '3306')
|
||||
db_user = Prompt.ask('数据库用户名', default='postgres' if db_type == 'postgresql' else 'root')
|
||||
db_password = Prompt.ask('数据库密码', password=True, default='123456')
|
||||
|
||||
console.print('配置 Redis 连接信息...', style='white')
|
||||
redis_host = Prompt.ask('Redis 主机', default='127.0.0.1')
|
||||
redis_port = Prompt.ask('Redis 端口', default='6379')
|
||||
redis_password = Prompt.ask('Redis 密码(留空表示无密码)', password=True, default='')
|
||||
redis_db = Prompt.ask('Redis 数据库编号', default='0')
|
||||
|
||||
console.print('生成 Token 和操作日志密钥...', style='white')
|
||||
token_secret = secrets.token_urlsafe(32)
|
||||
opera_log_secret = secrets.token_hex(32)
|
||||
|
||||
console.print('写入 .env 文件...', style='white')
|
||||
env_content = env_content.replace("DATABASE_TYPE='postgresql'", f"DATABASE_TYPE='{db_type}'")
|
||||
settings.DATABASE_TYPE = db_type
|
||||
env_content = env_content.replace("DATABASE_HOST='127.0.0.1'", f"DATABASE_HOST='{db_host}'")
|
||||
settings.DATABASE_HOST = db_host
|
||||
env_content = env_content.replace('DATABASE_PORT=5432', f'DATABASE_PORT={db_port}')
|
||||
settings.DATABASE_PORT = db_port
|
||||
env_content = env_content.replace("DATABASE_USER='postgres'", f"DATABASE_USER='{db_user}'")
|
||||
settings.DATABASE_USER = db_user
|
||||
env_content = env_content.replace("DATABASE_PASSWORD='123456'", f"DATABASE_PASSWORD='{db_password}'")
|
||||
settings.DATABASE_PASSWORD = db_password
|
||||
env_content = env_content.replace("REDIS_HOST='127.0.0.1'", f"REDIS_HOST='{redis_host}'")
|
||||
settings.REDIS_HOST = redis_host
|
||||
env_content = env_content.replace('REDIS_PORT=6379', f'REDIS_PORT={redis_port}')
|
||||
settings.REDIS_PORT = redis_port
|
||||
env_content = env_content.replace("REDIS_PASSWORD=''", f"REDIS_PASSWORD='{redis_password}'")
|
||||
settings.REDIS_PASSWORD = redis_password
|
||||
env_content = env_content.replace('REDIS_DATABASE=0', f'REDIS_DATABASE={redis_db}')
|
||||
settings.REDIS_DATABASE = redis_db
|
||||
env_content = re.sub(r"TOKEN_SECRET_KEY='[^']*'", f"TOKEN_SECRET_KEY='{token_secret}'", env_content)
|
||||
settings.TOKEN_SECRET_KEY = token_secret
|
||||
env_content = re.sub(
|
||||
r"OPERA_LOG_ENCRYPT_SECRET_KEY='[^']*'", f"OPERA_LOG_ENCRYPT_SECRET_KEY='{opera_log_secret}'", env_content
|
||||
)
|
||||
settings.OPERA_LOG_ENCRYPT_SECRET_KEY = opera_log_secret
|
||||
|
||||
Path(env_path).write_text(env_content, encoding='utf-8')
|
||||
console.print('.env 文件创建成功', style='green')
|
||||
except Exception as e:
|
||||
console.print(f'.env 文件创建失败: {e}', style='red')
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
async def create_database_if_not_exists() -> bool:
|
||||
from sqlalchemy import URL
|
||||
|
||||
try:
|
||||
terminate_sql = None
|
||||
if DataBaseType.mysql == settings.DATABASE_TYPE:
|
||||
url = URL.create(
|
||||
drivername='mysql+asyncmy',
|
||||
username=settings.DATABASE_USER,
|
||||
password=settings.DATABASE_PASSWORD,
|
||||
host=settings.DATABASE_HOST,
|
||||
port=settings.DATABASE_PORT,
|
||||
)
|
||||
check_sql = f"SHOW DATABASES LIKE '{settings.DATABASE_SCHEMA}'"
|
||||
drop_sql = f'DROP DATABASE IF EXISTS `{settings.DATABASE_SCHEMA}`'
|
||||
create_sql = (
|
||||
f'CREATE DATABASE `{settings.DATABASE_SCHEMA}` CHARACTER SET {settings.DATABASE_CHARSET} '
|
||||
f'COLLATE {settings.DATABASE_CHARSET}_unicode_ci'
|
||||
)
|
||||
else:
|
||||
url = URL.create(
|
||||
drivername='postgresql+asyncpg',
|
||||
username=settings.DATABASE_USER,
|
||||
password=settings.DATABASE_PASSWORD,
|
||||
host=settings.DATABASE_HOST,
|
||||
port=settings.DATABASE_PORT,
|
||||
database='postgres',
|
||||
)
|
||||
check_sql = f"SELECT 1 FROM pg_database WHERE datname = '{settings.DATABASE_SCHEMA}'"
|
||||
terminate_sql = (
|
||||
f'SELECT pg_terminate_backend(pid) FROM pg_stat_activity '
|
||||
f"WHERE datname = '{settings.DATABASE_SCHEMA}' AND pid <> pg_backend_pid()"
|
||||
)
|
||||
drop_sql = f'DROP DATABASE IF EXISTS {settings.DATABASE_SCHEMA}'
|
||||
create_sql = f'CREATE DATABASE {settings.DATABASE_SCHEMA}'
|
||||
|
||||
engine = create_async_engine(url, isolation_level='AUTOCOMMIT')
|
||||
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(text(check_sql))
|
||||
exists = result.fetchone() is not None
|
||||
|
||||
console.print(f'重建 {settings.DATABASE_SCHEMA} 数据库...', style='white')
|
||||
if exists:
|
||||
if terminate_sql:
|
||||
await conn.execute(text(terminate_sql))
|
||||
await conn.execute(text(drop_sql))
|
||||
await conn.execute(text(create_sql))
|
||||
console.print('数据库创建成功', style='green')
|
||||
finally:
|
||||
await engine.dispose()
|
||||
except Exception as e:
|
||||
console.print(f'数据库创建失败: {e}', style='red')
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
async def auto_init() -> None:
|
||||
"""自动化初始化流程"""
|
||||
console.print('\n[bold cyan]步骤 1/3:[/] 配置环境变量', style='bold')
|
||||
panel_content = Text()
|
||||
panel_content.append('【环境变量配置】', style='bold green')
|
||||
panel_content.append('\n\n • 数据库连接信息')
|
||||
panel_content.append('\n • Redis 连接信息')
|
||||
panel_content.append('\n • Token 密钥(自动生成)')
|
||||
|
||||
console.print(Panel(panel_content, title=f'fba v{__version__} 环境变量', border_style='cyan', padding=(1, 2)))
|
||||
if not setup_env_file():
|
||||
raise cappa.Exit('.env 文件配置失败', code=1)
|
||||
|
||||
console.print('\n[bold cyan]步骤 2/3:[/] 数据库创建', style='bold')
|
||||
panel_content = Text()
|
||||
panel_content.append('【数据库配置】', style='bold green')
|
||||
panel_content.append('\n\n • 类型: ')
|
||||
panel_content.append(f'{settings.DATABASE_TYPE}', style='yellow')
|
||||
panel_content.append('\n • 数据库:')
|
||||
panel_content.append(f'{settings.DATABASE_SCHEMA}', style='yellow')
|
||||
panel_content.append('\n • 主机:')
|
||||
panel_content.append(f'{settings.DATABASE_HOST}:{settings.DATABASE_PORT}', style='yellow')
|
||||
|
||||
console.print(Panel(panel_content, title=f'fba v{__version__} 数据库', border_style='cyan', padding=(1, 2)))
|
||||
ok = Prompt.ask('即将[red]新建/重建数据库[/red],确认继续吗?', choices=['y', 'n'], default='n')
|
||||
|
||||
if ok.lower() == 'y':
|
||||
if not await create_database_if_not_exists():
|
||||
raise cappa.Exit('数据库创建失败', code=1)
|
||||
else:
|
||||
console.print('已取消数据库操作', style='yellow')
|
||||
|
||||
console.print('\n[bold cyan]步骤 3/3:[/] 初始化数据库表和数据', style='bold')
|
||||
await init()
|
||||
|
||||
|
||||
async def init() -> None:
|
||||
panel_content = Text()
|
||||
panel_content.append('【数据库配置】', style='bold green')
|
||||
@@ -46,6 +207,8 @@ async def init() -> None:
|
||||
panel_content.append(f'{settings.DATABASE_TYPE}', style='yellow')
|
||||
panel_content.append('\n • 数据库:')
|
||||
panel_content.append(f'{settings.DATABASE_SCHEMA}', style='yellow')
|
||||
panel_content.append('\n • 主机:')
|
||||
panel_content.append(f'{settings.DATABASE_HOST}:{settings.DATABASE_PORT}', style='yellow')
|
||||
panel_content.append('\n • 主键模式:')
|
||||
panel_content.append(
|
||||
f'{settings.DATABASE_PK_MODE}',
|
||||
@@ -68,20 +231,19 @@ async def init() -> None:
|
||||
|
||||
console.print(Panel(panel_content, title=f'fba v{__version__} 初始化', border_style='cyan', padding=(1, 2)))
|
||||
ok = Prompt.ask(
|
||||
'即将[red]重建数据库表[/red]并[red]执行所有 SQL 脚本[/red],确认继续吗?', choices=['y', 'n'], default='n'
|
||||
'即将[red]新建/重建数据库表[/red]并[red]执行所有数据库脚本[/red],确认继续吗?', choices=['y', 'n'], default='n'
|
||||
)
|
||||
|
||||
if ok.lower() == 'y':
|
||||
console.print('开始初始化...', style='white')
|
||||
try:
|
||||
console.print('丢弃数据库表', style='white')
|
||||
await drop_tables()
|
||||
console.print('丢弃 Redis 缓存', style='white')
|
||||
console.print('清理 Redis 缓存', style='white')
|
||||
await redis_client.delete_prefix(settings.JWT_USER_REDIS_PREFIX)
|
||||
await redis_client.delete_prefix(settings.TOKEN_EXTRA_INFO_REDIS_PREFIX)
|
||||
await redis_client.delete_prefix(settings.TOKEN_REDIS_PREFIX)
|
||||
await redis_client.delete_prefix(settings.TOKEN_REFRESH_REDIS_PREFIX)
|
||||
console.print('创建数据库表', style='white')
|
||||
console.print('重建数据库表', style='white')
|
||||
await drop_tables()
|
||||
await create_tables()
|
||||
console.print('执行 SQL 脚本', style='white')
|
||||
sql_scripts = await get_sql_scripts()
|
||||
@@ -93,7 +255,7 @@ async def init() -> None:
|
||||
except Exception as e:
|
||||
raise cappa.Exit(f'初始化失败:{e}', code=1)
|
||||
else:
|
||||
console.print('已取消初始化', style='yellow')
|
||||
console.print('已取消初始化操作', style='yellow')
|
||||
|
||||
|
||||
def run(host: str, port: int, reload: bool, workers: int) -> None: # noqa: FBT001
|
||||
@@ -285,7 +447,7 @@ async def generate() -> None:
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
business = IntPrompt.ask('请从中选择一个业务编号', choices=[str(_id) for _id in ids])
|
||||
business = IntPrompt.ask('请从中选择一个业务编号', choices=[str(id_) for id_ in ids])
|
||||
|
||||
async with async_db_session.begin() as db:
|
||||
gen_path = await gen_service.generate(db=db, pk=business)
|
||||
@@ -299,8 +461,16 @@ async def generate() -> None:
|
||||
@cappa.command(help='初始化 fba 项目', default_long=True)
|
||||
@dataclass
|
||||
class Init:
|
||||
auto: Annotated[
|
||||
bool,
|
||||
cappa.Arg(default=False, help='自动化初始化模式:自动创建 .env、安装依赖、创建数据库并初始化表结构'),
|
||||
]
|
||||
|
||||
async def __call__(self) -> None:
|
||||
await init()
|
||||
if self.auto:
|
||||
await auto_init()
|
||||
else:
|
||||
await init()
|
||||
|
||||
|
||||
@cappa.command(help='运行 API 服务', default_long=True)
|
||||
|
||||
@@ -183,7 +183,7 @@ class Settings(BaseSettings):
|
||||
LOG_ERROR_FILENAME: str = 'fba_error.log'
|
||||
|
||||
# .env 操作日志
|
||||
OPERA_LOG_ENCRYPT_SECRET_KEY: str # 密钥 os.urandom(32), 需使用 bytes.hex() 方法转换为 str
|
||||
OPERA_LOG_ENCRYPT_SECRET_KEY: str # secrets.token_hex(32)
|
||||
|
||||
# 操作日志
|
||||
OPERA_LOG_PATH_EXCLUDE: list[str] = [
|
||||
|
||||
Reference in New Issue
Block a user