Refactor config, logging, and startup structure

Introduced pkg modules for environment config, logging, and startup initialization. Replaced direct config and logging setup in main.py with modularized functions. Updated database and migration modules to use environment variables and improved DEBUG handling. Removed tool.py and migrated password utilities to pkg. Cleaned up legacy comments and unused code in models and routes.
This commit is contained in:
2025-10-03 15:00:45 +08:00
parent 815e709339
commit c1c36c606f
16 changed files with 214 additions and 185 deletions

4
.gitignore vendored
View File

@@ -11,4 +11,6 @@ dist/
__pycache__/
# VsCodeCounter Data
.VSCodeCounter/
.VSCodeCounter/
.env

View File

@@ -8,9 +8,6 @@
},
{
"path": "../Findreve-NiceGUI"
},
{
"path": "../vein-based-iov-simulator-main"
}
],
"settings": {}

6
app.py
View File

@@ -33,18 +33,18 @@ app.include_router(object.Router)
@app.get("/")
def read_root():
if not os.path.exists("dist/index.html"):
raise HTTPException(status_code=404, detail="Frontend not built. Please build the frontend first.")
raise HTTPException(status_code=404)
return FileResponse("dist/index.html")
# 回退路由
@app.get("/{path:path}")
async def serve_spa(request: Request, path: str):
if not os.path.exists("dist/index.html"):
raise HTTPException(status_code=404, detail="Frontend not built. Please build the frontend first.")
raise HTTPException(status_code=404)
# 排除API路由
if path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not Found")
raise HTTPException(status_code=404)
# 检查是否是静态资源请求
if path.startswith("assets/") and os.path.exists(f"dist/{path}"):

40
main.py
View File

@@ -1,25 +1,35 @@
# 初始化数据库
import asyncio
from model.database import Database
asyncio.run(Database().init_db())
from loguru import logger
# 导入
# 导入配置模块
from pkg.env import load_config
from pkg.logger import setup_logging
from pkg.startup import startup
# 加载配置
host, port, debug = load_config()
# 配置日志
setup_logging(debug)
# 记录启动信息
logger.info(f"Debug mode: {'enabled' if debug else 'disabled'}")
logger.info(f"Starting Findreve on http://{host}:{port}")
# 导入应用实例
from app import app
from fastapi.staticfiles import StaticFiles
import logging
# 添加静态文件目录
try:
# 挂载静态文件目录
app.mount("/dist", StaticFiles(directory="dist"), name="dist")
except RuntimeError as e:
logging.warning(f'无法挂载静态目录: {str(e)}, 将启动纯后端模式')
# 执行启动流程
startup(app)
# 作为主程序启动时
if __name__ == '__main__':
import uvicorn
# 启动服务器
uvicorn.run(
'app:app',
host='0.0.0.0',
port=8080
host=host,
port=port,
log_config=None, # 禁用 uvicorn 默认的日志配置,使用 loguru
reload=debug, # 调试模式下启用热重载
)

View File

