diff --git a/.gitignore b/.gitignore index 68fd1fa..d74b66c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ dist/ __pycache__/ # VsCodeCounter Data -.VSCodeCounter/ \ No newline at end of file +.VSCodeCounter/ + +.env \ No newline at end of file diff --git a/Findreve.code-workspace b/Findreve.code-workspace index 1abc245..6470250 100644 --- a/Findreve.code-workspace +++ b/Findreve.code-workspace @@ -8,9 +8,6 @@ }, { "path": "../Findreve-NiceGUI" - }, - { - "path": "../vein-based-iov-simulator-main" } ], "settings": {} diff --git a/app.py b/app.py index b5ebc5e..c0d4a7a 100644 --- a/app.py +++ b/app.py @@ -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}"): diff --git a/main.py b/main.py index 240541f..89cf230 100644 --- a/main.py +++ b/main.py @@ -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, # 调试模式下启用热重载 ) \ No newline at end of file diff --git a/model/database.py b/model/database.py index cc7b8f9..fcaf9f9 100644 --- a/model/database.py +++ b/model/database.py @@ -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 {}, diff --git a/model/migration.py b/model/migration.py index 1b9fdca..a57483e 100644 --- a/model/migration.py +++ b/model/migration.py @@ -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("数据库初始化完成并提交更改") -""" \ No newline at end of file + await Setting.add(session, to_insert, refresh=False) \ No newline at end of file diff --git a/model/object.py b/model/object.py index 853aa62..36e98d2 100644 --- a/model/object.py +++ b/model/object.py @@ -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 diff --git a/model/setting.py b/model/setting.py index 058b543..ea43faf 100644 --- a/model/setting.py +++ b/model/setting.py @@ -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="设置类型") diff --git a/model/token.py b/model/token.py index 87ca038..72f1b45 100644 --- a/model/token.py +++ b/model/token.py @@ -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") \ No newline at end of file + username: str | None = None \ No newline at end of file diff --git a/pkg/__init__.py b/pkg/__init__.py new file mode 100644 index 0000000..5fd47c0 --- /dev/null +++ b/pkg/__init__.py @@ -0,0 +1 @@ +from .password import Password \ No newline at end of file diff --git a/pkg/env.py b/pkg/env.py new file mode 100644 index 0000000..ade6c86 --- /dev/null +++ b/pkg/env.py @@ -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 diff --git a/pkg/logger.py b/pkg/logger.py new file mode 100644 index 0000000..08b8698 --- /dev/null +++ b/pkg/logger.py @@ -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) diff --git a/pkg/password.py b/pkg/password.py index 05d2050..885b1e3 100644 --- a/pkg/password.py +++ b/pkg/password.py @@ -9,7 +9,7 @@ class Password(): @staticmethod def generate( - length: int = 8 + length: int = 8 ) -> str: """ 生成指定长度的随机密码。 diff --git a/pkg/startup.py b/pkg/startup.py new file mode 100644 index 0000000..fd7ee72 --- /dev/null +++ b/pkg/startup.py @@ -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) diff --git a/routes/session.py b/routes/session.py index 236a24b..5dfcbee 100644 --- a/routes/session.py +++ b/routes/session.py @@ -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 diff --git a/tool.py b/tool.py deleted file mode 100644 index 86ce08b..0000000 --- a/tool.py +++ /dev/null @@ -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)) \ No newline at end of file