@@ -1,6 +1,8 @@
# ~/models/database.py
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import os
from dotenv import load_dotenv
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.orm import sessionmaker
@@ -9,11 +11,17 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from .migration import migration
ASYNC_DATABASE_URL = "sqlite+aiosqlite:///data.db"
# 加载环境变量
load_dotenv('.env')
# 获取 DEBUG 配置
DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
ASYNC_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///data.db")
engine: AsyncEngine = create_async_engine(
ASYNC_DATABASE_URL,
echo=True,
echo=DEBUG, # 根据 DEBUG 配置决定是否输出 SQL 日志
connect_args={
"check_same_thread": False
} if ASYNC_DATABASE_URL.startswith("sqlite") else {},

View File

@@ -1,7 +1,7 @@
from loguru import logger
from sqlmodel import select
from .setting import Setting
from pkg.password import Password
from pkg import Password
default_settings: list[Setting] = [
Setting(type='string', name='version', value='1.0.0'),
@@ -33,56 +33,4 @@ async def migration(session):
to_insert = [s for s in settings if s.name not in existed]
if to_insert:
# 使用你写好的通用新增方法(是类方法),并传入会话
await Setting.add(session, to_insert, refresh=False)
"""
# 初始化设置表数据
async with db.execute("SELECT name FROM fr_settings WHERE name = 'version'") as cursor:
if not await cursor.fetchone():
await db.execute(
"INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)",
('string', 'version', '1.0.0')
)
logging.info("插入初始版本信息: version 1.0.0")
async with db.execute("SELECT name FROM fr_settings WHERE name = 'ver'") as cursor:
if not await cursor.fetchone():
await db.execute(
"INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)",
('int', 'ver', '1')
)
logging.info("插入初始版本号: ver 1")
async with db.execute("SELECT name FROM fr_settings WHERE name = 'account'") as cursor:
if not await cursor.fetchone():
account = 'admin@yuxiaoqiu.cn'
await db.execute(
"INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)",
('string', 'account', account)
)
logging.info(f"插入初始账号信息: {account}")
print(f"账号: {account}")
async with db.execute("SELECT name FROM fr_settings WHERE name = 'password'") as cursor:
if not await cursor.fetchone():
password = tool.generate_password()
hashed_password = tool.hash_password(password)
await db.execute(
"INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)",
('string', 'password', hashed_password)
)
logging.info("插入初始密码信息")
print(f"密码(请牢记,后续不再显示): {password}")
async with db.execute("SELECT name FROM fr_settings WHERE name = 'SECRET_KEY'") as cursor:
if not await cursor.fetchone():
secret_key = tool.generate_password(64)
await db.execute(
"INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)",
('string', 'SECRET_KEY', secret_key)
)
logging.info("插入初始密钥信息")
await db.commit()
logging.info("数据库初始化完成并提交更改")
"""
await Setting.add(session, to_insert, refresh=False)

View File

@@ -1,5 +1,3 @@
# my_project/models/download.py
from typing import Literal
from sqlmodel import Field, Column, String, DateTime
from .base import TableBase, IdMixin

View File

@@ -1,16 +1,6 @@
# model/setting.py
from sqlmodel import Field
from .base import TableBase
"""
原表:
CREATE TABLE IF NOT EXISTS fr_settings (
type TEXT,
name TEXT PRIMARY KEY,
value TEXT
)
"""
class Setting(TableBase, table=True):
type: str = Field(index=True, nullable=False, description="设置类型")

View File

@@ -1,13 +1,9 @@
from pydantic import BaseModel
from passlib.context import CryptContext
# FastAPI 鉴权模型
# FastAPI authentication model
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
username: str | None = None

1
pkg/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .password import Password

48
pkg/env.py Normal file
View File

@@ -0,0 +1,48 @@
"""
环境变量管理模块
负责 .env 文件的创建和环境变量的加载
"""
import os
from dotenv import load_dotenv
from typing import Tuple
def ensure_env_file(env_file_path: str = '.env') -> None:
"""
确保 .env 文件存在,如果不存在则创建默认配置
:param env_file_path: .env 文件路径
"""
if not os.path.exists(env_file_path):
default_env_content = """HOST=127.0.0.1
PORT=8167
DEBUG=false
DATABASE_URL=sqlite+aiosqlite:///data.db
"""
with open(env_file_path, 'w', encoding='utf-8') as f:
f.write(default_env_content)
print(f"Created default .env file at {env_file_path}")
def load_config() -> Tuple[str, int, bool]:
"""
加载配置信息
:return: (host, port, debug) 元组
"""
# 确保 .env 文件存在
ensure_env_file()
# 从.env文件加载环境变量
load_dotenv('.env')
# 从环境变量中加载主机、端口、调试模式等配置
host = os.getenv("HOST", "127.0.0.1")
port = int(os.getenv("PORT", 8167))
debug = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
return host, port, debug

75
pkg/logger.py Normal file
View File

@@ -0,0 +1,75 @@
"""
日志配置模块
负责配置 loguru 和接管标准库的日志
"""
import sys
import logging
from loguru import logger
class InterceptHandler(logging.Handler):
"""
拦截标准库的日志并转发到 loguru
"""
def emit(self, record: logging.LogRecord) -> None:
# 获取对应的 loguru 级别
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
# 查找调用者的帧
frame, depth = logging.currentframe(), 2
while frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def setup_logging(debug: bool = False) -> None:
"""
配置日志系统
:param debug: 是否启用调试模式
"""
# 配置 loguru
# 移除默认的 handler
logger.remove()
# 根据 DEBUG 模式配置不同的日志级别和格式
if debug:
logger.add(
sys.stderr,
level="DEBUG"
)
else:
logger.add(
sys.stderr,
level="INFO"
)
# 接管 uvicorn 的日志
logging.getLogger("uvicorn").handlers = [InterceptHandler()]
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
# 接管 SQLAlchemy 的日志
logging.getLogger("sqlalchemy.engine").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.pool").handlers = [InterceptHandler()]
# 设置日志级别
if debug:
logging.getLogger("uvicorn").setLevel(logging.DEBUG)
logging.getLogger("uvicorn.access").setLevel(logging.DEBUG)
logging.getLogger("uvicorn.error").setLevel(logging.DEBUG)
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
logging.getLogger("sqlalchemy.pool").setLevel(logging.DEBUG)
else:
logging.getLogger("uvicorn").setLevel(logging.INFO)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)

View File

@@ -9,7 +9,7 @@ class Password():
@staticmethod
def generate(
length: int = 8
length: int = 8
) -> str:
"""
生成指定长度的随机密码。

44
pkg/startup.py Normal file
View File

@@ -0,0 +1,44 @@
"""
应用启动模块
负责应用启动时的初始化工作
"""
import asyncio
from loguru import logger
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from model.database import Database
async def init_database() -> None:
"""
初始化数据库
"""
await Database().init_db()
def mount_static_files(app: FastAPI) -> None:
"""
挂载静态文件目录
:param app: FastAPI 应用实例
"""
try:
app.mount("/dist", StaticFiles(directory="dist"), name="dist")
except RuntimeError as e:
logger.warning(f'Unable to mount static directory: {str(e)}, starting in backend-only mode')
def startup(app: FastAPI) -> None:
"""
执行应用启动流程
:param app: FastAPI 应用实例
"""
# 初始化数据库
asyncio.run(init_database())
# 挂载静态文件
mount_static_files(app)

View File

@@ -6,7 +6,7 @@ from fastapi.security import OAuth2PasswordRequestForm
from fastapi import APIRouter
import jwt, JWT
from sqlmodel.ext.asyncio.session import AsyncSession
from pkg.password import Password
from pkg import Password
from loguru import logger
from model.token import Token

88
tool.py
View File

@@ -1,88 +0,0 @@
from datetime import datetime, timezone
def format_phone(
phone: str,
groups: list[int] | None = None,
separator: str = " ",
private: bool = False
) -> str:
"""
格式化中国大陆的11位手机号
:param phone: 手机号
:param groups: 分组长度列表
:param separator: 分隔符
:param private: 是否隐藏前七位
:return: 格式化后的手机号
"""
if groups is None:
groups = [3, 4, 4]
result = []
start = 0
for i, length in enumerate(groups):
segment = phone[start:start + length]
# 如果是private模式将前两组前7位替换为星号
if private and i < 2:
segment = "*" * length
result.append(segment)
start += length
return separator.join(result)
def format_time_diff(
target_time: datetime | str
) -> str:
"""
计算目标时间与当前时间的差值,返回易读的中文描述
Args:
target_time: 目标时间可以是datetime对象或时间字符串
Returns:
str: 格式化的时间差描述,如"一年前""3个月前"
"""
# 如果输入是字符串先转换为datetime对象
if isinstance(target_time, str):
try:
target_time = datetime.fromisoformat(target_time)
except ValueError:
return "时间格式错误"
now = datetime.now(timezone.utc)
target_time = target_time.astimezone(timezone.utc)
diff = now - target_time
# 如果是未来时间
if diff.total_seconds() < 0:
diff = -diff
suffix = ""
else:
suffix = ""
seconds = diff.total_seconds()
# 定义时间间隔
intervals = [
(31536000, ""),
(2592000, " 个月"),
(86400, ""),
(3600, " 小时"),
(60, " 分钟"),
(1, "")
]
# 计算最适合的时间单位
for seconds_in_unit, unit in intervals:
if seconds >= seconds_in_unit:
value = int(seconds / seconds_in_unit)
if unit == "个月" and value >= 12: # 超过12个月显示为年
continue
return f"{value}{unit}{suffix}"
return f"刚刚"
if __name__ == "__main__":
print(format_phone("18888888888", private=True))
print(format_phone("18888888888", private=False))